SarahXia0405 commited on
Commit
eacc9fc
·
verified ·
1 Parent(s): a7a4f2e

Update api/server.py

Browse files
Files changed (1) hide show
  1. api/server.py +584 -199
api/server.py CHANGED
@@ -1,199 +1,584 @@
1
- // web/src/lib/api.ts
2
- // Aligns with api/server.py routes:
3
- // POST /api/login, /api/chat, /api/upload, /api/export, /api/summary, /api/feedback
4
- // GET /api/memoryline
5
-
6
- export type LearningMode = "general" | "concept" | "socratic" | "exam" | "assignment" | "summary";
7
- export type LanguagePref = "Auto" | "English" | "中文";
8
- export type DocType = "Syllabus" | "Lecture Slides / PPT" | "Literature Review / Paper" | "Other Course Document";
9
-
10
- const DEFAULT_TIMEOUT_MS = 20000;
11
-
12
- function getBaseUrl() {
13
- // Vite env: VITE_API_BASE can be "", "http://localhost:8000", etc.
14
- const v = (import.meta as any)?.env?.VITE_API_BASE as string | undefined;
15
- return v && v.trim() ? v.trim() : "";
16
- }
17
-
18
- async function fetchWithTimeout(input: RequestInfo, init?: RequestInit, timeoutMs = DEFAULT_TIMEOUT_MS) {
19
- const controller = new AbortController();
20
- const id = setTimeout(() => controller.abort(), timeoutMs);
21
- try {
22
- return await fetch(input, { ...init, signal: controller.signal });
23
- } finally {
24
- clearTimeout(id);
25
- }
26
- }
27
-
28
- async function parseJsonSafe(res: Response) {
29
- const text = await res.text();
30
- try {
31
- return text ? JSON.parse(text) : null;
32
- } catch {
33
- return { _raw: text };
34
- }
35
- }
36
-
37
- function errMsg(data: any, fallback: string) {
38
- return (data && (data.error || data.detail || data.message)) ? String(data.error || data.detail || data.message) : fallback;
39
- }
40
-
41
- // --------------------
42
- // /api/login
43
- // --------------------
44
- export type ApiLoginReq = {
45
- name: string;
46
- user_id: string;
47
- };
48
-
49
- export type ApiLoginResp =
50
- | { ok: true; user: { name: string; user_id: string } }
51
- | { ok: false; error: string };
52
-
53
- export async function apiLogin(payload: ApiLoginReq): Promise<ApiLoginResp> {
54
- const base = getBaseUrl();
55
- const res = await fetchWithTimeout(`${base}/api/login`, {
56
- method: "POST",
57
- headers: { "Content-Type": "application/json" },
58
- body: JSON.stringify(payload),
59
- });
60
-
61
- const data = await parseJsonSafe(res);
62
- if (!res.ok) throw new Error(errMsg(data, `apiLogin failed (${res.status})`));
63
- return data as ApiLoginResp;
64
- }
65
-
66
- // --------------------
67
- // /api/chat
68
- // --------------------
69
- export type ApiChatReq = {
70
- user_id: string;
71
- message: string;
72
- learning_mode: string; // backend expects string (not strict union)
73
- language_preference?: string; // "Auto" | "English" | "中文"
74
- doc_type?: string; // "Syllabus" | "Lecture Slides / PPT" | ...
75
- };
76
-
77
- export type ApiChatRef = { source_file?: string; section?: string };
78
-
79
- export type ApiChatResp = {
80
- reply: string;
81
- session_status_md: string;
82
- refs: ApiChatRef[];
83
- latency_ms: number;
84
- };
85
-
86
- export async function apiChat(payload: ApiChatReq): Promise<ApiChatResp> {
87
- const base = getBaseUrl();
88
- const res = await fetchWithTimeout(`${base}/api/chat`, {
89
- method: "POST",
90
- headers: { "Content-Type": "application/json" },
91
- body: JSON.stringify({
92
- language_preference: "Auto",
93
- doc_type: "Syllabus",
94
- ...payload,
95
- }),
96
- }, 60000); // chat can be slow
97
-
98
- const data = await parseJsonSafe(res);
99
- if (!res.ok) throw new Error(errMsg(data, `apiChat failed (${res.status})`));
100
-
101
- // backend returns { reply, session_status_md, refs, latency_ms }
102
- return data as ApiChatResp;
103
- }
104
-
105
- // --------------------
106
- // /api/upload
107
- // --------------------
108
- export type ApiUploadResp = {
109
- ok: boolean;
110
- added_chunks?: number;
111
- status_md?: string;
112
- error?: string;
113
- };
114
-
115
- export async function apiUpload(args: { user_id: string; doc_type: string; file: File }): Promise<ApiUploadResp> {
116
- const base = getBaseUrl();
117
- const fd = new FormData();
118
- fd.append("user_id", args.user_id);
119
- fd.append("doc_type", args.doc_type);
120
- fd.append("file", args.file);
121
-
122
- const res = await fetchWithTimeout(`${base}/api/upload`, { method: "POST", body: fd }, 120000);
123
- const data = await parseJsonSafe(res);
124
- if (!res.ok) throw new Error(errMsg(data, `apiUpload failed (${res.status})`));
125
- return data as ApiUploadResp;
126
- }
127
-
128
- // --------------------
129
- // /api/export
130
- // --------------------
131
- export async function apiExport(payload: { user_id: string; learning_mode: string }): Promise<{ markdown: string }> {
132
- const base = getBaseUrl();
133
- const res = await fetchWithTimeout(`${base}/api/export`, {
134
- method: "POST",
135
- headers: { "Content-Type": "application/json" },
136
- body: JSON.stringify(payload),
137
- });
138
-
139
- const data = await parseJsonSafe(res);
140
- if (!res.ok) throw new Error(errMsg(data, `apiExport failed (${res.status})`));
141
- return data as { markdown: string };
142
- }
143
-
144
- // --------------------
145
- // /api/summary
146
- // --------------------
147
- export async function apiSummary(payload: { user_id: string; learning_mode: string; language_preference?: string }): Promise<{ markdown: string }> {
148
- const base = getBaseUrl();
149
- const res = await fetchWithTimeout(`${base}/api/summary`, {
150
- method: "POST",
151
- headers: { "Content-Type": "application/json" },
152
- body: JSON.stringify({ language_preference: "Auto", ...payload }),
153
- });
154
-
155
- const data = await parseJsonSafe(res);
156
- if (!res.ok) throw new Error(errMsg(data, `apiSummary failed (${res.status})`));
157
- return data as { markdown: string };
158
- }
159
-
160
- // --------------------
161
- // /api/feedback
162
- // --------------------
163
- export type ApiFeedbackReq = {
164
- user_id: string;
165
- rating: "helpful" | "not_helpful";
166
- assistant_message_id?: string;
167
- assistant_text: string;
168
- user_text?: string;
169
- comment?: string;
170
- tags?: string[];
171
- refs?: string[];
172
- learning_mode?: string;
173
- doc_type?: string;
174
- timestamp_ms?: number;
175
- };
176
-
177
- export async function apiFeedback(payload: ApiFeedbackReq): Promise<{ ok: boolean }> {
178
- const base = getBaseUrl();
179
- const res = await fetchWithTimeout(`${base}/api/feedback`, {
180
- method: "POST",
181
- headers: { "Content-Type": "application/json" },
182
- body: JSON.stringify(payload),
183
- });
184
-
185
- const data = await parseJsonSafe(res);
186
- if (!res.ok) throw new Error(errMsg(data, `apiFeedback failed (${res.status})`));
187
- return data as { ok: boolean };
188
- }
189
-
190
- // --------------------
191
- // /api/memoryline
192
- // --------------------
193
- export async function apiMemoryline(user_id: string): Promise<{ next_review_label: string; progress_pct: number }> {
194
- const base = getBaseUrl();
195
- const res = await fetchWithTimeout(`${base}/api/memoryline?user_id=${encodeURIComponent(user_id)}`, { method: "GET" });
196
- const data = await parseJsonSafe(res);
197
- if (!res.ok) throw new Error(errMsg(data, `apiMemoryline failed (${res.status})`));
198
- return data as { next_review_label: string; progress_pct: number };
199
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # api/server.py
2
+ import os
3
+ import time
4
+ import threading
5
+ from typing import Dict, List, Optional, Any, Tuple
6
+
7
+ from fastapi import FastAPI, UploadFile, File, Form, Request
8
+ from fastapi.responses import FileResponse, JSONResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel
12
+
13
+ from api.config import DEFAULT_COURSE_TOPICS, DEFAULT_MODEL
14
+ from api.syllabus_utils import extract_course_topics_from_file
15
+ from api.rag_engine import build_rag_chunks_from_file, retrieve_relevant_chunks
16
+ from api.clare_core import (
17
+ detect_language,
18
+ chat_with_clare,
19
+ update_weaknesses_from_message,
20
+ update_cognitive_state_from_message,
21
+ render_session_status,
22
+ export_conversation,
23
+ summarize_conversation,
24
+ )
25
+
26
+ # ✅ LangSmith (optional)
27
+ try:
28
+ from langsmith import Client
29
+ except Exception:
30
+ Client = None
31
+
32
+ # ----------------------------
33
+ # Paths / Constants
34
+ # ----------------------------
35
+ API_DIR = os.path.dirname(__file__)
36
+
37
+ MODULE10_PATH = os.path.join(API_DIR, "module10_responsible_ai.pdf")
38
+ MODULE10_DOC_TYPE = "Literature Review / Paper"
39
+
40
+ WEB_DIST = os.path.abspath(os.path.join(API_DIR, "..", "web", "build"))
41
+ WEB_INDEX = os.path.join(WEB_DIST, "index.html")
42
+ WEB_ASSETS = os.path.join(WEB_DIST, "assets")
43
+
44
+ LS_DATASET_NAME = os.getenv("LS_DATASET_NAME", "clare_user_events").strip()
45
+ LS_PROJECT = os.getenv("LANGSMITH_PROJECT", os.getenv("LANGCHAIN_PROJECT", "")).strip()
46
+
47
+ EXPERIMENT_ID = os.getenv("CLARE_EXPERIMENT_ID", "RESP_AI_W10").strip()
48
+
49
+ # ----------------------------
50
+ # Health / Warmup (cold start mitigation)
51
+ # ----------------------------
52
+ APP_START_TS = time.time()
53
+
54
+ WARMUP_DONE = False
55
+ WARMUP_ERROR: Optional[str] = None
56
+ WARMUP_STARTED = False
57
+
58
+ CLARE_ENABLE_WARMUP = os.getenv("CLARE_ENABLE_WARMUP", "1").strip() == "1"
59
+ CLARE_WARMUP_BLOCK_READY = os.getenv("CLARE_WARMUP_BLOCK_READY", "0").strip() == "1"
60
+
61
+ CLARE_ENABLE_LANGSMITH_LOG = os.getenv("CLARE_ENABLE_LANGSMITH_LOG", "0").strip() == "1"
62
+ CLARE_LANGSMITH_ASYNC = os.getenv("CLARE_LANGSMITH_ASYNC", "1").strip() == "1"
63
+
64
+ # ----------------------------
65
+ # App
66
+ # ----------------------------
67
+ app = FastAPI(title="Clare API")
68
+
69
+ app.add_middleware(
70
+ CORSMiddleware,
71
+ allow_origins=["*"],
72
+ allow_credentials=True,
73
+ allow_methods=["*"],
74
+ allow_headers=["*"],
75
+ )
76
+
77
+ # ----------------------------
78
+ # Static hosting (Vite build)
79
+ # ----------------------------
80
+ if os.path.isdir(WEB_ASSETS):
81
+ app.mount("/assets", StaticFiles(directory=WEB_ASSETS), name="assets")
82
+
83
+ if os.path.isdir(WEB_DIST):
84
+ app.mount("/static", StaticFiles(directory=WEB_DIST), name="static")
85
+
86
+
87
+ @app.get("/")
88
+ def index():
89
+ if os.path.exists(WEB_INDEX):
90
+ return FileResponse(WEB_INDEX)
91
+ return JSONResponse(
92
+ {"detail": "web/build not found. Build frontend first (web/build/index.html)."},
93
+ status_code=500,
94
+ )
95
+
96
+
97
+ # ----------------------------
98
+ # In-memory session store (MVP)
99
+ # ----------------------------
100
+ SESSIONS: Dict[str, Dict[str, Any]] = {}
101
+
102
+
103
+ def _preload_module10_chunks() -> List[Dict[str, Any]]:
104
+ if os.path.exists(MODULE10_PATH):
105
+ try:
106
+ return build_rag_chunks_from_file(MODULE10_PATH, MODULE10_DOC_TYPE) or []
107
+ except Exception as e:
108
+ print(f"[preload] module10 parse failed: {repr(e)}")
109
+ return []
110
+ return []
111
+
112
+
113
+ MODULE10_CHUNKS_CACHE = _preload_module10_chunks()
114
+
115
+
116
+ def _get_session(user_id: str) -> Dict[str, Any]:
117
+ if user_id not in SESSIONS:
118
+ SESSIONS[user_id] = {
119
+ "user_id": user_id,
120
+ "name": "",
121
+ "history": [], # List[Tuple[str, str]]
122
+ "weaknesses": [],
123
+ "cognitive_state": {"confusion": 0, "mastery": 0},
124
+ "course_outline": DEFAULT_COURSE_TOPICS,
125
+ "rag_chunks": list(MODULE10_CHUNKS_CACHE),
126
+ "model_name": DEFAULT_MODEL,
127
+ }
128
+ return SESSIONS[user_id]
129
+
130
+
131
+ # ----------------------------
132
+ # Warmup
133
+ # ----------------------------
134
+ def _do_warmup_once():
135
+ global WARMUP_DONE, WARMUP_ERROR, WARMUP_STARTED
136
+ if WARMUP_STARTED:
137
+ return
138
+ WARMUP_STARTED = True
139
+
140
+ try:
141
+ from api.config import client
142
+ client.models.list()
143
+ _ = MODULE10_CHUNKS_CACHE
144
+ WARMUP_DONE = True
145
+ WARMUP_ERROR = None
146
+ except Exception as e:
147
+ WARMUP_DONE = False
148
+ WARMUP_ERROR = repr(e)
149
+
150
+
151
+ def _start_warmup_background():
152
+ if not CLARE_ENABLE_WARMUP:
153
+ return
154
+ threading.Thread(target=_do_warmup_once, daemon=True).start()
155
+
156
+
157
+ @app.on_event("startup")
158
+ def _on_startup():
159
+ _start_warmup_background()
160
+
161
+
162
+ # ----------------------------
163
+ # LangSmith helpers
164
+ # ----------------------------
165
+ _ls_client = None
166
+ if (Client is not None) and CLARE_ENABLE_LANGSMITH_LOG:
167
+ try:
168
+ _ls_client = Client()
169
+ except Exception as e:
170
+ print("[langsmith] init failed:", repr(e))
171
+ _ls_client = None
172
+
173
+
174
+ def _log_event_to_langsmith(data: Dict[str, Any]):
175
+ if _ls_client is None:
176
+ return
177
+
178
+ def _do():
179
+ try:
180
+ inputs = {
181
+ "question": data.get("question", ""),
182
+ "student_id": data.get("student_id", ""),
183
+ "student_name": data.get("student_name", ""),
184
+ }
185
+ outputs = {"answer": data.get("answer", "")}
186
+
187
+ # keep metadata clean and JSON-serializable
188
+ metadata = {k: v for k, v in data.items() if k not in ("question", "answer")}
189
+
190
+ if LS_PROJECT:
191
+ metadata.setdefault("langsmith_project", LS_PROJECT)
192
+
193
+ _ls_client.create_example(
194
+ inputs=inputs,
195
+ outputs=outputs,
196
+ metadata=metadata,
197
+ dataset_name=LS_DATASET_NAME,
198
+ )
199
+ except Exception as e:
200
+ print("[langsmith] log failed:", repr(e))
201
+
202
+ if CLARE_LANGSMITH_ASYNC:
203
+ threading.Thread(target=_do, daemon=True).start()
204
+ else:
205
+ _do()
206
+
207
+
208
+ # ----------------------------
209
+ # Health endpoints
210
+ # ----------------------------
211
+ @app.get("/health")
212
+ def health():
213
+ return {
214
+ "ok": True,
215
+ "uptime_s": round(time.time() - APP_START_TS, 3),
216
+ "warmup_enabled": CLARE_ENABLE_WARMUP,
217
+ "warmup_started": bool(WARMUP_STARTED),
218
+ "warmup_done": bool(WARMUP_DONE),
219
+ "warmup_error": WARMUP_ERROR,
220
+ "ready": bool(WARMUP_DONE) if CLARE_WARMUP_BLOCK_READY else True,
221
+ "langsmith_enabled": bool(CLARE_ENABLE_LANGSMITH_LOG),
222
+ "langsmith_async": bool(CLARE_LANGSMITH_ASYNC),
223
+ "ts": int(time.time()),
224
+ }
225
+
226
+
227
+ @app.get("/ready")
228
+ def ready():
229
+ if not CLARE_ENABLE_WARMUP or not CLARE_WARMUP_BLOCK_READY:
230
+ return {"ready": True}
231
+ if WARMUP_DONE:
232
+ return {"ready": True}
233
+ return JSONResponse({"ready": False, "error": WARMUP_ERROR}, status_code=503)
234
+
235
+
236
+ # ----------------------------
237
+ # Schemas
238
+ # ----------------------------
239
+ class LoginReq(BaseModel):
240
+ name: str
241
+ user_id: str
242
+
243
+
244
+ class ChatReq(BaseModel):
245
+ user_id: str
246
+ message: str
247
+ learning_mode: str
248
+ language_preference: str = "Auto"
249
+ doc_type: str = "Syllabus"
250
+
251
+
252
+ class ExportReq(BaseModel):
253
+ user_id: str
254
+ learning_mode: str
255
+
256
+
257
+ class SummaryReq(BaseModel):
258
+ user_id: str
259
+ learning_mode: str
260
+ language_preference: str = "Auto"
261
+
262
+
263
+ class FeedbackReq(BaseModel):
264
+ # IMPORTANT: allow extra fields so FE can evolve without breaking backend
265
+ class Config:
266
+ extra = "ignore"
267
+
268
+ user_id: str
269
+ rating: str # "helpful" | "not_helpful"
270
+ assistant_message_id: Optional[str] = None
271
+
272
+ assistant_text: str
273
+ user_text: Optional[str] = ""
274
+
275
+ comment: Optional[str] = ""
276
+
277
+ # optional structured fields
278
+ tags: Optional[List[str]] = []
279
+ refs: Optional[List[str]] = []
280
+
281
+ learning_mode: Optional[str] = None
282
+ doc_type: Optional[str] = None
283
+ timestamp_ms: Optional[int] = None
284
+
285
+
286
+ # ----------------------------
287
+ # API Routes
288
+ # ----------------------------
289
+ @app.post("/api/login")
290
+ def login(req: LoginReq):
291
+ user_id = (req.user_id or "").strip()
292
+ name = (req.name or "").strip()
293
+ if not user_id or not name:
294
+ return JSONResponse({"ok": False, "error": "Missing name/user_id"}, status_code=400)
295
+
296
+ sess = _get_session(user_id)
297
+ sess["name"] = name
298
+ return {"ok": True, "user": {"name": name, "user_id": user_id}}
299
+
300
+
301
+ @app.post("/api/chat")
302
+ def chat(req: ChatReq):
303
+ user_id = (req.user_id or "").strip()
304
+ msg = (req.message or "").strip()
305
+ if not user_id:
306
+ return JSONResponse({"error": "Missing user_id"}, status_code=400)
307
+
308
+ sess = _get_session(user_id)
309
+
310
+ if not msg:
311
+ return {
312
+ "reply": "",
313
+ "session_status_md": render_session_status(
314
+ req.learning_mode, sess["weaknesses"], sess["cognitive_state"]
315
+ ),
316
+ "refs": [],
317
+ "latency_ms": 0.0,
318
+ }
319
+
320
+ t0 = time.time()
321
+ marks_ms: Dict[str, float] = {"start": 0.0}
322
+
323
+ resolved_lang = detect_language(msg, req.language_preference)
324
+ marks_ms["language_detect_done"] = (time.time() - t0) * 1000.0
325
+
326
+ sess["weaknesses"] = update_weaknesses_from_message(msg, sess["weaknesses"])
327
+ marks_ms["weakness_update_done"] = (time.time() - t0) * 1000.0
328
+
329
+ sess["cognitive_state"] = update_cognitive_state_from_message(msg, sess["cognitive_state"])
330
+ marks_ms["cognitive_update_done"] = (time.time() - t0) * 1000.0
331
+
332
+ if len(msg) < 20 and ("?" not in msg):
333
+ rag_context_text, rag_used_chunks = "", []
334
+ else:
335
+ rag_context_text, rag_used_chunks = retrieve_relevant_chunks(msg, sess["rag_chunks"])
336
+ marks_ms["rag_retrieve_done"] = (time.time() - t0) * 1000.0
337
+
338
+ try:
339
+ answer, new_history = chat_with_clare(
340
+ message=msg,
341
+ history=sess["history"],
342
+ model_name=sess["model_name"],
343
+ language_preference=resolved_lang,
344
+ learning_mode=req.learning_mode,
345
+ doc_type=req.doc_type,
346
+ course_outline=sess["course_outline"],
347
+ weaknesses=sess["weaknesses"],
348
+ cognitive_state=sess["cognitive_state"],
349
+ rag_context=rag_context_text,
350
+ )
351
+ except Exception as e:
352
+ print(f"[chat] error: {repr(e)}")
353
+ return JSONResponse({"error": f"chat failed: {repr(e)}"}, status_code=500)
354
+
355
+ marks_ms["llm_done"] = (time.time() - t0) * 1000.0
356
+ total_ms = marks_ms["llm_done"]
357
+
358
+ ordered = [
359
+ "start",
360
+ "language_detect_done",
361
+ "weakness_update_done",
362
+ "cognitive_update_done",
363
+ "rag_retrieve_done",
364
+ "llm_done",
365
+ ]
366
+ segments_ms: Dict[str, float] = {}
367
+ for i in range(1, len(ordered)):
368
+ a = ordered[i - 1]
369
+ b = ordered[i]
370
+ segments_ms[b] = max(0.0, marks_ms.get(b, 0.0) - marks_ms.get(a, 0.0))
371
+
372
+ latency_breakdown = {"marks_ms": marks_ms, "segments_ms": segments_ms, "total_ms": total_ms}
373
+
374
+ sess["history"] = new_history
375
+
376
+ refs = [
377
+ {"source_file": c.get("source_file"), "section": c.get("section")}
378
+ for c in (rag_used_chunks or [])
379
+ ]
380
+
381
+ rag_context_chars = len(rag_context_text or "")
382
+ rag_used_chunks_count = len(rag_used_chunks or [])
383
+ history_len = len(sess["history"])
384
+
385
+ _log_event_to_langsmith(
386
+ {
387
+ "experiment_id": EXPERIMENT_ID,
388
+ "student_id": user_id,
389
+ "student_name": sess.get("name", ""),
390
+ "event_type": "chat_turn",
391
+ "timestamp": time.time(),
392
+ "latency_ms": total_ms,
393
+ "latency_breakdown": latency_breakdown,
394
+ "rag_context_chars": rag_context_chars,
395
+ "rag_used_chunks_count": rag_used_chunks_count,
396
+ "history_len": history_len,
397
+ "question": msg,
398
+ "answer": answer,
399
+ "model_name": sess["model_name"],
400
+ "language": resolved_lang,
401
+ "learning_mode": req.learning_mode,
402
+ "doc_type": req.doc_type,
403
+ "refs": refs,
404
+ }
405
+ )
406
+
407
+ return {
408
+ "reply": answer,
409
+ "session_status_md": render_session_status(
410
+ req.learning_mode, sess["weaknesses"], sess["cognitive_state"]
411
+ ),
412
+ "refs": refs,
413
+ "latency_ms": total_ms,
414
+ }
415
+
416
+
417
+ @app.post("/api/upload")
418
+ async def upload(
419
+ user_id: str = Form(...),
420
+ doc_type: str = Form(...),
421
+ file: UploadFile = File(...),
422
+ ):
423
+ user_id = (user_id or "").strip()
424
+ doc_type = (doc_type or "").strip()
425
+
426
+ if not user_id:
427
+ return JSONResponse({"ok": False, "error": "Missing user_id"}, status_code=400)
428
+ if not file or not file.filename:
429
+ return JSONResponse({"ok": False, "error": "Missing file"}, status_code=400)
430
+
431
+ sess = _get_session(user_id)
432
+
433
+ safe_name = os.path.basename(file.filename).replace("..", "_")
434
+ tmp_path = os.path.join("/tmp", safe_name)
435
+
436
+ content = await file.read()
437
+ with open(tmp_path, "wb") as f:
438
+ f.write(content)
439
+
440
+ if doc_type == "Syllabus":
441
+ class _F:
442
+ pass
443
+ fo = _F()
444
+ fo.name = tmp_path
445
+ try:
446
+ sess["course_outline"] = extract_course_topics_from_file(fo, doc_type)
447
+ except Exception as e:
448
+ print(f"[upload] syllabus parse error: {repr(e)}")
449
+
450
+ try:
451
+ new_chunks = build_rag_chunks_from_file(tmp_path, doc_type) or []
452
+ sess["rag_chunks"] = (sess["rag_chunks"] or []) + new_chunks
453
+ except Exception as e:
454
+ print(f"[upload] rag build error: {repr(e)}")
455
+ new_chunks = []
456
+
457
+ status_md = f"✅ Loaded base reading + uploaded {doc_type} file."
458
+
459
+ _log_event_to_langsmith(
460
+ {
461
+ "experiment_id": EXPERIMENT_ID,
462
+ "student_id": user_id,
463
+ "student_name": sess.get("name", ""),
464
+ "event_type": "upload",
465
+ "timestamp": time.time(),
466
+ "doc_type": doc_type,
467
+ "filename": safe_name,
468
+ "added_chunks": len(new_chunks),
469
+ "question": f"[upload] {safe_name}",
470
+ "answer": status_md,
471
+ }
472
+ )
473
+
474
+ return {"ok": True, "added_chunks": len(new_chunks), "status_md": status_md}
475
+
476
+
477
+ @app.post("/api/feedback")
478
+ def api_feedback(req: FeedbackReq):
479
+ user_id = (req.user_id or "").strip()
480
+ if not user_id:
481
+ return JSONResponse({"ok": False, "error": "Missing user_id"}, status_code=400)
482
+
483
+ sess = _get_session(user_id)
484
+ student_name = sess.get("name", "")
485
+
486
+ rating = (req.rating or "").strip().lower()
487
+ if rating not in ("helpful", "not_helpful"):
488
+ return JSONResponse({"ok": False, "error": "Invalid rating"}, status_code=400)
489
+
490
+ # normalize fields
491
+ assistant_text = (req.assistant_text or "").strip()
492
+ user_text = (req.user_text or "").strip()
493
+ comment = (req.comment or "").strip()
494
+ refs = req.refs or []
495
+ tags = req.tags or []
496
+ timestamp_ms = int(req.timestamp_ms or int(time.time() * 1000))
497
+
498
+ _log_event_to_langsmith(
499
+ {
500
+ "experiment_id": EXPERIMENT_ID,
501
+ "student_id": user_id,
502
+ "student_name": student_name,
503
+ "event_type": "feedback",
504
+ "timestamp": time.time(),
505
+ "timestamp_ms": timestamp_ms,
506
+ "rating": rating,
507
+ "assistant_message_id": req.assistant_message_id,
508
+
509
+ # Keep the Example readable:
510
+ "question": user_text, # what user asked (optional)
511
+ "answer": assistant_text, # the assistant response being rated
512
+
513
+ # metadata
514
+ "comment": comment,
515
+ "tags": tags,
516
+ "refs": refs,
517
+ "learning_mode": req.learning_mode,
518
+ "doc_type": req.doc_type,
519
+ }
520
+ )
521
+
522
+ return {"ok": True}
523
+
524
+
525
+ @app.post("/api/export")
526
+ def api_export(req: ExportReq):
527
+ user_id = (req.user_id or "").strip()
528
+ if not user_id:
529
+ return JSONResponse({"error": "Missing user_id"}, status_code=400)
530
+
531
+ sess = _get_session(user_id)
532
+ md = export_conversation(
533
+ sess["history"],
534
+ sess["course_outline"],
535
+ req.learning_mode,
536
+ sess["weaknesses"],
537
+ sess["cognitive_state"],
538
+ )
539
+ return {"markdown": md}
540
+
541
+
542
+ @app.post("/api/summary")
543
+ def api_summary(req: SummaryReq):
544
+ user_id = (req.user_id or "").strip()
545
+ if not user_id:
546
+ return JSONResponse({"error": "Missing user_id"}, status_code=400)
547
+
548
+ sess = _get_session(user_id)
549
+ md = summarize_conversation(
550
+ sess["history"],
551
+ sess["course_outline"],
552
+ sess["weaknesses"],
553
+ sess["cognitive_state"],
554
+ sess["model_name"],
555
+ req.language_preference,
556
+ )
557
+ return {"markdown": md}
558
+
559
+
560
+ @app.get("/api/memoryline")
561
+ def memoryline(user_id: str):
562
+ _ = _get_session((user_id or "").strip())
563
+ return {"next_review_label": "T+7", "progress_pct": 0.4}
564
+
565
+
566
+ # ----------------------------
567
+ # SPA Fallback
568
+ # ----------------------------
569
+ @app.get("/{full_path:path}")
570
+ def spa_fallback(full_path: str, request: Request):
571
+ if (
572
+ full_path.startswith("api/")
573
+ or full_path.startswith("assets/")
574
+ or full_path.startswith("static/")
575
+ ):
576
+ return JSONResponse({"detail": "Not Found"}, status_code=404)
577
+
578
+ if os.path.exists(WEB_INDEX):
579
+ return FileResponse(WEB_INDEX)
580
+
581
+ return JSONResponse(
582
+ {"detail": "web/build not found. Build frontend first (web/build/index.html)."},
583
+ status_code=500,
584
+ )