# api/server.py import os import time from typing import Dict, List, Optional from fastapi import FastAPI, UploadFile, File, Form, Request from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from api.config import DEFAULT_COURSE_TOPICS, DEFAULT_MODEL from api.syllabus_utils import extract_course_topics_from_file from api.rag_engine import build_rag_chunks_from_file, retrieve_relevant_chunks from api.clare_core import ( detect_language, chat_with_clare, update_weaknesses_from_message, update_cognitive_state_from_message, render_session_status, export_conversation, summarize_conversation, ) # ✅ LangSmith (same idea as your Gradio app.py) try: from langsmith import Client except Exception: Client = None # ---------------------------- # Paths / Constants # ---------------------------- API_DIR = os.path.dirname(__file__) MODULE10_PATH = os.path.join(API_DIR, "module10_responsible_ai.pdf") MODULE10_DOC_TYPE = "Literature Review / Paper" WEB_DIST = os.path.abspath(os.path.join(API_DIR, "..", "web", "build")) WEB_INDEX = os.path.join(WEB_DIST, "index.html") WEB_ASSETS = os.path.join(WEB_DIST, "assets") # ✅ LangSmith dataset name (match what you used before) LS_DATASET_NAME = os.getenv("LS_DATASET_NAME", "clare_user_events").strip() LS_PROJECT = os.getenv("LANGSMITH_PROJECT", os.getenv("LANGCHAIN_PROJECT", "")).strip() # optional # ---------------------------- # App # ---------------------------- app = FastAPI(title="Clare API") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ---------------------------- # Static hosting (Vite build) # ---------------------------- if os.path.isdir(WEB_ASSETS): app.mount("/assets", StaticFiles(directory=WEB_ASSETS), name="assets") if os.path.isdir(WEB_DIST): app.mount("/static", StaticFiles(directory=WEB_DIST), name="static") @app.get("/") def index(): if os.path.exists(WEB_INDEX): return FileResponse(WEB_INDEX) return JSONResponse( {"detail": "web/build not found. Build frontend first (web/build/index.html)."}, status_code=500, ) # ---------------------------- # In-memory session store (MVP) # ---------------------------- SESSIONS: Dict[str, Dict] = {} def _preload_module10_chunks(): if os.path.exists(MODULE10_PATH): try: return build_rag_chunks_from_file(MODULE10_PATH, MODULE10_DOC_TYPE) or [] except Exception as e: print(f"[preload] module10 parse failed: {repr(e)}") return [] return [] MODULE10_CHUNKS_CACHE = _preload_module10_chunks() def _get_session(user_id: str) -> Dict: if user_id not in SESSIONS: SESSIONS[user_id] = { "user_id": user_id, "name": "", "history": [], "weaknesses": [], "cognitive_state": {"confusion": 0, "mastery": 0}, "course_outline": DEFAULT_COURSE_TOPICS, "rag_chunks": list(MODULE10_CHUNKS_CACHE), "model_name": DEFAULT_MODEL, } return SESSIONS[user_id] # ---------------------------- # LangSmith helpers # ---------------------------- _ls_client = None if Client is not None: try: _ls_client = Client() except Exception as e: print("[langsmith] init failed:", repr(e)) _ls_client = None def _log_event_to_langsmith(data: Dict): """ Create an Example in LangSmith Dataset (clare_user_events). Mimic your previous Gradio log_event behavior. Inputs/Outputs show up as "Inputs" / "Reference Outputs". Everything else goes into metadata columns. """ if _ls_client is None: return try: inputs = { "question": data.get("question", ""), "student_id": data.get("student_id", ""), "student_name": data.get("student_name", ""), } outputs = {"answer": data.get("answer", "")} metadata = {k: v for k, v in data.items() if k not in ("question", "answer")} # helpful for filtering in UI if LS_PROJECT: metadata.setdefault("langsmith_project", LS_PROJECT) _ls_client.create_example( inputs=inputs, outputs=outputs, metadata=metadata, dataset_name=LS_DATASET_NAME, ) except Exception as e: print("[langsmith] log failed:", repr(e)) # ---------------------------- # Schemas # ---------------------------- class LoginReq(BaseModel): name: str user_id: str class ChatReq(BaseModel): user_id: str message: str learning_mode: str language_preference: str = "Auto" doc_type: str = "Syllabus" class ExportReq(BaseModel): user_id: str learning_mode: str class SummaryReq(BaseModel): user_id: str learning_mode: str language_preference: str = "Auto" class FeedbackReq(BaseModel): user_id: str rating: str # "helpful" | "not_helpful" assistant_message_id: Optional[str] = None # what the user is rating assistant_text: str user_text: Optional[str] = "" # optional free-text comment comment: Optional[str] = "" # context for analysis refs: Optional[List[str]] = [] learning_mode: Optional[str] = None doc_type: Optional[str] = None timestamp_ms: Optional[int] = None # ---------------------------- # API Routes # ---------------------------- @app.post("/api/login") def login(req: LoginReq): user_id = (req.user_id or "").strip() name = (req.name or "").strip() if not user_id or not name: return JSONResponse({"ok": False, "error": "Missing name/user_id"}, status_code=400) sess = _get_session(user_id) sess["name"] = name return {"ok": True, "user": {"name": name, "user_id": user_id}} @app.post("/api/chat") def chat(req: ChatReq): user_id = (req.user_id or "").strip() msg = (req.message or "").strip() if not user_id: return JSONResponse({"error": "Missing user_id"}, status_code=400) sess = _get_session(user_id) if not msg: return { "reply": "", "session_status_md": render_session_status( req.learning_mode, sess["weaknesses"], sess["cognitive_state"] ), "refs": [], "latency_ms": 0.0, } resolved_lang = detect_language(msg, req.language_preference) sess["weaknesses"] = update_weaknesses_from_message(msg, sess["weaknesses"]) sess["cognitive_state"] = update_cognitive_state_from_message(msg, sess["cognitive_state"]) rag_context_text, rag_used_chunks = retrieve_relevant_chunks(msg, sess["rag_chunks"]) start_ts = time.time() try: answer, new_history = chat_with_clare( message=msg, history=sess["history"], model_name=sess["model_name"], language_preference=resolved_lang, learning_mode=req.learning_mode, doc_type=req.doc_type, course_outline=sess["course_outline"], weaknesses=sess["weaknesses"], cognitive_state=sess["cognitive_state"], rag_context=rag_context_text, ) except Exception as e: print(f"[chat] error: {repr(e)}") return JSONResponse({"error": f"chat failed: {repr(e)}"}, status_code=500) latency_ms = (time.time() - start_ts) * 1000.0 sess["history"] = new_history refs = [ {"source_file": c.get("source_file"), "section": c.get("section")} for c in (rag_used_chunks or []) ] # ✅ log chat_turn to LangSmith (uses login name/id; NO hardcoding) _log_event_to_langsmith( { "experiment_id": "RESP_AI_W10", "student_id": user_id, "student_name": sess.get("name", ""), "event_type": "chat_turn", "timestamp": time.time(), "latency_ms": latency_ms, "question": msg, "answer": answer, "model_name": sess["model_name"], "language": resolved_lang, "learning_mode": req.learning_mode, "doc_type": req.doc_type, "refs": refs, } ) return { "reply": answer, "session_status_md": render_session_status( req.learning_mode, sess["weaknesses"], sess["cognitive_state"] ), "refs": refs, "latency_ms": latency_ms, } @app.post("/api/upload") async def upload( user_id: str = Form(...), doc_type: str = Form(...), file: UploadFile = File(...), ): user_id = (user_id or "").strip() doc_type = (doc_type or "").strip() if not user_id: return JSONResponse({"ok": False, "error": "Missing user_id"}, status_code=400) if not file or not file.filename: return JSONResponse({"ok": False, "error": "Missing file"}, status_code=400) sess = _get_session(user_id) safe_name = os.path.basename(file.filename).replace("..", "_") tmp_path = os.path.join("/tmp", safe_name) content = await file.read() with open(tmp_path, "wb") as f: f.write(content) if doc_type == "Syllabus": class _F: pass fo = _F() fo.name = tmp_path try: sess["course_outline"] = extract_course_topics_from_file(fo, doc_type) except Exception as e: print(f"[upload] syllabus parse error: {repr(e)}") try: new_chunks = build_rag_chunks_from_file(tmp_path, doc_type) or [] sess["rag_chunks"] = (sess["rag_chunks"] or []) + new_chunks except Exception as e: print(f"[upload] rag build error: {repr(e)}") new_chunks = [] status_md = f"✅ Loaded base reading + uploaded {doc_type} file." # ✅ optional: log upload event _log_event_to_langsmith( { "experiment_id": "RESP_AI_W10", "student_id": user_id, "student_name": sess.get("name", ""), "event_type": "upload", "timestamp": time.time(), "doc_type": doc_type, "filename": safe_name, "added_chunks": len(new_chunks), "question": f"[upload] {safe_name}", "answer": status_md, } ) return {"ok": True, "added_chunks": len(new_chunks), "status_md": status_md} @app.post("/api/feedback") def api_feedback(req: FeedbackReq): user_id = (req.user_id or "").strip() if not user_id: return JSONResponse({"ok": False, "error": "Missing user_id"}, status_code=400) sess = _get_session(user_id) student_name = sess.get("name", "") rating = (req.rating or "").strip().lower() if rating not in ("helpful", "not_helpful"): return JSONResponse({"ok": False, "error": "Invalid rating"}, status_code=400) # ✅ record feedback as its own event row in the SAME dataset _log_event_to_langsmith( { "experiment_id": "RESP_AI_W10", "student_id": user_id, "student_name": student_name, "event_type": "feedback", "timestamp": time.time(), "rating": rating, "assistant_message_id": req.assistant_message_id, "question": (req.user_text or "").strip(), "answer": (req.assistant_text or "").strip(), "comment": (req.comment or "").strip(), "refs": req.refs or [], "learning_mode": req.learning_mode, "doc_type": req.doc_type, "timestamp_ms": req.timestamp_ms, } ) return {"ok": True} @app.post("/api/export") def api_export(req: ExportReq): user_id = (req.user_id or "").strip() if not user_id: return JSONResponse({"error": "Missing user_id"}, status_code=400) sess = _get_session(user_id) md = export_conversation( sess["history"], sess["course_outline"], req.learning_mode, sess["weaknesses"], sess["cognitive_state"], ) return {"markdown": md} @app.post("/api/summary") def api_summary(req: SummaryReq): user_id = (req.user_id or "").strip() if not user_id: return JSONResponse({"error": "Missing user_id"}, status_code=400) sess = _get_session(user_id) md = summarize_conversation( sess["history"], sess["course_outline"], sess["weaknesses"], sess["cognitive_state"], sess["model_name"], req.language_preference, ) return {"markdown": md} @app.get("/api/memoryline") def memoryline(user_id: str): _ = _get_session((user_id or "").strip()) return {"next_review_label": "T+7", "progress_pct": 0.4} # ---------------------------- # SPA Fallback # ---------------------------- @app.get("/{full_path:path}") def spa_fallback(full_path: str, request: Request): if ( full_path.startswith("api/") or full_path.startswith("assets/") or full_path.startswith("static/") ): return JSONResponse({"detail": "Not Found"}, status_code=404) if os.path.exists(WEB_INDEX): return FileResponse(WEB_INDEX) return JSONResponse( {"detail": "web/build not found. Build frontend first (web/build/index.html)."}, status_code=500, )