Deign86 commited on
Commit
ac19778
·
verified ·
1 Parent(s): 5168371

Upload backend/routes/rag_routes.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. backend/routes/rag_routes.py +427 -0
backend/routes/rag_routes.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from threading import Lock
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from fastapi import APIRouter, HTTPException, Request
12
+ from pydantic import BaseModel, Field
13
+
14
+ from services.inference_client import (
15
+ InferenceRequest,
16
+ create_default_client,
17
+ is_sequential_model,
18
+ get_model_for_task,
19
+ )
20
+ from rag.curriculum_rag import (
21
+ build_analysis_curriculum_context,
22
+ build_lesson_prompt,
23
+ build_lesson_query,
24
+ build_problem_generation_prompt,
25
+ format_retrieved_chunks,
26
+ retrieve_curriculum_context,
27
+ retrieve_lesson_pdf_context,
28
+ summarize_retrieval_confidence,
29
+ )
30
+ from rag.vectorstore_loader import get_vectorstore_health, reset_vectorstore_singleton
31
+
32
+ try:
33
+ from firebase_admin import firestore as firebase_firestore
34
+ except Exception:
35
+ firebase_firestore = None
36
+
37
+ logger = logging.getLogger("mathpulse.rag")
38
+ router = APIRouter(prefix="/api/rag", tags=["rag"])
39
+
40
+ _inference_client = None
41
+ _inference_lock = Lock()
42
+
43
+
44
+ def _get_inference_client():
45
+ global _inference_client
46
+ if _inference_client is None:
47
+ with _inference_lock:
48
+ if _inference_client is None:
49
+ _inference_client = create_default_client()
50
+ return _inference_client
51
+
52
+
53
+ async def _generate_text(
54
+ prompt: str,
55
+ task_type: str,
56
+ max_new_tokens: int = 900,
57
+ enable_thinking: bool = False,
58
+ ) -> str:
59
+ request = InferenceRequest(
60
+ messages=[
61
+ {"role": "system", "content": "You are a precise DepEd-aligned curriculum assistant."},
62
+ {"role": "user", "content": prompt},
63
+ ],
64
+ task_type=task_type,
65
+ max_new_tokens=max_new_tokens,
66
+ temperature=0.2,
67
+ top_p=0.9,
68
+ enable_thinking=enable_thinking,
69
+ )
70
+ return _get_inference_client().generate_from_messages(request)
71
+
72
+
73
+ def _log_rag_usage(
74
+ request: Request,
75
+ *,
76
+ event_type: str,
77
+ topic: str,
78
+ subject: str,
79
+ quarter: Optional[int],
80
+ chunks: List[Dict[str, Any]],
81
+ ) -> None:
82
+ if firebase_firestore is None:
83
+ return
84
+ try:
85
+ user = getattr(request.state, "user", None)
86
+ uid = getattr(user, "uid", None)
87
+ domains = sorted({str(chunk.get("content_domain") or "").strip() for chunk in chunks if chunk.get("content_domain")})
88
+ top_score = max((float(chunk.get("score") or 0.0) for chunk in chunks), default=0.0)
89
+ payload = {
90
+ "userId": uid,
91
+ "type": event_type,
92
+ "topic": topic,
93
+ "subject": subject,
94
+ "quarter": quarter,
95
+ "retrievedChunks": len(chunks),
96
+ "topScore": top_score,
97
+ "curriculumDomainsHit": domains,
98
+ "timestamp": firebase_firestore.SERVER_TIMESTAMP,
99
+ "createdAtIso": datetime.now(timezone.utc).isoformat(),
100
+ }
101
+ firebase_firestore.client().collection("rag_usage").add(payload)
102
+ except Exception as exc:
103
+ logger.warning("rag_usage logging skipped: %s", exc)
104
+
105
+
106
+ def _strip_thinking_and_parse(text: str) -> dict:
107
+ cleaned = text.strip()
108
+ cleaned = re.sub(r" </think>", "", cleaned, flags=re.DOTALL).strip()
109
+ if "{" in cleaned and "}" in cleaned:
110
+ try:
111
+ start = cleaned.find("{")
112
+ end = cleaned.rfind("}") + 1
113
+ parsed = json.loads(cleaned[start:end])
114
+ if isinstance(parsed, dict):
115
+ return parsed
116
+ except Exception:
117
+ pass
118
+ return {"explanation": text}
119
+
120
+
121
+ class RagLessonRequest(BaseModel):
122
+ topic: str
123
+ subject: str
124
+ quarter: int
125
+ lessonTitle: Optional[str] = None
126
+ learningCompetency: Optional[str] = None
127
+ moduleUnit: Optional[str] = None
128
+ learnerLevel: Optional[str] = None
129
+ userId: Optional[str] = None
130
+ moduleId: Optional[str] = None
131
+ lessonId: Optional[str] = None
132
+ competencyCode: Optional[str] = None
133
+ storagePath: Optional[str] = None
134
+
135
+
136
+ class RagProblemRequest(BaseModel):
137
+ topic: str
138
+ subject: str
139
+ quarter: int
140
+ difficulty: str = Field(default="medium")
141
+ userId: Optional[str] = None
142
+
143
+
144
+ class RagAnalysisContextRequest(BaseModel):
145
+ weakTopics: List[str]
146
+ subject: str
147
+ userId: Optional[str] = None
148
+
149
+
150
+ @router.get("/health")
151
+ async def rag_health():
152
+ active_model = get_model_for_task("rag_lesson")
153
+ is_seq = is_sequential_model(active_model)
154
+ try:
155
+ health = get_vectorstore_health()
156
+ return {
157
+ "status": "ok",
158
+ "chunkCount": health["chunkCount"],
159
+ "subjects": health["subjects"],
160
+ "lastIngested": datetime.now(timezone.utc).isoformat(),
161
+ "activeModel": active_model,
162
+ "isSequentialModel": is_seq,
163
+ }
164
+ except Exception as exc:
165
+ return {
166
+ "status": "degraded",
167
+ "chunkCount": 0,
168
+ "subjects": {},
169
+ "lastIngested": None,
170
+ "activeModel": active_model,
171
+ "isSequentialModel": is_seq,
172
+ "warning": str(exc),
173
+ }
174
+
175
+
176
+ def _fetch_youtube_video(lesson_title: str, subject: str, competency: str, quarter: int) -> dict:
177
+ try:
178
+ from backend.services.youtube_service import get_video_for_lesson
179
+ except ImportError:
180
+ return {}
181
+ try:
182
+ video = get_video_for_lesson(lesson_title, subject, competency, quarter)
183
+ return video or {}
184
+ except Exception as e:
185
+ logger.warning("YouTube search failed: %s", e)
186
+ return {}
187
+
188
+
189
+ def _ensure_7_sections(lesson_data: dict, lesson_title: str) -> dict:
190
+ sections = lesson_data.get("sections", [])
191
+ section_types = {s.get("type") for s in sections}
192
+ required = ["introduction", "key_concepts", "video", "worked_examples", "important_notes", "try_it_yourself", "summary"]
193
+
194
+ default_content = {
195
+ "introduction": {"type": "introduction", "title": "Introduction", "content": f"Welcome to the lesson on {lesson_title}."},
196
+ "key_concepts": {"type": "key_concepts", "title": "Key Concepts", "content": "Below are the key concepts covered in this lesson.", "callouts": []},
197
+ "video": {"type": "video", "title": "Video Lesson", "content": "Watch this explanation to understand the concepts visually.", "videoId": "", "videoTitle": "", "videoChannel": "", "embedUrl": "", "thumbnailUrl": ""},
198
+ "worked_examples": {"type": "worked_examples", "title": "Worked Examples", "examples": []},
199
+ "important_notes": {"type": "important_notes", "title": "Important Notes", "bulletPoints": []},
200
+ "try_it_yourself": {"type": "try_it_yourself", "title": "Try It Yourself", "practiceProblems": []},
201
+ "summary": {"type": "summary", "title": "Summary", "content": f"Great job completing the lesson on {lesson_title}!"},
202
+ }
203
+
204
+ filled = {}
205
+ for req_type in required:
206
+ for existing in sections:
207
+ if existing.get("type") == req_type:
208
+ filled[req_type] = existing
209
+ break
210
+ else:
211
+ filled[req_type] = default_content[req_type]
212
+
213
+ ordered = [filled[t] for t in required]
214
+
215
+ for i, section in enumerate(ordered):
216
+ s_type = section.get("type")
217
+ if s_type == "key_concepts" and not section.get("callouts"):
218
+ section["callouts"] = []
219
+ if s_type == "worked_examples" and not section.get("examples"):
220
+ section["examples"] = []
221
+ if s_type == "important_notes" and not section.get("bulletPoints"):
222
+ section["bulletPoints"] = []
223
+ if s_type == "try_it_yourself" and not section.get("practiceProblems"):
224
+ section["practiceProblems"] = []
225
+ ordered[i] = section
226
+
227
+ return {**lesson_data, "sections": ordered}
228
+
229
+
230
+ @router.post("/lesson")
231
+ async def rag_lesson(request: Request, payload: RagLessonRequest):
232
+ try:
233
+ chunks, retrieval_mode = retrieve_lesson_pdf_context(
234
+ query=build_lesson_query(
235
+ payload.topic,
236
+ payload.subject,
237
+ payload.quarter,
238
+ lesson_title=payload.lessonTitle,
239
+ competency=payload.learningCompetency,
240
+ module_unit=payload.moduleUnit,
241
+ learner_level=payload.learnerLevel,
242
+ ),
243
+ subject=payload.subject,
244
+ quarter=payload.quarter,
245
+ lesson_title=payload.lessonTitle,
246
+ competency=payload.learningCompetency,
247
+ module_id=payload.moduleId,
248
+ lesson_id=payload.lessonId,
249
+ competency_code=payload.competencyCode,
250
+ storage_path=payload.storagePath,
251
+ top_k=8,
252
+ )
253
+
254
+ if not chunks:
255
+ raise HTTPException(
256
+ status_code=404,
257
+ detail={
258
+ "error": "no_curriculum_context",
259
+ "message": f"No curriculum content found for lesson '{payload.lessonTitle}' ({payload.subject} Q{payload.quarter}). Please ensure the PDF has been ingested.",
260
+ "retrievalBand": "low",
261
+ "sources": [],
262
+ },
263
+ )
264
+
265
+ prompt = build_lesson_prompt(
266
+ lesson_title=payload.lessonTitle or payload.topic,
267
+ competency=payload.learningCompetency or payload.topic,
268
+ grade_level="Grade 11-12",
269
+ subject=payload.subject,
270
+ quarter=payload.quarter,
271
+ learner_level=payload.learnerLevel,
272
+ module_unit=payload.moduleUnit,
273
+ curriculum_chunks=chunks,
274
+ competency_code=payload.competencyCode,
275
+ )
276
+
277
+ raw_explanation = await _generate_text(
278
+ prompt,
279
+ task_type="lesson_generation",
280
+ max_new_tokens=1800,
281
+ enable_thinking=True,
282
+ )
283
+
284
+ parsed_lesson = _strip_thinking_and_parse(raw_explanation)
285
+ parsed_lesson = _ensure_7_sections(parsed_lesson, payload.lessonTitle or payload.topic)
286
+
287
+ if parsed_lesson.get("sections"):
288
+ video_section = next((s for s in parsed_lesson["sections"] if s.get("type") == "video"), None)
289
+ if video_section:
290
+ video_data = _fetch_youtube_video(
291
+ payload.lessonTitle or payload.topic,
292
+ payload.subject,
293
+ payload.learningCompetency or "",
294
+ payload.quarter,
295
+ )
296
+ if video_data:
297
+ video_section["videoId"] = video_data.get("videoId", "")
298
+ video_section["videoTitle"] = video_data.get("videoTitle", "")
299
+ video_section["videoChannel"] = video_data.get("videoChannel", "")
300
+ video_section["embedUrl"] = video_data.get("embedUrl", "")
301
+ video_section["thumbnailUrl"] = video_data.get("thumbnailUrl", "")
302
+
303
+ retrieval_summary = summarize_retrieval_confidence(chunks)
304
+
305
+ _log_rag_usage(
306
+ request,
307
+ event_type="lesson",
308
+ topic=build_lesson_query(payload.topic, payload.subject, payload.quarter, lesson_title=payload.lessonTitle),
309
+ subject=payload.subject,
310
+ quarter=payload.quarter,
311
+ chunks=chunks,
312
+ )
313
+
314
+ needs_review = parsed_lesson.get("needsReview", False)
315
+ if retrieval_summary.get("band") == "low":
316
+ needs_review = True
317
+
318
+ return {
319
+ **parsed_lesson,
320
+ "retrievalConfidence": retrieval_summary.get("confidence", 0.0),
321
+ "retrievalBand": retrieval_summary.get("band", "low"),
322
+ "retrievalMode": retrieval_mode,
323
+ "needsReview": needs_review,
324
+ "sources": [
325
+ {
326
+ "subject": row.get("subject"),
327
+ "quarter": row.get("quarter"),
328
+ "source_file": row.get("source_file"),
329
+ "storage_path": row.get("storage_path"),
330
+ "page": row.get("page"),
331
+ "score": row.get("score"),
332
+ "content_domain": row.get("content_domain"),
333
+ "chunk_type": row.get("chunk_type"),
334
+ "content": row.get("content"),
335
+ }
336
+ for row in chunks
337
+ ],
338
+ "activeModel": get_model_for_task("rag_lesson"),
339
+ }
340
+ except Exception as exc:
341
+ import traceback
342
+ logger.error(f"RAG lesson error: {type(exc).__name__}: {exc}\n{traceback.format_exc()}")
343
+ raise HTTPException(
344
+ status_code=500,
345
+ detail={
346
+ "error": type(exc).__name__,
347
+ "message": str(exc),
348
+ "traceback": traceback.format_exc(),
349
+ },
350
+ )
351
+
352
+
353
+ @router.post("/generate-problem")
354
+ async def rag_generate_problem(request: Request, payload: RagProblemRequest):
355
+ chunks = retrieve_curriculum_context(
356
+ query=payload.topic,
357
+ subject=payload.subject,
358
+ quarter=payload.quarter,
359
+ top_k=5,
360
+ )
361
+ prompt = build_problem_generation_prompt(payload.topic, payload.difficulty, chunks)
362
+ raw = await _generate_text(
363
+ prompt,
364
+ task_type="quiz_generation",
365
+ max_new_tokens=600,
366
+ enable_thinking=False,
367
+ )
368
+
369
+ parsed = _strip_thinking_and_parse(raw)
370
+
371
+ problem = str(parsed.get("problem") or raw)
372
+ if not problem or problem.startswith("{"):
373
+ problem = str(parsed.get("content") or str(parsed))
374
+ if len(problem) < 3 or problem.startswith("{"):
375
+ problem = raw
376
+ solution = str(parsed.get("solution") or "")
377
+ competency_ref = str(parsed.get("competencyReference") or "DepEd competency-aligned")
378
+
379
+ _log_rag_usage(
380
+ request,
381
+ event_type="problem_generation",
382
+ topic=payload.topic,
383
+ subject=payload.subject,
384
+ quarter=payload.quarter,
385
+ chunks=chunks,
386
+ )
387
+
388
+ return {
389
+ "problem": problem,
390
+ "solution": solution,
391
+ "competencyReference": competency_ref,
392
+ "sources": [
393
+ {
394
+ "subject": row.get("subject"),
395
+ "quarter": row.get("quarter"),
396
+ "source_file": row.get("source_file"),
397
+ "page": row.get("page"),
398
+ "score": row.get("score"),
399
+ }
400
+ for row in chunks
401
+ ],
402
+ }
403
+
404
+
405
+ @router.post("/analysis-context")
406
+ async def rag_analysis_context(request: Request, payload: RagAnalysisContextRequest):
407
+ if not payload.weakTopics:
408
+ raise HTTPException(status_code=400, detail="weakTopics must be a non-empty list")
409
+
410
+ chunks = build_analysis_curriculum_context(payload.weakTopics, payload.subject)
411
+ lines = ["LEARNING COMPETENCIES:"]
412
+ for index, row in enumerate(chunks, start=1):
413
+ lines.append(
414
+ f"{index}. {row.get('content')} (Source: {row.get('source_file')} p.{row.get('page')}, "
415
+ f"Q{row.get('quarter')}, {row.get('content_domain')})"
416
+ )
417
+
418
+ _log_rag_usage(
419
+ request,
420
+ event_type="analysis_context",
421
+ topic=", ".join(payload.weakTopics),
422
+ subject=payload.subject,
423
+ quarter=None,
424
+ chunks=chunks,
425
+ )
426
+
427
+ return {"curriculumContext": "\n".join(lines)}