File size: 11,873 Bytes
9d76e23
 
ca2bd00
 
9d76e23
ca2bd00
 
9d76e23
 
 
ca2bd00
9d76e23
 
 
 
 
 
ca2bd00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d76e23
 
 
 
 
 
 
 
 
 
 
 
ca2bd00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d76e23
 
 
 
 
ca2bd00
9d76e23
 
 
ca2bd00
9d76e23
 
 
 
 
ca2bd00
9d76e23
 
 
 
 
60572ef
 
 
 
 
 
 
 
ca2bd00
9d76e23
 
 
ca2bd00
 
 
 
 
 
 
 
60572ef
 
ca2bd00
60572ef
 
 
 
 
 
 
 
 
 
 
 
 
 
9d76e23
60572ef
 
9d76e23
ca2bd00
9d76e23
 
 
 
 
 
 
ca2bd00
9d76e23
 
 
 
 
ca2bd00
 
 
 
 
 
 
9d76e23
ca2bd00
 
 
 
 
 
9d76e23
ca2bd00
 
9d76e23
ca2bd00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d76e23
 
 
 
 
 
 
ca2bd00
9d76e23
 
 
 
 
ca2bd00
9d76e23
 
 
 
ca2bd00
9d76e23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
import logging
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime

from src.core.recommender import recommender
from src.database.mongodb import mongodb
from src.config.settings import API_TITLE, API_DESCRIPTION, API_VERSION

logger = logging.getLogger(__name__)
# Configure basic logging if not already set up elsewhere
if not logger.hasHandlers():
    logging.basicConfig(level=logging.INFO)

# Create a new FastAPI app instance instead of importing from src.main
app = FastAPI(
    title=API_TITLE,
    description=API_DESCRIPTION,
    version=API_VERSION
)

# Add CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Initialize recommender on startup
@app.on_event("startup")
async def startup_event():
    """Initialize recommender system on startup."""
    try:
        logger.info("Initializing recommender system...")
        recommender.load_components()
        app.state.recommender = recommender
        logger.info("Recommender system initialized successfully")
    except Exception as e:
        logger.error(f"Failed to initialize recommender system: {e}", exc_info=True)
        # Don't raise here to allow the app to start even if recommender fails

@app.on_event("shutdown")
async def shutdown_event():
    """Cleanup on shutdown."""
    try:
        mongodb.close()
        logger.info("MongoDB connection closed")
    except Exception as e:
        logger.error(f"Error during shutdown: {e}", exc_info=True)

# Pydantic models for request/response bodies
class FeedbackPayload(BaseModel):
    user_id: str
    msid: str
    clicked_msid: str  # Comma-separated string of MSIDs
    k: int = Field(default=5, ge=1, le=10)

class FeedbackResponse(BaseModel):
    message: str

# API Endpoints
@app.get("/health")
async def health_check():
    """
    Health check endpoint to diagnose system status.
    """
    health_status = {
        "status": "healthy",
        "timestamp": datetime.now().isoformat(),
        "components": {}
    }
    
    # Check recommender system
    if hasattr(app.state, "recommender") and app.state.recommender is not None:
        health_status["components"]["recommender"] = {
            "status": "available",
            "models_loaded": {
                "embed_model": app.state.recommender.embed_model is not None,
                "reranker": app.state.recommender.reranker is not None,
                "generator": app.state.recommender.generator is not None
            },
            "data_available": app.state.recommender.df is not None and not app.state.recommender.df.empty,
            "faiss_index_available": app.state.recommender.index is not None,
            "faiss_vectors": app.state.recommender.index.ntotal if app.state.recommender.index else 0
        }
    else:
        health_status["components"]["recommender"] = {"status": "not_available"}
        health_status["status"] = "degraded"
    
    # Check MongoDB connection
    try:
        if mongodb.db is not None:
            # Try a simple operation to test connection
            mongodb.db.command("ping")
            health_status["components"]["mongodb"] = {"status": "connected"}
        else:
            health_status["components"]["mongodb"] = {"status": "not_connected"}
            health_status["status"] = "degraded"
    except Exception as e:
        health_status["components"]["mongodb"] = {
            "status": "error",
            "error": str(e)
        }
        health_status["status"] = "degraded"
    
    return health_status

@app.get("/recommendations/")
async def get_recommendations_api(query: str, k: int = 5):
    """
    Get recommendations based on a textual query.
    """
    try:
        if not hasattr(app.state, "recommender") or app.state.recommender is None:
            logger.error("Recommender is not available.")
            raise HTTPException(status_code=503, detail="Recommender service not available")
        
        response = app.state.recommender.get_recommendations(query, k)
        return response
    except Exception as e:
        logger.error(f"API Error in get_recommendations_api for query '{query}': {e}", exc_info=True)
        raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}")

