Spaces:
Sleeping
Sleeping
| # 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") | |
| 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 | |
| # ---------------------------- | |
| 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}} | |
| 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, | |
| } | |
| 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} | |
| 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} | |
| 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} | |
| 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} | |
| def memoryline(user_id: str): | |
| _ = _get_session((user_id or "").strip()) | |
| return {"next_review_label": "T+7", "progress_pct": 0.4} | |
| # ---------------------------- | |
| # SPA Fallback | |
| # ---------------------------- | |
| 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, | |
| ) | |