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

Update api/server.py

Browse files
Files changed (1) hide show
  1. api/server.py +199 -584
api/server.py CHANGED
@@ -1,584 +1,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
- )
 
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
+ }