github-actions[bot] commited on
Commit
3fa58ae
·
1 Parent(s): b739f9d

🚀 Auto-deploy backend from GitHub (14767ef)

Browse files
main.py CHANGED
@@ -107,6 +107,7 @@ from routes.ai_monitoring import router as ai_monitoring_router
107
  from routes.class_analytics_routes import router as class_analytics_router
108
  from routes.intervention_routes import router as intervention_router
109
  from routes.pipeline_routes import router as pipeline_router
 
110
 
111
  # Rate limiting (slowapi)
112
  try:
@@ -1169,6 +1170,7 @@ app.include_router(ai_monitoring_router)
1169
  app.include_router(class_analytics_router)
1170
  app.include_router(intervention_router)
1171
  app.include_router(pipeline_router)
 
1172
 
1173
 
1174
  # ─── Global Exception Handler ─────────────────────────────────
 
107
  from routes.class_analytics_routes import router as class_analytics_router
108
  from routes.intervention_routes import router as intervention_router
109
  from routes.pipeline_routes import router as pipeline_router
110
+ from routes.deepseek_rag_routes import router as deepseek_rag_router
111
 
112
  # Rate limiting (slowapi)
113
  try:
 
1170
  app.include_router(class_analytics_router)
1171
  app.include_router(intervention_router)
1172
  app.include_router(pipeline_router)
1173
+ app.include_router(deepseek_rag_router)
1174
 
1175
 
1176
  # ─── Global Exception Handler ─────────────────────────────────
