Spaces:
Sleeping
Sleeping
| # api/server.py | |
| import asyncio | |
| import datetime | |
| import json | |
| import logging | |
| import os | |
| import time | |
| import threading | |
| from typing import Dict, List, Optional, Any, Tuple | |
| import secrets | |
| from fastapi import FastAPI, UploadFile, File, Form, Request, Depends, HTTPException, status | |
| from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.security import HTTPBasic, HTTPBasicCredentials | |
| from pydantic import BaseModel | |
| from api.config import DEFAULT_COURSE_TOPICS, DEFAULT_MODEL, async_client | |
| 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, | |
| generate_quiz_for_external, | |
| generate_suggested_questions, | |
| ) | |
| from api.tts_podcast import ( | |
| text_to_speech, | |
| build_podcast_script_from_history, | |
| build_podcast_script_from_summary, | |
| generate_podcast_audio, | |
| ) | |
| from api.weaviate_retrieve import retrieve_from_weaviate_with_refs | |
| # ✅ NEW: course directory + workspace schema routes | |
| from api.routes_directory import router as directory_router | |
| # ✅ 教师 Agent:课程描述、文档建议、作业题库、学习评估 | |
| from api.routes_teacher import router as teacher_router | |
| # ✅ Courseware:课程愿景、活动设计、课堂助教、QA 优化、教案与 PPT | |
| from api.routes_courseware import router as courseware_router | |
| from api.routes_courseware_ai import router as courseware_ai_router | |
| # DB persistence (graceful degradation if DATABASE_URL is unset) | |
| from api import db as db_module | |
| # ✅ LangSmith (optional) | |
| try: | |
| from langsmith import Client | |
| except Exception: | |
| Client = None | |
| # --------------------------------------------------------------------------- | |
| # Logging - set CLARE_LOG_LEVEL=DEBUG in .env for verbose output | |
| # --------------------------------------------------------------------------- | |
| _log_level = os.getenv("CLARE_LOG_LEVEL", "INFO").strip().upper() | |
| logging.basicConfig( | |
| level=getattr(logging, _log_level, logging.INFO), | |
| format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", | |
| datefmt="%H:%M:%S", | |
| ) | |
| log = logging.getLogger("clare.server") | |
| logging.getLogger("httpx").setLevel(logging.WARNING) | |
| logging.getLogger("sentence_transformers").setLevel(logging.WARNING) | |
| logging.getLogger("huggingface_hub").setLevel(logging.WARNING) | |
| # ---------------------------- | |
| # Paths / Constants | |
| # ---------------------------- | |
| API_DIR = os.path.dirname(__file__) | |
| MODULE10_PATH = os.path.join(API_DIR, "module10_responsible_ai.pdf") # legacy fallback | |
| MODULE10_DIR = os.path.abspath(os.path.join(API_DIR, "..", "Module 10")) | |
| 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") | |
| LS_DATASET_NAME = os.getenv("LS_DATASET_NAME", "clare_user_events").strip() | |
| LS_PROJECT = os.getenv("LANGSMITH_PROJECT", os.getenv("LANGCHAIN_PROJECT", "")).strip() | |
| EXPERIMENT_ID = os.getenv("CLARE_EXPERIMENT_ID", "RESP_AI_W10").strip() | |
| # Max user-uploaded chunks stored per session (module10 base is referenced globally) | |
| MAX_UPLOAD_CHUNKS = int(os.getenv("CLARE_MAX_UPLOAD_CHUNKS", "500")) | |
| # 每 1000 tokens 的估算成本(美元),用于 per-student 成本统计;默认 0 表示不计算 | |
| try: | |
| TOKEN_COST_PER_1K = float(os.getenv("CLARE_TOKEN_COST_PER_1K", "0").strip() or 0.0) | |
| except Exception: | |
| TOKEN_COST_PER_1K = 0.0 | |
| # 方案三:Clare 调用 GenAICoursesDB 向量知识库。设为 HF Space ID 或完整 URL 时启用 | |
| GENAI_COURSES_SPACE = (os.getenv("GENAI_COURSES_SPACE") or "").strip() | |
| # ---------------------------- | |
| # Health / Warmup (cold start mitigation) | |
| # ---------------------------- | |
| APP_START_TS = time.time() | |
| WARMUP_DONE = False | |
| WARMUP_ERROR: Optional[str] = None | |
| WARMUP_STARTED = False | |
| CLARE_ENABLE_WARMUP = os.getenv("CLARE_ENABLE_WARMUP", "1").strip() == "1" | |
| CLARE_WARMUP_BLOCK_READY = os.getenv("CLARE_WARMUP_BLOCK_READY", "0").strip() == "1" | |
| # Dataset logging (create_example) | |
| CLARE_ENABLE_LANGSMITH_LOG = os.getenv("CLARE_ENABLE_LANGSMITH_LOG", "0").strip() == "1" | |
| CLARE_LANGSMITH_ASYNC = os.getenv("CLARE_LANGSMITH_ASYNC", "1").strip() == "1" | |
| # Feedback logging (create_feedback -> attach to run_id) | |
| CLARE_ENABLE_LANGSMITH_FEEDBACK = os.getenv("CLARE_ENABLE_LANGSMITH_FEEDBACK", "1").strip() == "1" | |
| # ---------------------------- | |
| # App | |
| # ---------------------------- | |
| app = FastAPI(title="Clare API") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ✅ NEW: include directory/workspace APIs BEFORE SPA fallback | |
| app.include_router(directory_router) | |
| app.include_router(teacher_router) | |
| app.include_router(courseware_router) | |
| app.include_router(courseware_ai_router) | |
| # ---------------------------- | |
| # Static hosting (Vite build) | |
| # ---------------------------- | |
| print(f"[DEBUG] WEB_DIST: {WEB_DIST}") | |
| print(f"[DEBUG] WEB_INDEX: {WEB_INDEX}") | |
| print(f"[DEBUG] WEB_ASSETS: {WEB_ASSETS}") | |
| print(f"[DEBUG] WEB_INDEX exists: {os.path.exists(WEB_INDEX)}") | |
| print(f"[DEBUG] WEB_ASSETS exists: {os.path.isdir(WEB_ASSETS)}") | |
| print(f"[DEBUG] WEB_DIST exists: {os.path.isdir(WEB_DIST)}") | |
| # 启动时打印 RAG/知识库状态(学生端用 module10+GenAI Courses,教师端可选 Weaviate) | |
| try: | |
| from api.config import USE_WEAVIATE | |
| if USE_WEAVIATE: | |
| print("[Clare] Weaviate configured (teacher/courseware RAG).") | |
| else: | |
| print("[Clare] Weaviate not configured (optional for teacher/courseware).") | |
| except Exception: | |
| print("[Clare] Weaviate config check skipped.") | |
| if GENAI_COURSES_SPACE: | |
| print(f"[Clare] GenAI Courses Space connected: {GENAI_COURSES_SPACE[:60]}...") | |
| else: | |
| print("[Clare] GenAI Courses Space not set (student RAG uses module10 only).") | |
| if os.path.isdir(WEB_ASSETS): | |
| print(f"[DEBUG] Mounting /assets from {WEB_ASSETS}") | |
| app.mount("/assets", StaticFiles(directory=WEB_ASSETS), name="assets") | |
| else: | |
| print(f"[WARNING] WEB_ASSETS directory not found: {WEB_ASSETS}") | |
| if os.path.isdir(WEB_DIST): | |
| print(f"[DEBUG] Mounting /static from {WEB_DIST}") | |
| app.mount("/static", StaticFiles(directory=WEB_DIST), name="static") | |
| else: | |
| print(f"[WARNING] WEB_DIST directory not found: {WEB_DIST}") | |
| # 禁止缓存 index.html,避免旧 HTML 引用已不存在的 hashed 资源导致 404 白屏 | |
| NO_CACHE_HEADERS = { | |
| "Cache-Control": "no-store, no-cache, must-revalidate", | |
| "Pragma": "no-cache", | |
| "Expires": "0", | |
| } | |
| def index(): | |
| if os.path.exists(WEB_INDEX): | |
| print(f"[DEBUG] Serving index.html from {WEB_INDEX}") | |
| return FileResponse(WEB_INDEX, headers=NO_CACHE_HEADERS) | |
| print(f"[ERROR] WEB_INDEX not found: {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[str, Any]] = {} | |
| def _preload_module10_chunks() -> List[Dict[str, Any]]: | |
| chunks: List[Dict[str, Any]] = [] | |
| print(f"[preload] MODULE10_DIR={MODULE10_DIR!r} exists={os.path.isdir(MODULE10_DIR)}") | |
| # Load all files from Module 10/ folder | |
| if os.path.isdir(MODULE10_DIR): | |
| for fname in sorted(os.listdir(MODULE10_DIR)): | |
| if not fname.endswith((".md", ".pdf", ".docx", ".pptx", ".txt")): | |
| continue | |
| path = os.path.join(MODULE10_DIR, fname) | |
| try: | |
| new = build_rag_chunks_from_file(path, MODULE10_DOC_TYPE) or [] | |
| print(f"[preload] {fname}: {len(new)} chunks") | |
| chunks.extend(new) | |
| except Exception as e: | |
| print(f"[preload] {fname} failed: {repr(e)}") | |
| print(f"[preload] total module10 chunks: {len(chunks)}") | |
| return chunks | |
| # Legacy fallback: single PDF | |
| 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)}") | |
| print("[preload] WARNING: no Module 10 content found — RAG will be empty") | |
| return [] | |
| MODULE10_CHUNKS_CACHE: List[Dict[str, Any]] = [] | |
| def _run_preload_in_background(): | |
| import threading | |
| def _load(): | |
| global MODULE10_CHUNKS_CACHE | |
| chunks = _preload_module10_chunks() | |
| MODULE10_CHUNKS_CACHE = chunks | |
| print(f"[preload] background load complete: {len(chunks)} chunks ready") | |
| threading.Thread(target=_load, daemon=True).start() | |
| _run_preload_in_background() | |
| def _build_faiss_index(chunks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: | |
| """Build and cache FAISS index from chunks. Returns cached index dict or None.""" | |
| if not chunks: | |
| return None | |
| from api.rag_engine import VectorStore | |
| try: | |
| vs = VectorStore() | |
| vs.build_index(chunks) | |
| cached = vs.get_cached() | |
| return cached | |
| except Exception as e: | |
| log.error("failed to build FAISS index: %r", e) | |
| return None | |
| def _get_session(session_id: str) -> Dict[str, Any]: | |
| if session_id not in SESSIONS: | |
| SESSIONS[session_id] = { | |
| "session_id": session_id, | |
| "login_id": "", | |
| "history": [], # List[Tuple[str, str]] | |
| "weaknesses": [], | |
| "cognitive_state": {"confusion": 0, "mastery": 0}, | |
| "course_outline": DEFAULT_COURSE_TOPICS, | |
| "rag_chunks": [], # user-uploaded chunks only; module10 merged at retrieval | |
| "model_name": DEFAULT_MODEL, | |
| "uploaded_files": [], | |
| # NEW: profile init (MVP in-memory) | |
| "profile_bio": "", | |
| "init_answers": {}, | |
| "init_dismiss_until": 0, | |
| "faiss_index": None, # Cached FAISS index (built at init and on upload) | |
| "chat_id": None, # Active chat DB row (set at login, updated on new chat) | |
| "chat_turn_count": 0, # Turns in current chat (used for auto-rename after 1st message) | |
| } | |
| # Build initial FAISS index with MODULE10_CHUNKS_CACHE | |
| initial_chunks = MODULE10_CHUNKS_CACHE | |
| SESSIONS[session_id]["faiss_index"] = _build_faiss_index(initial_chunks) | |
| if "uploaded_files" not in SESSIONS[session_id]: | |
| SESSIONS[session_id]["uploaded_files"] = [] | |
| # NEW backfill | |
| SESSIONS[session_id].setdefault("profile_bio", "") | |
| SESSIONS[session_id].setdefault("init_answers", {}) | |
| SESSIONS[session_id].setdefault("init_dismiss_until", 0) | |
| SESSIONS[session_id].setdefault("faiss_index", None) | |
| return SESSIONS[session_id] | |
| # NEW: helper to build a deterministic "what files are loaded" hint for the LLM | |
| def _build_upload_hint(sess: Dict[str, Any]) -> str: | |
| files = sess.get("uploaded_files") or [] | |
| if not files: | |
| # Still mention that base reading is available | |
| return ( | |
| "Files available to you in this session:\n" | |
| "- Base reading: module10_responsible_ai.pdf (pre-loaded)\n" | |
| "If the student asks about an uploaded file but none exist, ask them to upload." | |
| ) | |
| lines = [ | |
| "Files available to you in this session:", | |
| "- Base reading: module10_responsible_ai.pdf (pre-loaded)", | |
| ] | |
| # show last few only to keep prompt small | |
| for f in files[-5:]: | |
| fn = (f.get("filename") or "").strip() | |
| dt = (f.get("doc_type") or "").strip() | |
| chunks = f.get("added_chunks") | |
| lines.append(f"- Uploaded: {fn} (doc_type={dt}, added_chunks={chunks})") | |
| lines.append( | |
| "When the student asks to summarize/read 'the uploaded file', interpret it as the MOST RECENT uploaded file unless specified." | |
| ) | |
| return "\n".join(lines) | |
| # NEW: force RAG on short "document actions" so refs exist | |
| def _should_force_rag(message: str) -> bool: | |
| m = (message or "").lower() | |
| if not m: | |
| return False | |
| triggers = [ | |
| "summarize", "summary", "read", "analyze", "explain", | |
| "the uploaded file", "uploaded", "file", "document", "pdf", | |
| "slides", "ppt", "syllabus", "lecture", | |
| "总结", "概括", "阅读", "读一下", "解析", "分析", "这份文件", "上传", "文档", "课件", "讲义", | |
| ] | |
| return any(t in m for t in triggers) | |
| def _retrieve_from_genai_courses(question: str, top_k: int = 5) -> str: | |
| """调用 GenAICoursesDB Space 的 retrieve 接口,获取课程检索结果。""" | |
| if not GENAI_COURSES_SPACE or len(question.strip()) < 5: | |
| return "" | |
| try: | |
| from gradio_client import Client | |
| client = Client(GENAI_COURSES_SPACE) | |
| result = client.predict(question, api_name="/retrieve") | |
| return (result or "").strip() | |
| except Exception as e: | |
| print(f"[genai_courses] retrieve failed: {repr(e)}") | |
| return "" | |
| def _extract_filename_hint(message: str) -> Optional[str]: | |
| m = (message or "").strip() | |
| if not m: | |
| return None | |
| # 极简:如果用户直接提到了 .pdf/.ppt/.docx 文件名,就用它 | |
| for token in m.replace(""", '"').replace(""", '"').split(): | |
| if any(token.lower().endswith(ext) for ext in [".pdf", ".ppt", ".pptx", ".doc", ".docx"]): | |
| return os.path.basename(token.strip('"').strip("'").strip()) | |
| return None | |
| def _resolve_rag_scope(sess: Dict[str, Any], msg: str) -> Tuple[Optional[List[str]], Optional[List[str]]]: | |
| """ | |
| Return (allowed_source_files, allowed_doc_types) | |
| - If user is asking about "uploaded file"/document action -> restrict to latest uploaded file. | |
| - If message contains an explicit filename -> restrict to that filename if we have it. | |
| - Else no restriction (None, None). | |
| """ | |
| files = sess.get("uploaded_files") or [] | |
| msg_l = (msg or "").lower() | |
| # 1) explicit filename mentioned | |
| hinted = _extract_filename_hint(msg) | |
| if hinted: | |
| # only restrict if that file exists in session uploads | |
| known = {os.path.basename(f.get("filename", "")) for f in files if f.get("filename")} | |
| if hinted in known: | |
| return ([hinted], None) | |
| # 2) generic "uploaded file" intent | |
| uploaded_intent = any(t in msg_l for t in [ | |
| "uploaded file", "uploaded files", "the uploaded file", "this file", "this document", | |
| "上传的文件", "这份文件", "这个文件", "文档", "课件", "讲义" | |
| ]) | |
| if uploaded_intent and files: | |
| last = files[-1] | |
| fn = os.path.basename(last.get("filename", "")).strip() or None | |
| dt = (last.get("doc_type") or "").strip() or None | |
| allowed_files = [fn] if fn else None | |
| allowed_doc_types = [dt] if dt else None | |
| return (allowed_files, allowed_doc_types) | |
| return (None, None) | |
| # ---------------------------- | |
| # Warmup | |
| # ---------------------------- | |
| def _do_warmup_once(): | |
| global WARMUP_DONE, WARMUP_ERROR, WARMUP_STARTED | |
| if WARMUP_STARTED: | |
| return | |
| WARMUP_STARTED = True | |
| try: | |
| from api.config import client | |
| client.models.list() | |
| _ = MODULE10_CHUNKS_CACHE | |
| WARMUP_DONE = True | |
| WARMUP_ERROR = None | |
| except Exception as e: | |
| WARMUP_DONE = False | |
| WARMUP_ERROR = repr(e) | |
| def _start_warmup_background(): | |
| if not CLARE_ENABLE_WARMUP: | |
| return | |
| threading.Thread(target=_do_warmup_once, daemon=True).start() | |
| def _on_startup(): | |
| _start_warmup_background() | |
| db_module.init_db() | |
| # ---------------------------- | |
| # LangSmith helpers | |
| # ---------------------------- | |
| _ls_client = None | |
| if (Client is not None) and CLARE_ENABLE_LANGSMITH_LOG: | |
| try: | |
| _ls_client = Client() | |
| except Exception as e: | |
| print("[langsmith] init failed:", repr(e)) | |
| _ls_client = None | |
| def _log_event_to_langsmith(data: Dict[str, Any]): | |
| """ | |
| Dataset logging: create_example into LS_DATASET_NAME | |
| """ | |
| if _ls_client is None: | |
| return | |
| def _do(): | |
| try: | |
| inputs = { | |
| "question": data.get("question", ""), | |
| "student_id": data.get("student_id", ""), | |
| "student_name": data.get("student_name", ""), | |
| } | |
| outputs = {"answer": data.get("answer", "")} | |
| # keep metadata clean and JSON-serializable | |
| metadata = {k: v for k, v in data.items() if k not in ("question", "answer")} | |
| 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)) | |
| if CLARE_LANGSMITH_ASYNC: | |
| threading.Thread(target=_do, daemon=True).start() | |
| else: | |
| _do() | |
| def _write_feedback_to_langsmith_run( | |
| run_id: str, | |
| rating: str, | |
| comment: str = "", | |
| tags: Optional[List[str]] = None, | |
| metadata: Optional[Dict[str, Any]] = None, | |
| ) -> bool: | |
| """ | |
| Run-level feedback: create_feedback attached to a specific run_id. | |
| This is separate from dataset create_example logging. | |
| """ | |
| if not CLARE_ENABLE_LANGSMITH_FEEDBACK: | |
| return False | |
| if Client is None: | |
| return False | |
| rid = (run_id or "").strip() | |
| if not rid: | |
| return False | |
| try: | |
| ls = Client() | |
| score = 1 if rating == "helpful" else 0 | |
| meta = metadata or {} | |
| if tags is not None: | |
| meta["tags"] = tags | |
| if LS_PROJECT: | |
| meta.setdefault("langsmith_project", LS_PROJECT) | |
| ls.create_feedback( | |
| run_id=rid, | |
| key="ui_rating", | |
| score=score, | |
| comment=comment or "", | |
| metadata=meta, | |
| ) | |
| return True | |
| except Exception as e: | |
| print("[langsmith] create_feedback failed:", repr(e)) | |
| return False | |
| # ---------------------------- | |
| # Health endpoints | |
| # ---------------------------- | |
| def health(): | |
| return { | |
| "ok": True, | |
| "uptime_s": round(time.time() - APP_START_TS, 3), | |
| "warmup_enabled": CLARE_ENABLE_WARMUP, | |
| "warmup_started": bool(WARMUP_STARTED), | |
| "warmup_done": bool(WARMUP_DONE), | |
| "warmup_error": WARMUP_ERROR, | |
| "ready": bool(WARMUP_DONE) if CLARE_WARMUP_BLOCK_READY else True, | |
| "langsmith_enabled": bool(CLARE_ENABLE_LANGSMITH_LOG), | |
| "langsmith_async": bool(CLARE_LANGSMITH_ASYNC), | |
| "langsmith_feedback_enabled": bool(CLARE_ENABLE_LANGSMITH_FEEDBACK), | |
| "ts": int(time.time()), | |
| } | |
| def ready(): | |
| if not CLARE_ENABLE_WARMUP or not CLARE_WARMUP_BLOCK_READY: | |
| return {"ready": True} | |
| if WARMUP_DONE: | |
| return {"ready": True} | |
| return JSONResponse({"ready": False, "error": WARMUP_ERROR}, status_code=503) | |
| # ---------------------------- | |
| # Quiz (Micro-Quiz) Instruction | |
| # ---------------------------- | |
| MICRO_QUIZ_INSTRUCTION = ( | |
| "We are running a short micro-quiz session based ONLY on **Module 10 – " | |
| "Responsible AI (Alto, 2024, Chapter 12)** and the pre-loaded materials.\n\n" | |
| "Step 1 – Before asking any content question:\n" | |
| "• First ask me which quiz style I prefer right now:\n" | |
| " - (1) Multiple-choice questions\n" | |
| " - (2) Short-answer / open-ended questions\n" | |
| "• Ask me explicitly: \"Which quiz style do you prefer now: 1) Multiple-choice or 2) Short-answer? " | |
| "Please reply with 1 or 2.\"\n" | |
| "• Do NOT start a content question until I have answered 1 or 2.\n\n" | |
| "Step 2 – After I choose the style:\n" | |
| "• If I choose 1 (multiple-choice):\n" | |
| " - Ask ONE multiple-choice question at a time, based on Module 10 concepts " | |
| "(Responsible AI definition, risk types, mitigation layers, EU AI Act, etc.).\n" | |
| " - Provide 3–4 options (A, B, C, D) and make only one option clearly correct.\n" | |
| "• If I choose 2 (short-answer):\n" | |
| " - Ask ONE short-answer question at a time, also based on Module 10 concepts.\n" | |
| " - Do NOT show the answer when you ask the question.\n\n" | |
| "Step 3 – For each answer I give:\n" | |
| "• Grade my answer (correct / partially correct / incorrect).\n" | |
| "• Give a brief explanation and the correct answer.\n" | |
| "• Then ask if I want another question of the SAME style.\n" | |
| "• Continue this pattern until I explicitly say to stop.\n\n" | |
| "Please start by asking me which quiz style I prefer (1 = multiple-choice, 2 = short-answer). " | |
| "Do not ask any content question before I choose." | |
| ) | |
| # ---------------------------- | |
| # Schemas | |
| # ---------------------------- | |
| class LoginReq(BaseModel): | |
| login_id: str | |
| class ChatReq(BaseModel): | |
| session_id: str | |
| message: str | |
| learning_mode: str | |
| language_preference: str = "Auto" | |
| doc_type: str = "Syllabus" | |
| class QuizStartReq(BaseModel): | |
| session_id: str | |
| language_preference: str = "Auto" | |
| doc_type: str = MODULE10_DOC_TYPE | |
| learning_mode: str = "quiz" | |
| class QuizGenerateReq(BaseModel): | |
| """供外部网站调用的 Quiz 生成请求(无需 session_id)""" | |
| topic: str | |
| num_questions: int = 3 | |
| language: str = "en" # en | zh | |
| # ── v2.1 Smart Quiz models (backend-facing) ────────────────────── | |
| class QuizContextV2(BaseModel): | |
| courseId: int | |
| moduleId: int = 0 | |
| topics: list[str] = [] | |
| class QuizConfigV2(BaseModel): | |
| recipe: dict[str, int] | |
| language: str = "EN" | |
| class QuizGenerateV2Req(BaseModel): | |
| requestId: str = "" | |
| context: QuizContextV2 | |
| configurations: QuizConfigV2 | |
| class QuizGradeReq(BaseModel): | |
| requestId: str = "" | |
| quizContext: dict | |
| userAnswers: list[dict] | |
| class QuizHintReq(BaseModel): | |
| requestId: str = "" | |
| questionContext: dict | |
| type: str = "HINT" | |
| class LearningTrackerInsightsConfig(BaseModel): | |
| language: str = "CN" | |
| class LearningTrackerInsightsReq(BaseModel): | |
| requestId: str = "" | |
| context: dict | |
| configurations: LearningTrackerInsightsConfig = LearningTrackerInsightsConfig() | |
| class ExportReq(BaseModel): | |
| session_id: str | |
| learning_mode: str | |
| class SummaryReq(BaseModel): | |
| session_id: str | |
| learning_mode: str | |
| language_preference: str = "Auto" | |
| class TtsReq(BaseModel): | |
| session_id: str | |
| text: str | |
| voice: Optional[str] = "nova" # alloy, echo, fable, onyx, nova, shimmer | |
| class PodcastReq(BaseModel): | |
| session_id: str | |
| source: str = "summary" # "summary" | "conversation" | |
| voice: Optional[str] = "nova" | |
| class FeedbackReq(BaseModel): | |
| class Config: | |
| extra = "ignore" | |
| session_id: str | |
| rating: str # "helpful" | "not_helpful" | |
| run_id: Optional[str] = None | |
| assistant_message_id: Optional[str] = None | |
| assistant_text: str | |
| user_text: Optional[str] = "" | |
| comment: Optional[str] = "" | |
| tags: Optional[List[str]] = [] | |
| refs: Optional[List[str]] = [] | |
| learning_mode: Optional[str] = None | |
| doc_type: Optional[str] = None | |
| timestamp_ms: Optional[int] = None | |
| class ProfileDismissReq(BaseModel): | |
| session_id: str | |
| days: int = 7 | |
| class ProfileInitSubmitReq(BaseModel): | |
| session_id: str | |
| answers: Dict[str, Any] | |
| language_preference: str = "Auto" | |
| async def _generate_profile_bio_with_clare( | |
| sess: Dict[str, Any], | |
| answers: Dict[str, Any], | |
| language_preference: str = "Auto", | |
| ) -> str: | |
| """ | |
| Generates an English Profile Bio. Keep it neutral/supportive and non-judgmental. | |
| IMPORTANT: Do not contaminate user's normal chat history; use empty history. | |
| """ | |
| student_name = (sess.get("name") or "").strip() | |
| prompt = f""" | |
| You are Clare, an AI teaching assistant. | |
| Task: | |
| Generate a concise English Profile Bio for the student using ONLY the initialization answers provided below. | |
| Hard constraints: | |
| - Output language: English. | |
| - Tone: neutral, supportive, non-judgmental. | |
| - No medical/psychological diagnosis language. | |
| - Do not infer sensitive attributes (race, religion, political views, health status, sexuality, immigration status). | |
| - Length: 60–120 words. | |
| - Structure (4 short sentences max): | |
| 1) background & current context | |
| 2) learning goal for this course | |
| 3) learning preferences (format + pace) | |
| 4) how Clare will support them going forward (practical and concrete) | |
| Student name (if available): {student_name} | |
| Initialization answers (JSON): | |
| {answers} | |
| Return ONLY the bio text. Do not add a title. | |
| """.strip() | |
| resolved_lang = "English" # force English regardless of UI preference | |
| try: | |
| bio, _unused_history, _run_id, _tokens_used = await chat_with_clare( | |
| message=prompt, | |
| history=[], | |
| model_name=sess["model_name"], | |
| language_preference=resolved_lang, | |
| learning_mode="summary", | |
| doc_type="Other Course Document", | |
| course_outline=sess["course_outline"], | |
| weaknesses=sess["weaknesses"], | |
| cognitive_state=sess["cognitive_state"], | |
| rag_context="", | |
| ) | |
| return (bio or "").strip() | |
| except Exception as e: | |
| print("[profile_bio] generate failed:", repr(e)) | |
| return "" | |
| # ---------------------------- | |
| # API Routes | |
| # ---------------------------- | |
| def login(req: LoginReq): | |
| import secrets | |
| login_id = (req.login_id or "").strip() | |
| if not login_id: | |
| return JSONResponse({"ok": False, "error": "Missing login_id"}, status_code=400) | |
| session_id = secrets.token_hex(4) # 8-char hex | |
| sess = _get_session(session_id) | |
| sess["login_id"] = login_id | |
| db_module.upsert_session(session_id=session_id, login_id=login_id) | |
| # Create initial chat row for this session | |
| default_name = f"New Chat {datetime.datetime.now().strftime('%b %d, %H:%M').replace(' 0', ' ')}" | |
| chat_id = db_module.create_chat( | |
| login_id=login_id, | |
| name=default_name, | |
| chat_mode="ask", | |
| created_session_id=session_id, | |
| ) | |
| sess["chat_id"] = chat_id | |
| sess["chat_turn_count"] = 0 | |
| return {"ok": True, "session_id": session_id, "login_id": login_id, "chat_id": chat_id} | |
| async def chat(req: ChatReq): | |
| session_id = (req.session_id or "").strip() | |
| msg = (req.message or "").strip() | |
| if not session_id: | |
| return JSONResponse({"error": "Missing session_id"}, status_code=400) | |
| sess = _get_session(session_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, | |
| "run_id": None, | |
| } | |
| t0 = time.time() | |
| _user_ts = datetime.datetime.utcnow() | |
| marks_ms: Dict[str, float] = {"start": 0.0} | |
| login_id = sess.get("login_id", "") | |
| chat_id = sess.get("chat_id") | |
| log.info("chat request | session=%s | login=%s | chat=%s | len=%d | mode=%s | lang=%s", session_id, login_id, chat_id, len(msg), req.learning_mode, req.language_preference) | |
| log.debug("chat message | session=%s | msg=%r", session_id, msg) | |
| log.debug("session state | weaknesses=%s | cognitive=%s | history_turns=%d | rag_chunks=%d", | |
| sess["weaknesses"], sess["cognitive_state"], len(sess["history"]), | |
| len(MODULE10_CHUNKS_CACHE) + len(sess["rag_chunks"])) | |
| resolved_lang = detect_language(msg, req.language_preference) | |
| marks_ms["language_detect_done"] = (time.time() - t0) * 1000.0 | |
| sess["weaknesses"] = update_weaknesses_from_message(msg, sess["weaknesses"]) | |
| marks_ms["weakness_update_done"] = (time.time() - t0) * 1000.0 | |
| sess["cognitive_state"] = update_cognitive_state_from_message(msg, sess["cognitive_state"]) | |
| marks_ms["cognitive_update_done"] = (time.time() - t0) * 1000.0 | |
| # NEW: do NOT bypass RAG for document actions (so UI refs are preserved) | |
| force_rag = _should_force_rag(msg) | |
| allowed_files, allowed_doc_types = _resolve_rag_scope(sess, msg) | |
| log.debug("rag gate | msg_len=%d | force_rag=%s | allowed_files=%s | allowed_doc_types=%s", | |
| len(msg), force_rag, allowed_files, allowed_doc_types) | |
| if (len(msg) < 20 and ("?" not in msg)) and (not force_rag): | |
| log.debug("rag skipped - message too short") | |
| rag_context_text, rag_used_chunks = "", [] | |
| else: | |
| # Use cached FAISS index if available (no rebuild on each query) | |
| rag_context_text, rag_used_chunks = retrieve_relevant_chunks( | |
| msg, | |
| MODULE10_CHUNKS_CACHE + sess["rag_chunks"], | |
| allowed_source_files=allowed_files, | |
| allowed_doc_types=allowed_doc_types, | |
| max_context_chars=2000, | |
| cached_index=sess.get("faiss_index"), | |
| ) | |
| log.debug("faiss rag | chunks_returned=%d | context_chars=%d", len(rag_used_chunks), len(rag_context_text)) | |
| if rag_used_chunks: | |
| for i, c in enumerate(rag_used_chunks): | |
| log.debug(" faiss chunk[%d] | score=%.3f | source=%s | section=%s", | |
| i, c.get("_rag_score", 0), c.get("source_file", "?"), c.get("section", "?")) | |
| # 方案二:从 Weaviate 检索课程知识库(与教师端共用) | |
| weaviate_used = False | |
| weaviate_refs_raw: List[Dict] = [] | |
| _weav_t0 = time.time() | |
| try: | |
| weav_text, weav_refs = retrieve_from_weaviate_with_refs(msg, top_k=6) | |
| _weav_ms = (time.time() - _weav_t0) * 1000 | |
| if weav_text: | |
| prefix = "\n\n[来自 Weaviate 课程知识库]\n\n" | |
| rag_context_text = (rag_context_text or "") + prefix + weav_text | |
| weaviate_used = True | |
| weaviate_refs_raw = list(weav_refs or []) | |
| log.debug("weaviate rag | latency_ms=%.0f | refs=%d | context_chars=%d", | |
| _weav_ms, len(weaviate_refs_raw), len(weav_text)) | |
| else: | |
| log.debug("weaviate rag | latency_ms=%.0f | no results returned", _weav_ms) | |
| except Exception as e: | |
| log.warning("weaviate retrieve failed | error=%r", e) | |
| # 方案三:调用 GenAICoursesDB 向量知识库,补充课程检索结果 | |
| course_used = False | |
| if GENAI_COURSES_SPACE: | |
| course_chunks = _retrieve_from_genai_courses(msg) | |
| if course_chunks: | |
| prefix = "\n\n[来自 GENAI 课程知识库]\n\n" | |
| rag_context_text = (rag_context_text or "") + prefix + course_chunks | |
| course_used = True | |
| log.debug("genai courses rag | chars=%d", len(course_chunks)) | |
| marks_ms["rag_retrieve_done"] = (time.time() - t0) * 1000.0 | |
| log.debug("rag total | faiss=%s | weaviate=%s | total_context_chars=%d", | |
| bool(rag_used_chunks), weaviate_used, len(rag_context_text or "")) | |
| # NEW: prepend deterministic upload/file-state hint so the model never says "no file" | |
| upload_hint = _build_upload_hint(sess) | |
| if upload_hint: | |
| rag_context_text = (upload_hint + "\n\n---\n\n" + (rag_context_text or "")).strip() | |
| log.debug("llm call start | model=%s | history_turns=%d | rag_context_chars=%d", | |
| sess["model_name"], len(sess["history"]), len(rag_context_text or "")) | |
| _error_flag = False | |
| _timeout_flag = False | |
| # Build messages for streaming (same as chat_with_clare does internally) | |
| from api.clare_core import build_messages | |
| messages = build_messages( | |
| user_message=msg, | |
| history=sess["history"], | |
| 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, | |
| ) | |
| # Stream LLM response via SSE | |
| async def event_generator(): | |
| nonlocal _error_flag, _timeout_flag | |
| _first_token_ts = None | |
| _last_token_ts = None | |
| _suggestions_ts = None | |
| _suggestions_result: List[str] = [] | |
| try: | |
| full_text = "" | |
| model_name = sess["model_name"] | |
| # Call OpenAI with stream=True | |
| stream = await async_client.chat.completions.create( | |
| model=model_name, | |
| messages=messages, | |
| temperature=0.5, | |
| max_tokens=2048, | |
| stream=True, | |
| ) | |
| # Iterate over chunks and yield tokens | |
| async for chunk in stream: | |
| if chunk.choices and chunk.choices[0].delta.content: | |
| token = chunk.choices[0].delta.content | |
| if _first_token_ts is None: | |
| _first_token_ts = datetime.datetime.utcnow() | |
| full_text += token | |
| # Send token via SSE | |
| yield f"data: {json.dumps({'token': token, 'is_final': False})}\n\n" | |
| _last_token_ts = datetime.datetime.utcnow() | |
| # After streaming completes, update session and send final message | |
| new_history = list(sess["history"]) + [(msg, full_text)] | |
| sess["history"] = new_history | |
| # Build refs | |
| refs: List[Dict[str, Optional[str]]] = [] | |
| for c in (rag_used_chunks or []): | |
| a = c.get("source_file") | |
| b = c.get("section") | |
| refs.append({"source_file": a, "section": b}) | |
| if weaviate_used and weaviate_refs_raw: | |
| for r in weaviate_refs_raw: | |
| src = (r.get("source") or "GenAICourses").strip() | |
| page = (r.get("page") or "").strip() | |
| section = f"Weaviate RAG" | |
| if page: | |
| section = f"{section} - page {page}" | |
| refs.append({"source_file": src or "GenAICourses", "section": section}) | |
| if course_used: | |
| refs.append({"source_file": "GenAICoursesDB", "section": "retrieve (GENAI COURSES dataset)"}) | |
| if not refs: | |
| refs = [{"source_file": "No RAG", "section": "Answer based on model general knowledge; web search: not used."}] | |
| # Final message with metadata | |
| total_ms = (time.time() - t0) * 1000.0 | |
| final_msg = { | |
| "reply": full_text, | |
| "refs": refs, | |
| "latency_ms": int(total_ms), | |
| "is_final": True, | |
| } | |
| yield f"data: {json.dumps(final_msg)}\n\n" | |
| # Generate follow-up suggestions (not blocking, sent after final message) | |
| try: | |
| log.debug("generating suggestions...") | |
| _suggestions_result = await asyncio.wait_for( | |
| generate_suggested_questions( | |
| user_message=msg, | |
| assistant_reply=full_text, | |
| language=resolved_lang, | |
| model_name=model_name, | |
| ), | |
| timeout=30.0, # Max 30 seconds for suggestions | |
| ) | |
| _suggestions_ts = datetime.datetime.utcnow() | |
| log.debug("suggestions generated | count=%d | data=%r", len(_suggestions_result) if _suggestions_result else 0, _suggestions_result) | |
| if _suggestions_result and len(_suggestions_result) > 0: | |
| yield f"data: {json.dumps({'suggested_questions': _suggestions_result, 'type': 'suggestions', 'is_final': True})}\n\n" | |
| log.info("suggestions sent | count=%d", len(_suggestions_result)) | |
| else: | |
| log.debug("no suggestions returned") | |
| except asyncio.TimeoutError: | |
| log.warning("suggestions generation timed out (>30s)") | |
| except Exception as e: | |
| log.warning("suggestions generation failed: %r", e) | |
| # DB persistence | |
| _chat_turn = sess.get("chat_turn_count", 0) | |
| # Unique file names only — no section labels | |
| _seen: dict = {} | |
| for r in refs: | |
| sf = (r.get("source_file") or "").strip() | |
| if sf and sf != "No RAG": | |
| _seen[sf] = True | |
| _doc_refs = list(_seen.keys()) | |
| db_module.upsert_session( | |
| session_id=session_id, | |
| login_id=login_id, | |
| learning_mode=(req.learning_mode or ""), | |
| ) | |
| # Auto-rename chat to first user message (truncated 60 chars) | |
| if chat_id and _chat_turn == 0: | |
| _auto_name = msg[:60] + ("..." if len(msg) > 60 else "") | |
| db_module.rename_chat(chat_id=chat_id, name=_auto_name, session_id=session_id) | |
| sess["chat_turn_count"] = _chat_turn + 1 | |
| db_module.insert_interaction( | |
| session_id=session_id, | |
| chat_id=chat_id, | |
| login_id=login_id, | |
| user_message=msg, | |
| assistant_reply=full_text, | |
| learning_mode=(req.learning_mode or ""), | |
| total_tokens=0, | |
| estimated_cost=0.0, | |
| user_ts=_user_ts, | |
| first_token_ts=_first_token_ts, | |
| last_token_ts=_last_token_ts, | |
| suggestions_ts=_suggestions_ts, | |
| doc_references=_doc_refs, | |
| suggested_questions=list(_suggestions_result or []), | |
| error_flag=False, | |
| timeout_flag=False, | |
| run_id=None, | |
| ) | |
| log.info("chat streamed | session=%s | chars=%d | total_ms=%.0f", | |
| session_id, len(full_text), total_ms) | |
| except Exception as e: | |
| import httpx | |
| _error_flag = True | |
| _timeout_flag = isinstance(e, (httpx.TimeoutException,)) or "timeout" in type(e).__name__.lower() | |
| log.error("stream failed | error=%r | timeout=%s", e, _timeout_flag) | |
| yield f"data: {json.dumps({'error': f'Stream failed: {repr(e)}', 'is_final': True})}\n\n" | |
| # Return streaming response | |
| marks_ms["llm_done"] = (time.time() - t0) * 1000.0 | |
| return StreamingResponse(event_generator(), media_type="text/event-stream") | |
| async def quiz_start(req: QuizStartReq): | |
| session_id = (req.session_id or "").strip() | |
| if not session_id: | |
| return JSONResponse({"error": "Missing session_id"}, status_code=400) | |
| sess = _get_session(session_id) | |
| quiz_instruction = MICRO_QUIZ_INSTRUCTION | |
| t0 = time.time() | |
| resolved_lang = detect_language(quiz_instruction, req.language_preference) | |
| rag_context_text, rag_used_chunks = retrieve_relevant_chunks( | |
| "Module 10 quiz", | |
| MODULE10_CHUNKS_CACHE + sess["rag_chunks"], | |
| cached_index=sess.get("faiss_index"), | |
| ) | |
| # ✅ NEW: same hint for quiz start as well | |
| upload_hint = _build_upload_hint(sess) | |
| if upload_hint: | |
| rag_context_text = (upload_hint + "\n\n---\n\n" + (rag_context_text or "")).strip() | |
| try: | |
| answer, new_history, run_id, tokens_used = await chat_with_clare( | |
| message=quiz_instruction, | |
| 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"[quiz_start] error: {repr(e)}") | |
| return JSONResponse({"error": f"quiz_start failed: {repr(e)}"}, status_code=500) | |
| total_ms = (time.time() - t0) * 1000.0 | |
| sess["history"] = new_history | |
| try: | |
| tokens_used = int(tokens_used) | |
| except Exception: | |
| tokens_used = 0 | |
| sess.setdefault("total_tokens_used", 0) | |
| sess["total_tokens_used"] += tokens_used | |
| cost_estimated = (tokens_used / 1000.0) * TOKEN_COST_PER_1K if TOKEN_COST_PER_1K > 0 else 0.0 | |
| refs = [ | |
| {"source_file": c.get("source_file"), "section": c.get("section")} | |
| for c in (rag_used_chunks or []) | |
| ] | |
| _log_event_to_langsmith( | |
| { | |
| "experiment_id": EXPERIMENT_ID, | |
| "login_id": sess.get("login_id", ""), | |
| "session_id": session_id, | |
| "event_type": "micro_quiz_start", | |
| "timestamp": time.time(), | |
| "latency_ms": total_ms, | |
| "tokens_used": tokens_used, | |
| "total_tokens_used": sess.get("total_tokens_used", tokens_used), | |
| "cost_estimated": cost_estimated, | |
| "question": "[micro_quiz_start] " + quiz_instruction[:200], | |
| "answer": answer, | |
| "model_name": sess["model_name"], | |
| "language": resolved_lang, | |
| "learning_mode": req.learning_mode, | |
| "doc_type": req.doc_type, | |
| "refs": refs, | |
| "rag_used_chunks_count": len(rag_used_chunks or []), | |
| "history_len": len(sess["history"]), | |
| "run_id": run_id, | |
| } | |
| ) | |
| return { | |
| "reply": answer, | |
| "session_status_md": render_session_status( | |
| req.learning_mode, sess["weaknesses"], sess["cognitive_state"] | |
| ), | |
| "refs": refs, | |
| "latency_ms": total_ms, | |
| "run_id": run_id, | |
| } | |
| # 可选:设置 QUIZ_API_KEY 后,外部调用 /api/quiz/generate 需在 Header 带 X-API-Key | |
| QUIZ_API_KEY = (os.getenv("QUIZ_API_KEY") or "").strip() | |
| def _check_bearer_auth(request: Request): | |
| """Check Authorization: Bearer header against QUIZ_API_KEY. Returns error JSONResponse or None.""" | |
| if not QUIZ_API_KEY: | |
| return None | |
| auth = (request.headers.get("Authorization") or "").strip() | |
| token = auth[7:].strip() if auth.lower().startswith("bearer ") else "" | |
| if token != QUIZ_API_KEY: | |
| return JSONResponse( | |
| status_code=429, | |
| content={"code": 429, "error": {"type": "RATE_LIMIT", "reason": "missing_or_invalid_api_key"}}, | |
| ) | |
| return None | |
| def _meta(model: str, tokens: int, latency_ms: float, prompt_version: str = "v2.1") -> dict: | |
| return { | |
| "model": model, | |
| "model_version": "", | |
| "prompt_version": prompt_version, | |
| "temperature": 0.4, | |
| "tokens_used": tokens, | |
| "latency_ms": round(latency_ms, 2), | |
| } | |
| def _error_response(status: int, error_type: str, reason: str, details: str = "", tokens: int = 0, latency_ms: float = 0.0): | |
| return JSONResponse( | |
| status_code=status, | |
| content={ | |
| "code": status, | |
| "error": {"type": error_type, "reason": reason, "details": details}, | |
| "meta": _meta(DEFAULT_MODEL, tokens, latency_ms), | |
| }, | |
| ) | |
| async def quiz_generate(request: Request): | |
| """ | |
| Dual-format Quiz generation endpoint. | |
| - New v2.1 format: {requestId, context, configurations.recipe} → {data: {questions}, meta} | |
| - Old format: {topic, num_questions, language} → {questions, meta} (backward compatible) | |
| """ | |
| body = await request.json() | |
| # ── New v2.1 format ────────────────────────────────────────── | |
| if "configurations" in body: | |
| auth_err = _check_bearer_auth(request) | |
| if auth_err: | |
| return auth_err | |
| try: | |
| req = QuizGenerateV2Req(**body) | |
| except Exception as e: | |
| return _error_response(422, "INVALID_GENERATION", "schema_violation", str(e)) | |
| t0 = time.time() | |
| try: | |
| from api.quiz_backend import generate_quiz_smart | |
| questions, tokens_used = await generate_quiz_smart( | |
| recipe=req.configurations.recipe, | |
| topics=req.configurations.language and req.context.topics or req.context.topics, | |
| language=req.configurations.language, | |
| ) | |
| except ValueError as e: | |
| return _error_response(422, "INVALID_GENERATION", str(e), latency_ms=(time.time() - t0) * 1000.0) | |
| except Exception as e: | |
| print(f"[quiz_generate_v2] error: {repr(e)}") | |
| return _error_response(500, "MODEL_ERROR", "generation_failed", str(e), latency_ms=(time.time() - t0) * 1000.0) | |
| latency_ms = (time.time() - t0) * 1000.0 | |
| return {"data": {"questions": questions}, "meta": _meta(DEFAULT_MODEL, tokens_used, latency_ms)} | |
| # ── Old format (backward compatible) ───────────────────────── | |
| if QUIZ_API_KEY: | |
| key = (request.headers.get("X-API-Key") or "").strip() | |
| if key != QUIZ_API_KEY: | |
| return JSONResponse( | |
| status_code=429, | |
| content={"code": 429, "error": {"type": "RATE_LIMIT", "reason": "missing_or_invalid_api_key"}}, | |
| ) | |
| try: | |
| req_old = QuizGenerateReq(**body) | |
| except Exception as e: | |
| return JSONResponse(status_code=422, content={"code": 422, "error": {"type": "INVALID_GENERATION", "reason": str(e)}}) | |
| topic = (req_old.topic or "").strip() | |
| if not topic: | |
| return JSONResponse( | |
| status_code=422, | |
| content={"code": 422, "error": {"type": "INVALID_GENERATION", "reason": "topic_required"}}, | |
| ) | |
| t0 = time.time() | |
| try: | |
| questions, tokens_used = await generate_quiz_for_external( | |
| topic=topic, | |
| num_questions=req_old.num_questions, | |
| language=req_old.language, | |
| ) | |
| except ValueError as e: | |
| return JSONResponse( | |
| status_code=422, | |
| content={"code": 422, "error": {"type": "INVALID_GENERATION", "reason": str(e).replace(" ", "_")}}, | |
| ) | |
| except Exception as e: | |
| print(f"[quiz_generate] error: {repr(e)}") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"code": 500, "error": {"type": "MODEL_ERROR", "reason": "generation_failed"}}, | |
| ) | |
| latency_ms = (time.time() - t0) * 1000.0 | |
| return { | |
| "questions": questions, | |
| "meta": { | |
| "model": DEFAULT_MODEL, | |
| "model_version": "", | |
| "prompt_version": "quiz_generate_v1", | |
| "temperature": 0.4, | |
| "tokens_used": tokens_used, | |
| "latency_ms": round(latency_ms, 2), | |
| }, | |
| } | |
| async def quiz_grade(req: QuizGradeReq, request: Request): | |
| """ | |
| Grade a quiz and return per-question feedback + recommendations. | |
| Ref: AI_Interface_Design_v2.1 §3.2 | |
| """ | |
| auth_err = _check_bearer_auth(request) | |
| if auth_err: | |
| return auth_err | |
| t0 = time.time() | |
| try: | |
| from api.quiz_backend import grade_quiz | |
| result, tokens_used = await grade_quiz( | |
| quiz_context=req.quizContext, | |
| user_answers=req.userAnswers, | |
| ) | |
| except ValueError as e: | |
| return _error_response(422, "INVALID_GENERATION", str(e), latency_ms=(time.time() - t0) * 1000.0) | |
| except Exception as e: | |
| print(f"[quiz_grade] error: {repr(e)}") | |
| return _error_response(500, "MODEL_ERROR", "grading_failed", str(e), latency_ms=(time.time() - t0) * 1000.0) | |
| latency_ms = (time.time() - t0) * 1000.0 | |
| return {"data": result, "meta": _meta(DEFAULT_MODEL, tokens_used, latency_ms)} | |
| async def quiz_hint(req: QuizHintReq, request: Request): | |
| """ | |
| Generate a hint for a single question without revealing the answer. | |
| Ref: AI_Interface_Design_v2.1 §3.3 | |
| """ | |
| auth_err = _check_bearer_auth(request) | |
| if auth_err: | |
| return auth_err | |
| q_content = (req.questionContext.get("content") or "").strip() | |
| if not q_content: | |
| return _error_response(422, "INVALID_GENERATION", "missing_field", "questionContext.content is required") | |
| options = req.questionContext.get("options") or [] | |
| t0 = time.time() | |
| try: | |
| from api.quiz_backend import get_hint | |
| hint_text, tokens_used = await get_hint( | |
| question_content=q_content, | |
| options=options, | |
| ) | |
| except ValueError as e: | |
| return _error_response(422, "INVALID_GENERATION", str(e), latency_ms=(time.time() - t0) * 1000.0) | |
| except Exception as e: | |
| print(f"[quiz_hint] error: {repr(e)}") | |
| return _error_response(500, "MODEL_ERROR", "hint_failed", str(e), latency_ms=(time.time() - t0) * 1000.0) | |
| latency_ms = (time.time() - t0) * 1000.0 | |
| return {"data": {"hint": hint_text}, "meta": _meta(DEFAULT_MODEL, tokens_used, latency_ms)} | |
| async def learning_tracker_insights_generate(req: LearningTrackerInsightsReq, request: Request): | |
| """ | |
| Section 3.4 — Learning Tracker Insights. | |
| Receives a weekly summary snapshot, returns weekHighlights + improvementSuggestions. | |
| Ref: docs/AI_Interface_Design_v2.2.md §3.4 | |
| """ | |
| auth_err = _check_bearer_auth(request) | |
| if auth_err: | |
| return auth_err | |
| t0 = time.time() | |
| try: | |
| from api.learning_tracker_backend import generate_learning_tracker_insights | |
| result, tokens_used = await generate_learning_tracker_insights( | |
| context=req.context, | |
| language=req.configurations.language, | |
| ) | |
| except ValueError as e: | |
| return _error_response(422, "INVALID_GENERATION", str(e), latency_ms=(time.time() - t0) * 1000.0) | |
| except Exception as e: | |
| print(f"[learning_tracker_insights] error: {repr(e)}") | |
| return _error_response(500, "MODEL_ERROR", "generation_failed", str(e), latency_ms=(time.time() - t0) * 1000.0) | |
| latency_ms = (time.time() - t0) * 1000.0 | |
| return {"data": result, "meta": _meta(DEFAULT_MODEL, tokens_used, latency_ms)} | |
| async def upload( | |
| session_id: str = Form(...), | |
| doc_type: str = Form(...), | |
| file: UploadFile = File(...), | |
| ): | |
| session_id = (session_id or "").strip() | |
| doc_type = (doc_type or "").strip() | |
| if not session_id: | |
| return JSONResponse({"ok": False, "error": "Missing session_id"}, status_code=400) | |
| if not file or not file.filename: | |
| return JSONResponse({"ok": False, "error": "Missing file"}, status_code=400) | |
| sess = _get_session(session_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 [] | |
| combined = (sess["rag_chunks"] or []) + new_chunks | |
| if len(combined) > MAX_UPLOAD_CHUNKS: | |
| log.warning("[upload] session %s hit chunk cap: %d chunks → truncated to %d", | |
| session_id, len(combined), MAX_UPLOAD_CHUNKS) | |
| combined = combined[:MAX_UPLOAD_CHUNKS] | |
| sess["rag_chunks"] = combined | |
| # REBUILD FAISS index with merged chunks (MODULE10 + new uploads) | |
| all_chunks = MODULE10_CHUNKS_CACHE + sess["rag_chunks"] | |
| sess["faiss_index"] = _build_faiss_index(all_chunks) | |
| log.debug("[upload] rebuilt FAISS index with %d total chunks", len(all_chunks)) | |
| except Exception as e: | |
| print(f"[upload] rag build error: {repr(e)}") | |
| new_chunks = [] | |
| # ✅ NEW: record upload metadata for prompting/debug | |
| try: | |
| sess["uploaded_files"] = sess.get("uploaded_files") or [] | |
| sess["uploaded_files"].append( | |
| { | |
| "filename": safe_name, | |
| "doc_type": doc_type, | |
| "added_chunks": len(new_chunks), | |
| "ts": int(time.time()), | |
| } | |
| ) | |
| except Exception as e: | |
| print(f"[upload] uploaded_files record error: {repr(e)}") | |
| status_md = f"✅ Loaded base reading + uploaded {doc_type} file." | |
| _log_event_to_langsmith( | |
| { | |
| "experiment_id": EXPERIMENT_ID, | |
| "login_id": sess.get("login_id", ""), | |
| "session_id": session_id, | |
| "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): | |
| session_id = (req.session_id or "").strip() | |
| if not session_id: | |
| return JSONResponse({"ok": False, "error": "Missing session_id"}, status_code=400) | |
| sess = _get_session(session_id) | |
| login_id = sess.get("login_id", "") | |
| rating = (req.rating or "").strip().lower() | |
| if rating not in ("helpful", "not_helpful"): | |
| return JSONResponse({"ok": False, "error": "Invalid rating"}, status_code=400) | |
| assistant_text = (req.assistant_text or "").strip() | |
| user_text = (req.user_text or "").strip() | |
| comment = (req.comment or "").strip() | |
| refs = req.refs or [] | |
| tags = req.tags or [] | |
| timestamp_ms = int(req.timestamp_ms or int(time.time() * 1000)) | |
| _log_event_to_langsmith( | |
| { | |
| "experiment_id": EXPERIMENT_ID, | |
| "login_id": login_id, | |
| "session_id": session_id, | |
| "event_type": "feedback", | |
| "timestamp": time.time(), | |
| "timestamp_ms": timestamp_ms, | |
| "rating": rating, | |
| "assistant_message_id": req.assistant_message_id, | |
| "run_id": req.run_id, | |
| "question": user_text, | |
| "answer": assistant_text, | |
| "comment": comment, | |
| "tags": tags, | |
| "refs": refs, | |
| "learning_mode": req.learning_mode, | |
| "doc_type": req.doc_type, | |
| } | |
| ) | |
| wrote_run_feedback = False | |
| if req.run_id: | |
| wrote_run_feedback = _write_feedback_to_langsmith_run( | |
| run_id=req.run_id, | |
| rating=rating, | |
| comment=comment, | |
| tags=tags, | |
| metadata={ | |
| "experiment_id": EXPERIMENT_ID, | |
| "login_id": login_id, | |
| "session_id": session_id, | |
| "assistant_message_id": req.assistant_message_id, | |
| "learning_mode": req.learning_mode, | |
| "doc_type": req.doc_type, | |
| "refs": refs, | |
| "timestamp_ms": timestamp_ms, | |
| }, | |
| ) | |
| # DB: attach feedback to the matching interaction row | |
| if req.run_id: | |
| db_module.update_interaction_feedback( | |
| run_id=req.run_id, | |
| thumbs_rating=rating, | |
| free_text_feedback=comment, | |
| ) | |
| return {"ok": True, "run_feedback_written": wrote_run_feedback} | |
| async def api_export(req: ExportReq): | |
| session_id = (req.session_id or "").strip() | |
| if not session_id: | |
| return JSONResponse({"error": "Missing session_id"}, status_code=400) | |
| sess = _get_session(session_id) | |
| md = await export_conversation( | |
| sess["history"], | |
| sess["course_outline"], | |
| req.learning_mode, | |
| sess["weaknesses"], | |
| sess["cognitive_state"], | |
| ) | |
| return {"markdown": md} | |
| async def api_summary(req: SummaryReq): | |
| session_id = (req.session_id or "").strip() | |
| if not session_id: | |
| return JSONResponse({"error": "Missing session_id"}, status_code=400) | |
| sess = _get_session(session_id) | |
| md = await summarize_conversation( | |
| sess["history"], | |
| sess["course_outline"], | |
| sess["weaknesses"], | |
| sess["cognitive_state"], | |
| sess["model_name"], | |
| req.language_preference, | |
| ) | |
| return {"markdown": md} | |
| # ---------------------------- | |
| # TTS & Podcast (OpenAI TTS API) | |
| # ---------------------------- | |
| async def api_tts(req: TtsReq): | |
| """Convert text to speech; returns MP3 audio.""" | |
| session_id = (req.session_id or "").strip() | |
| if not session_id: | |
| return JSONResponse({"error": "Missing session_id"}, status_code=400) | |
| text = (req.text or "").strip() | |
| if not text: | |
| return JSONResponse({"error": "Missing text"}, status_code=400) | |
| if len(text) > 50_000: | |
| return JSONResponse({"error": "Text too long (max 50000 chars)"}, status_code=400) | |
| try: | |
| audio_bytes = await text_to_speech(text, voice=req.voice or "nova") | |
| except Exception as e: | |
| print(f"[tts] error: {repr(e)}") | |
| return JSONResponse({"error": f"TTS failed: {repr(e)}"}, status_code=500) | |
| if not audio_bytes: | |
| return JSONResponse({"error": "No audio generated"}, status_code=500) | |
| return Response(content=audio_bytes, media_type="audio/mpeg") | |
| async def api_podcast(req: PodcastReq): | |
| """Generate podcast audio from session summary or conversation. Returns MP3.""" | |
| session_id = (req.session_id or "").strip() | |
| if not session_id: | |
| return JSONResponse({"error": "Missing session_id"}, status_code=400) | |
| sess = _get_session(session_id) | |
| source = (req.source or "summary").lower() | |
| voice = req.voice or "nova" | |
| try: | |
| if source == "conversation": | |
| script = build_podcast_script_from_history(sess["history"]) | |
| else: | |
| md = summarize_conversation( | |
| sess["history"], | |
| sess["course_outline"], | |
| sess["weaknesses"], | |
| sess["cognitive_state"], | |
| sess["model_name"], | |
| "Auto", | |
| ) | |
| script = build_podcast_script_from_summary(md) | |
| audio_bytes = await generate_podcast_audio(script, voice=voice) | |
| except Exception as e: | |
| print(f"[podcast] error: {repr(e)}") | |
| return JSONResponse({"error": f"Podcast failed: {repr(e)}"}, status_code=500) | |
| if not audio_bytes: | |
| return JSONResponse({"error": "No audio generated"}, status_code=500) | |
| return Response(content=audio_bytes, media_type="audio/mpeg") | |
| def memoryline(session_id: str): | |
| _ = _get_session((session_id or "").strip()) | |
| return {"next_review_label": "T+7", "progress_pct": 0.4} | |
| def profile_status(session_id: str): | |
| session_id = (session_id or "").strip() | |
| if not session_id: | |
| return JSONResponse({"error": "Missing session_id"}, status_code=400) | |
| sess = _get_session(session_id) | |
| bio = (sess.get("profile_bio") or "").strip() | |
| bio_len = len(bio) | |
| now = int(time.time()) | |
| dismissed_until = int(sess.get("init_dismiss_until") or 0) | |
| # 触发条件:bio <= 50 且不在 dismiss 窗口内 | |
| need_init = (bio_len <= 50) and (now >= dismissed_until) | |
| return { | |
| "need_init": need_init, | |
| "bio_len": bio_len, | |
| "dismissed_until": dismissed_until, | |
| } | |
| def profile_dismiss(req: ProfileDismissReq): | |
| session_id = (req.session_id or "").strip() | |
| if not session_id: | |
| return JSONResponse({"error": "Missing session_id"}, status_code=400) | |
| sess = _get_session(session_id) | |
| days = max(1, min(int(req.days or 7), 30)) # 1–30 days | |
| sess["init_dismiss_until"] = int(time.time()) + days * 24 * 3600 | |
| return {"ok": True, "dismissed_until": sess["init_dismiss_until"]} | |
| async def profile_init_submit(req: ProfileInitSubmitReq): | |
| session_id = (req.session_id or "").strip() | |
| if not session_id: | |
| return JSONResponse({"error": "Missing session_id"}, status_code=400) | |
| sess = _get_session(session_id) | |
| answers = req.answers or {} | |
| sess["init_answers"] = answers | |
| bio = await _generate_profile_bio_with_clare(sess, answers, req.language_preference) | |
| if not bio: | |
| return JSONResponse({"error": "Failed to generate bio"}, status_code=500) | |
| sess["profile_bio"] = bio | |
| return {"ok": True, "bio": bio} | |
| # ---------------------------- | |
| # Survey | |
| # ---------------------------- | |
| def serve_survey(): | |
| survey_path = os.path.join(WEB_DIST, "survey.html") | |
| if os.path.exists(survey_path): | |
| return FileResponse(survey_path) | |
| return JSONResponse({"detail": "survey.html not found"}, status_code=404) | |
| async def submit_survey(request: Request): | |
| from api.db import insert_survey_response | |
| body = await request.json() | |
| login_id = body.get("login_id") or None | |
| responses = {k: v for k, v in body.items() if k != "login_id"} | |
| row_id = insert_survey_response(login_id=login_id, responses=responses) | |
| return JSONResponse({"ok": True, "id": row_id}) | |
| # ---------------------------- | |
| # Chats (stubs — localStorage is active store; DB wiring deferred) | |
| # ---------------------------- | |
| class CreateChatReq(BaseModel): | |
| session_id: str | |
| name: str | |
| chat_mode: str = "ask" | |
| class RenameChatReq(BaseModel): | |
| name: str | |
| def create_chat(req: CreateChatReq): | |
| from api.db import create_chat as db_create_chat | |
| session_id = (req.session_id or "").strip() | |
| sess = SESSIONS.get(session_id, {}) if session_id else {} | |
| login_id = sess.get("login_id", "") | |
| chat_id = db_create_chat( | |
| login_id=login_id, | |
| name=(req.name or "New Chat").strip(), | |
| chat_mode=(req.chat_mode or "ask"), | |
| created_session_id=session_id or None, | |
| ) | |
| # Update active chat in session so subsequent interactions use this chat | |
| if session_id in SESSIONS and chat_id: | |
| SESSIONS[session_id]["chat_id"] = chat_id | |
| SESSIONS[session_id]["chat_turn_count"] = 0 | |
| return JSONResponse({"ok": True, "chat_id": chat_id}) | |
| def list_chats(login_id: str = ""): | |
| from api.db import get_chats_for_user | |
| if not login_id: | |
| return JSONResponse({"ok": False, "error": "Missing login_id"}, status_code=400) | |
| rows = get_chats_for_user(login_id) | |
| return JSONResponse({"ok": True, "chats": _json_safe(rows)}) | |
| def get_chat_messages(chat_id: str): | |
| from api.db import get_messages_for_chat | |
| rows = get_messages_for_chat(chat_id) | |
| messages = [] | |
| for row in rows: | |
| uid = str(row["id"]) | |
| user_ts = row["user_ts"].isoformat() if row["user_ts"] else None | |
| last_ts = row["last_token_ts"].isoformat() if row["last_token_ts"] else None | |
| messages.append({ | |
| "id": f"{uid}_u", | |
| "role": "user", | |
| "content": row["user_message"], | |
| "timestamp": user_ts, | |
| }) | |
| messages.append({ | |
| "id": f"{uid}_a", | |
| "role": "assistant", | |
| "content": row["assistant_reply"], | |
| "timestamp": last_ts, | |
| "references": row["doc_references"] or [], | |
| "suggestedQuestions": row["suggested_questions"] or [], | |
| }) | |
| return JSONResponse({"ok": True, "messages": messages}) | |
| def update_chat(chat_id: str, req: RenameChatReq, request: Request): | |
| from api.db import rename_chat | |
| # Best-effort: derive session_id from Authorization or skip | |
| rename_chat(chat_id=chat_id, name=(req.name or "").strip()) | |
| return JSONResponse({"ok": True}) | |
| def remove_chat(chat_id: str): | |
| from api.db import delete_chat | |
| delete_chat(chat_id=chat_id) | |
| return JSONResponse({"ok": True}) | |
| class ActivateChatReq(BaseModel): | |
| session_id: str | |
| def activate_chat(chat_id: str, req: ActivateChatReq): | |
| """Tell the server session to use a different chat (e.g. when user loads a saved chat).""" | |
| session_id = (req.session_id or "").strip() | |
| if session_id in SESSIONS: | |
| SESSIONS[session_id]["chat_id"] = chat_id | |
| # Count existing interactions so auto-rename doesn't trigger again | |
| existing = db_module.get_messages_for_chat(chat_id) | |
| SESSIONS[session_id]["chat_turn_count"] = len(existing) | |
| return JSONResponse({"ok": True}) | |
| # ---------------------------- | |
| # Admin | |
| # ---------------------------- | |
| _admin_security = HTTPBasic() | |
| _ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "changeme").encode() | |
| def _require_admin(credentials: HTTPBasicCredentials = Depends(_admin_security)): | |
| ok_pass = secrets.compare_digest(credentials.password.encode(), _ADMIN_PASSWORD) | |
| if not ok_pass: | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Invalid credentials", | |
| headers={"WWW-Authenticate": "Basic"}, | |
| ) | |
| def serve_admin(): | |
| admin_path = os.path.join(WEB_DIST, "admin.html") | |
| if os.path.exists(admin_path): | |
| return FileResponse(admin_path) | |
| return JSONResponse({"detail": "admin.html not found"}, status_code=404) | |
| def _json_safe(rows): | |
| clean = [] | |
| for r in rows: | |
| clean.append({k: (str(v) if hasattr(v, 'isoformat') else | |
| float(v) if hasattr(v, "__float__") and not isinstance(v, (int, bool)) else v) | |
| for k, v in r.items()}) | |
| return clean | |
| def admin_overview(_: None = Depends(_require_admin)): | |
| from api.db import get_user_overview | |
| return JSONResponse(_json_safe(get_user_overview())) | |
| def admin_interactions(login_id: str, _: None = Depends(_require_admin)): | |
| from api.db import get_interactions_for_user | |
| return JSONResponse(_json_safe(get_interactions_for_user(login_id))) | |
| # ---------------------------- | |
| # 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, headers=NO_CACHE_HEADERS) | |
| return JSONResponse( | |
| {"detail": "web/build not found. Build frontend first (web/build/index.html)."}, | |
| status_code=500, | |
| ) | |