@app.get("/recommendations/msid/")
async def get_recommendations_by_id_api(msid: str, k: int = 5):
    """
    Get recommendations based on a given MSID.
    """
    try:
        # Validate input parameters
        if not msid or not isinstance(msid, str):
            raise HTTPException(status_code=400, detail="Invalid MSID provided")
            
        if not isinstance(k, int) or k < 1 or k > 10:
            raise HTTPException(status_code=400, detail="k must be an integer between 1 and 10")

        # Check if recommender service is available
        if not hasattr(app.state, "recommender") or app.state.recommender is None:
            logger.error("Recommender is not available.")
            raise HTTPException(status_code=503, detail="Recommender service not available")

        # Check if recommender has the necessary data
        if app.state.recommender.df is None or app.state.recommender.df.empty:
            logger.error("Recommender data not available (MongoDB connection issue).")
            raise HTTPException(
                status_code=503, 
                detail="Recommender data not available. The service is currently unable to access the required data."
            )

        # Get recommendations with error handling
        try:
            response = app.state.recommender.get_recommendations_by_id(msid, k)
            if not response:
                raise HTTPException(status_code=404, detail=f"No recommendations found for MSID: {msid}")
            return response
            
        except ValueError as ve:
            logger.error(f"Value error in get_recommendations_by_id for msid '{msid}': {ve}")
            raise HTTPException(status_code=400, detail=str(ve))
            
        except Exception as e:
            logger.error(f"Error getting recommendations for msid '{msid}': {e}", exc_info=True)
            raise HTTPException(status_code=500, detail="Internal server error while getting recommendations")
            
    except HTTPException as he:
        raise he
    except Exception as e:
        logger.error(f"Unexpected error in get_recommendations_by_id_api for msid '{msid}': {e}", exc_info=True)
        raise HTTPException(status_code=500, detail="An unexpected error occurred")

@app.post("/recommendations/feedback/user/", response_model=FeedbackResponse)
async def submit_user_feedback_api(payload: FeedbackPayload):
    """
    Submit user feedback (e.g., clicked articles) and save it.
    Optionally, this endpoint can also trigger re-computation of recommendations based on feedback,
    though the primary response here is the status of feedback submission.
    """
    try:
        if not hasattr(app.state, "recommender") or app.state.recommender is None:
            logger.error("Recommender is not available.")
            raise HTTPException(status_code=503, detail="Recommender service not available")

        # (Optional) Compute recommendations based on feedback, similar to Gradio function.
        # The result of this call is not the primary output of this API endpoint.
        try:
            _ = app.state.recommender.get_recommendations_user_feedback(
                payload.user_id, payload.msid, payload.clicked_msid, payload.k
            )
            logger.info(f"API: (Computed recommendations for user '{payload.user_id}' based on click, not part of this response)")
        except Exception as e:
            logger.warning(f"Could not compute recommendations based on feedback: {e}")

        # Save feedback to MongoDB (optional - only if MongoDB is available)
        try:
            actual_clicked_msids = [s.strip() for s in payload.clicked_msid.split(',') if s.strip()]
            if not actual_clicked_msids:
                logger.warning(f"API: Invalid clicked_msid: '{payload.clicked_msid}' for user '{payload.user_id}'")
                raise HTTPException(status_code=400, detail="clicked_msid parameter is invalid or does not contain valid MSIDs.")

            logger.info(
                f"API: Saving feedback for user '{payload.user_id}', context msid: '{payload.msid}', clicked msids: {actual_clicked_msids}"
            )
            
            feedback_collection_name = "user_feedback_tracking"
            # Check if MongoDB is available
            if mongodb.db is None:
                logger.warning("MongoDB database connection is not available. Skipping feedback storage.")
                return FeedbackResponse(message="Response processed successfully (feedback storage unavailable)")
                
            feedback_collection = mongodb.db[feedback_collection_name]
            user_doc = feedback_collection.find_one({"user_id": payload.user_id})

            if user_doc:
                feedback_collection.update_one(
                    {"user_id": payload.user_id},
                    {"$addToSet": {"Articles": {"msid": payload.msid, "Read": actual_clicked_msids}}}
                )
            else:
                feedback_collection.insert_one({
                    "user_id": payload.user_id,
                    "Articles": [{"msid": payload.msid, "Read": actual_clicked_msids}]
                })
            
            logger.info(f"API: Successfully saved feedback for user '{payload.user_id}'")
            return FeedbackResponse(message="Response saved successfully")
            
        except Exception as e:
            logger.error(f"Error saving feedback to MongoDB: {e}", exc_info=True)
            # Don't fail the entire request if MongoDB is unavailable
            return FeedbackResponse(message="Response processed successfully (feedback storage failed)")

    except HTTPException:
        raise # Re-raise HTTPException directly
    except Exception as e:
        logger.error(f"API Error in submit_user_feedback_api for user '{payload.user_id}': {e}", exc_info=True)
        raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}")

@app.get("/recommendations/summary/")
async def get_recommendations_summary_api(msid: str, k: int = 5, summary: bool = True, smart_tip: bool = True):
    """
    Get recommendations with optional summary and smart tip for a given MSID.
    """
    try:
        if not hasattr(app.state, "recommender") or app.state.recommender is None:
            logger.error("Recommender is not available.")
            raise HTTPException(status_code=503, detail="Recommender service not available")

        try:
            response = app.state.recommender.get_recommendations_summary(msid, k, summary, smart_tip)
        except RuntimeError as e:
            # Catch the meta tensor error and return a fallback
            if "meta tensor" in str(e):
                logger.error("Summary model error: %s", e)
                response = {
                    "msid": msid,
                    "recommendations": [],
                    "summary": [],
                    "smart_tip": [],
                    "error": "Summary model is not available on this server."
                }
            else:
                raise

        return response
    except Exception as e:
        logger.error(f"API Error in get_recommendations_summary_api for msid '{msid}': {e}", exc_info=True)
        raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}")

if __name__ == "__main__":
    uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)