routes/deepseek_rag_routes.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG-grounded DeepSeek routes.
3
+
4
+ Feature 1: Topic-level weakness detection
5
+ Feature 2: AI preview for coming_soon modules
6
+ Feature 3: Personalized study tips per flagged topic
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ from typing import Optional
12
+ from pydantic import BaseModel, Field
13
+ from fastapi import APIRouter
14
+
15
+ from services.ai_client import REASONER_MODEL, CHAT_MODEL
16
+ from services.deepseek_client import is_enabled, rag_grounded_completion, parse_json_response
17
+ from rag.curriculum_rag import (
18
+ retrieve_curriculum_context,
19
+ build_analysis_curriculum_context,
20
+ retrieve_lesson_pdf_context,
21
+ format_retrieved_chunks,
22
+ summarize_retrieval_confidence,
23
+ build_exact_lesson_query,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+ router = APIRouter(prefix="/api/deepseek", tags=["deepseek-rag"])
28
+
29
+ WEAK_TOPIC_THRESHOLD = 0.60
30
+
31
+
32
+ # ═══════════════════════════════════════════════════════════════
33
+ # Feature 1 — RAG-grounded topic-level weakness detection
34
+ # ═══════════════════════════════════════════════════════════════
35
+
36
+ class QuestionResult(BaseModel):
37
+ question_id: str
38
+ topic_id: str
39
+ quarter: int
40
+ competency_code: str = ""
41
+ is_correct: bool
42
+
43
+
44
+ class WeaknessDetectionRequest(BaseModel):
45
+ student_id: str
46
+ subject: str = "General Mathematics"
47
+ questions: list[QuestionResult]
48
+
49
+
50
+ class WeaknessDetectionResponse(BaseModel):
51
+ flagged_topics: list[str]
52
+ confidence: dict[str, float] = Field(default_factory=dict)
53
+ reasoning_summary: str = ""
54
+ source: str = "rule_based" # "deepseek" or "rule_based"
55
+
56
+
57
+ @router.post("/weakness-detection", response_model=WeaknessDetectionResponse)
58
+ async def detect_weaknesses(req: WeaknessDetectionRequest):
59
+ """Detect topic-level weaknesses using RAG + DeepSeek, with rule-based fallback."""
60
+
61
+ # Rule-based fallback: compute per-topic accuracy
62
+ topic_stats: dict[str, dict] = {}
63
+ for q in req.questions:
64
+ if q.topic_id not in topic_stats:
65
+ topic_stats[q.topic_id] = {"correct": 0, "total": 0}
66
+ topic_stats[q.topic_id]["total"] += 1
67
+ if q.is_correct:
68
+ topic_stats[q.topic_id]["correct"] += 1
69
+
70
+ rule_flagged = []
71
+ rule_confidence = {}
72
+ for topic_id, stats in topic_stats.items():
73
+ accuracy = stats["correct"] / stats["total"] if stats["total"] > 0 else 0
74
+ if accuracy < WEAK_TOPIC_THRESHOLD:
75
+ rule_flagged.append(topic_id)
76
+ rule_confidence[topic_id] = round(1.0 - accuracy, 2)
77
+
78
+ if not is_enabled() or not rule_flagged:
79
+ return WeaknessDetectionResponse(
80
+ flagged_topics=rule_flagged,
81
+ confidence=rule_confidence,
82
+ reasoning_summary="Rule-based detection: topics below 60% accuracy threshold.",
83
+ source="rule_based",
84
+ )
85
+
86
+ # RAG retrieval
87
+ topic_names = list({q.topic_id for q in req.questions if q.topic_id in rule_flagged})
88
+ rag_chunks = build_analysis_curriculum_context(weak_topics=topic_names, subject=req.subject)
89
+
90
+ for topic_name in topic_names:
91
+ chunks = retrieve_curriculum_context(
92
+ query=f"DepEd learning competency for {topic_name}",
93
+ subject=req.subject,
94
+ chunk_type="learning_competency",
95
+ top_k=3,
96
+ )
97
+ rag_chunks.extend(chunks)
98
+
99
+ rag_context = format_retrieved_chunks(rag_chunks)
100
+
101
+ # DeepSeek call
102
+ system_prompt = (
103
+ "You are a DepEd SHS math assessment expert. Analyze student quiz results and identify "
104
+ "specific topic weaknesses at the competency level. Base your analysis ONLY on the "
105
+ "DepEd curriculum evidence provided in [CURRICULUM CONTEXT]. Do not invent competencies "
106
+ "or topics not present in the retrieved context."
107
+ )
108
+ questions_json = json.dumps([q.model_dump() for q in req.questions], default=str)
109
+ user_prompt = (
110
+ f"[CURRICULUM CONTEXT]\n{rag_context}\n\n"
111
+ f"[STUDENT QUIZ RESULTS]\n{questions_json}\n\n"
112
+ "Identify flagged topics and return JSON:\n"
113
+ '{"flagged_topics": ["topic_id", ...], '
114
+ '"confidence": {"topic_id": 0.85}, '
115
+ '"reasoning_summary": "plain text for teacher dashboard, grounded in DepEd competencies"}'
116
+ )
117
+
118
+ raw = rag_grounded_completion(REASONER_MODEL, system_prompt, user_prompt, temperature=0.1)
119
+ parsed = parse_json_response(raw)
120
+
121
+ if parsed and "flagged_topics" in parsed:
122
+ return WeaknessDetectionResponse(
123
+ flagged_topics=parsed["flagged_topics"],
124
+ confidence=parsed.get("confidence", rule_confidence),
125
+ reasoning_summary=parsed.get("reasoning_summary", ""),
126
+ source="deepseek",
127
+ )
128
+
129
+ # Fallback
130
+ return WeaknessDetectionResponse(
131
+ flagged_topics=rule_flagged,
132
+ confidence=rule_confidence,
133
+ reasoning_summary="Rule-based detection: topics below 60% accuracy threshold.",
134
+ source="rule_based",
135
+ )
136
+
137
+
138
+ # ═══════════════════════════════════════════════════════════════
139
+ # Feature 2 — RAG-grounded AI preview for coming_soon modules
140
+ # ═══════════════════════════════════════════════════════════════
141
+
142
+ class ModulePreviewRequest(BaseModel):
143
+ module_id: str
144
+ module_title: str
145
+ subject: str = "General Mathematics"
146
+ quarter: int = 1
147
+
148
+
149
+ class ModulePreviewResponse(BaseModel):
150
+ ai_overview: str
151
+ rag_confidence: str = "low" # "high" | "medium" | "low"
152
+ generated: bool = False
153
+
154
+
155
+ @router.post("/module-preview", response_model=ModulePreviewResponse)
156
+ async def generate_module_preview(req: ModulePreviewRequest):
157
+ """Generate a RAG-grounded AI preview for a coming_soon module."""
158
+
159
+ if not is_enabled():
160
+ return ModulePreviewResponse(ai_overview="", generated=False)
161
+
162
+ # RAG retrieval using existing 4-tier fallback
163
+ query = build_exact_lesson_query(
164
+ topic=req.module_title,
165
+ subject=req.subject,
166
+ quarter=req.quarter,
167
+ )
168
+ chunks, _ = retrieve_lesson_pdf_context(
169
+ topic=req.module_title,
170
+ subject=req.subject,
171
+ quarter=req.quarter,
172
+ top_k=6,
173
+ )
174
+
175
+ rag_context = format_retrieved_chunks(chunks)
176
+ confidence_info = summarize_retrieval_confidence(chunks)
177
+ band = confidence_info.get("band", "low")
178
+
179
+ system_prompt = (
180
+ "You are a DepEd K-12 SHS math educator writing for Grade 11-12 Filipino students. "
181
+ "Generate content ONLY from the retrieved DepEd curriculum excerpts provided. "
182
+ "Do NOT add generic filler. Do NOT invent examples or definitions not present "
183
+ "in the retrieved context."
184
+ )
185
+ user_prompt = (
186
+ f"[CURRICULUM CONTEXT]\n{rag_context}\n\n"
187
+ f"Write a 3-5 sentence student-friendly overview of the topic '{req.module_title}' "
188
+ f"under '{req.subject}', Quarter {req.quarter}, strictly based on the "
189
+ "curriculum evidence above."
190
+ )
191
+
192
+ raw = rag_grounded_completion(CHAT_MODEL, system_prompt, user_prompt, temperature=0.3)
193
+
194
+ if not raw:
195
+ return ModulePreviewResponse(ai_overview="", rag_confidence=band, generated=False)
196
+
197
+ overview = raw.strip()
198
+ if band == "low":
199
+ overview += "\n\n⚠ Limited curriculum data available for this topic."
200
+
201
+ return ModulePreviewResponse(ai_overview=overview, rag_confidence=band, generated=True)
202
+
203
+
204
+ # ═══════════════════════════════════════════════════════════════
205
+ # Feature 3 — RAG-grounded personalized study tips
206
+ # ═══════════════════════════════════════════════════════════════
207
+
208
+ class StudyTipsRequest(BaseModel):
209
+ student_id: str
210
+ topic_id: str
211
+ topic_name: str
212
+ subject: str = "General Mathematics"
213
+ confidence_score: float = 0.0
214
+
215
+
216
+ class StudyTipsResponse(BaseModel):
217
+ tips: str
218
+ generated: bool = False
219
+ confidence_score: float = 0.0
220
+
221
+
222
+ @router.post("/study-tips", response_model=StudyTipsResponse)
223
+ async def generate_study_tips(req: StudyTipsRequest):
224
+ """Generate RAG-grounded personalized study tips for a flagged topic."""
225
+
226
+ if not is_enabled():
227
+ return StudyTipsResponse(tips="", generated=False, confidence_score=req.confidence_score)
228
+
229
+ # RAG retrieval: practice chunks
230
+ practice_chunks = retrieve_curriculum_context(
231
+ query=f"study tips practice exercises for {req.topic_name}",
232
+ subject=req.subject,
233
+ chunk_type="practice",
234
+ top_k=4,
235
+ )
236
+ # Fallback if no practice chunks found
237
+ if not practice_chunks:
238
+ practice_chunks = retrieve_curriculum_context(
239
+ query=f"study tips practice exercises for {req.topic_name}",
240
+ subject=req.subject,
241
+ top_k=4,
242
+ )
243
+
244
+ # Worked examples
245
+ example_chunks = retrieve_curriculum_context(
246
+ query=f"worked examples for {req.topic_name}",
247
+ subject=req.subject,
248
+ chunk_type="worked_examples",
249
+ top_k=2,
250
+ )
251
+
252
+ # Merge and deduplicate
253
+ seen_keys: set[str] = set()
254
+ merged: list[dict] = []
255
+ for chunk in practice_chunks + example_chunks:
256
+ key = f"{chunk.get('source_file')}::{chunk.get('page')}::{chunk.get('content', '')[:60]}"
257
+ if key not in seen_keys:
258
+ seen_keys.add(key)
259
+ merged.append(chunk)
260
+
261
+ rag_context = format_retrieved_chunks(merged)
262
+
263
+ system_prompt = (
264
+ "You are a math tutor helping a Filipino SHS student improve weak areas. "
265
+ "Base ALL study tips strictly on the retrieved DepEd curriculum content below. "
266
+ "Do not invent practice problems or examples not found in the curriculum context."
267
+ )
268
+ user_prompt = (
269
+ f"[CURRICULUM CONTEXT]\n{rag_context}\n\n"
270
+ f"Give 2-3 concise, practical study tips for a student weak in '{req.topic_name}' "
271
+ "under DepEd SHS curriculum. Reference specific concepts from the curriculum "
272
+ "context above. Be direct and student-friendly."
273
+ )
274
+
275
+ raw = rag_grounded_completion(CHAT_MODEL, system_prompt, user_prompt, temperature=0.4)
276
+
277
+ if not raw:
278
+ return StudyTipsResponse(tips="", generated=False, confidence_score=req.confidence_score)
279
+
280
+ return StudyTipsResponse(
281
+ tips=raw.strip(),
282
+ generated=True,
283
+ confidence_score=req.confidence_score,
284
+ )
routes/diagnostic.py CHANGED
@@ -826,6 +826,12 @@ async def analyze_diagnostic(request: DiagnosticAnalysisRequest, req: Request):
826
  if not results_doc.exists:
827
  raise HTTPException(status_code=404, detail="No diagnostic results found")
828
 
 
 
 
 
 
 
829
  results_data = results_doc.to_dict() or {}
830
  responses = results_data.get("responses", [])
831
  domain_scores = results_data.get("domainScores", {})
@@ -938,6 +944,12 @@ Return ONLY valid JSON, no markdown fences."""
938
  logger.warning(f"[diagnostic/analyze] AI call failed: {type(e).__name__}: {e}, using fallback")
939
  analysis = _build_fallback_analysis(responses, domain_scores, risk_profile)
940
 
 
 
 
 
 
 
941
  return DiagnosticAnalysisResponse(success=True, analysis=analysis)
942
 
943
 
 
826
  if not results_doc.exists:
827
  raise HTTPException(status_code=404, detail="No diagnostic results found")
828
 
829
+ # Check server-side cache first
830
+ cache_ref = firestore_client.collection("diagnosticResults").document(request.user_id).collection("cache").document("analysis")
831
+ cache_doc = cache_ref.get()
832
+ if cache_doc.exists:
833
+ return DiagnosticAnalysisResponse(success=True, analysis=cache_doc.to_dict())
834
+
835
  results_data = results_doc.to_dict() or {}
836
  responses = results_data.get("responses", [])
837
  domain_scores = results_data.get("domainScores", {})
 
944
  logger.warning(f"[diagnostic/analyze] AI call failed: {type(e).__name__}: {e}, using fallback")
945
  analysis = _build_fallback_analysis(responses, domain_scores, risk_profile)
946
 
947
+ # Cache the analysis in Firestore for future requests
948
+ try:
949
+ cache_ref.set(analysis)
950
+ except Exception as e:
951
+ logger.warning(f"[diagnostic/analyze] Failed to cache analysis: {e}")
952
+
953
  return DiagnosticAnalysisResponse(success=True, analysis=analysis)
954
 
955
 
services/deepseek_client.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG-grounded DeepSeek client wrapper.
3
+
4
+ All calls go through `rag_grounded_completion()` which enforces:
5
+ - DEEPSEEK_ENABLED feature flag check
6
+ - Retry with exponential backoff on 429
7
+ - Token usage logging
8
+ """
9
+
10
+ import os
11
+ import time
12
+ import json
13
+ import logging
14
+ from typing import Optional
15
+
16
+ from services.ai_client import get_deepseek_client, CHAT_MODEL, REASONER_MODEL, RateLimitError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ DEEPSEEK_ENABLED = os.getenv("DEEPSEEK_ENABLED", "true").lower() in ("true", "1", "yes")
21
+ MAX_RETRIES = 3
22
+ BACKOFF_DELAYS = [2, 4, 8]
23
+
24
+
25
+ def is_enabled() -> bool:
26
+ return DEEPSEEK_ENABLED
27
+
28
+
29
+ def rag_grounded_completion(
30
+ model: str,
31
+ system_prompt: str,
32
+ user_prompt: str,
33
+ temperature: float = 0.2,
34
+ ) -> Optional[str]:
35
+ """
36
+ Call DeepSeek with retry on 429. Returns response text or None if disabled/failed.
37
+ Logs token usage per call.
38
+ """
39
+ if not DEEPSEEK_ENABLED:
40
+ logger.info("[DEEPSEEK] Disabled via DEEPSEEK_ENABLED flag, skipping.")
41
+ return None
42
+
43
+ client = get_deepseek_client()
44
+
45
+ for attempt in range(MAX_RETRIES):
46
+ try:
47
+ response = client.chat.completions.create(
48
+ model=model,
49
+ messages=[
50
+ {"role": "system", "content": system_prompt},
51
+ {"role": "user", "content": user_prompt},
52
+ ],
53
+ temperature=temperature,
54
+ )
55
+ usage = response.usage
56
+ if usage:
57
+ logger.info(
58
+ "[DEEPSEEK] model=%s prompt_tokens=%d completion_tokens=%d total=%d",
59
+ model, usage.prompt_tokens, usage.completion_tokens, usage.total_tokens,
60
+ )
61
+ return response.choices[0].message.content or ""
62
+ except RateLimitError:
63
+ delay = BACKOFF_DELAYS[attempt] if attempt < len(BACKOFF_DELAYS) else 8
64
+ logger.warning("[DEEPSEEK] 429 rate limited, retry %d/%d in %ds", attempt + 1, MAX_RETRIES, delay)
65
+ time.sleep(delay)
66
+ except Exception as e:
67
+ logger.error("[DEEPSEEK] Call failed: %s", e)
68
+ return None
69
+
70
+ logger.error("[DEEPSEEK] All %d retries exhausted.", MAX_RETRIES)
71
+ return None
72
+
73
+
74
+ def parse_json_response(text: Optional[str]) -> Optional[dict]:
75
+ """Attempt to parse JSON from DeepSeek response, handling markdown fences."""
76
+ if not text:
77
+ return None
78
+ cleaned = text.strip()
79
+ if cleaned.startswith("```"):
80
+ lines = cleaned.split("\n")
81
+ lines = [l for l in lines if not l.strip().startswith("```")]
82
+ cleaned = "\n".join(lines)
83
+ try:
84
+ return json.loads(cleaned)
85
+ except json.JSONDecodeError:
86
+ logger.warning("[DEEPSEEK] Failed to parse JSON response")
87
+ return None