github-actions[bot] commited on
Commit
8b6568e
ยท
1 Parent(s): 3fa58ae

๐Ÿš€ Auto-deploy backend from GitHub (3efade4)

Browse files
main.py CHANGED
@@ -108,6 +108,7 @@ 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:
@@ -1171,6 +1172,7 @@ 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -1894,6 +1896,74 @@ def _is_continuation_followup_token(message: str) -> bool:
1894
  return followup_token in _CONTINUATION_FOLLOWUP_TOKENS
1895
 
1896
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1897
  def _extract_latest_assistant_message(history: Optional[Sequence[Any]]) -> Optional[str]:
1898
  if not history:
1899
  return None
@@ -2316,7 +2386,40 @@ MEMORY AWARENESS:
2316
  - Build on what was previously taught โ€” don't repeat from scratch unless the student asks.
2317
  - If the student refers to "that problem" or "last time", use memory context to infer what they mean.
2318
  - If memory is not available, continue tutoring normally without it.
2319
- - Never mention the memory system to the student โ€” just use it naturally."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2320
 
2321
 
2322
  _STREAM_COMPLETION_MODES: Set[str] = {"auto", "marker", "none"}
@@ -2528,9 +2631,24 @@ async def chat_tutor(request: ChatRequest):
2528
  """AI Math Tutor powered by Hugging Face Inference routing."""
2529
  _start_ms = int(time.monotonic() * 1000)
2530
  try:
2531
- boundary_response = get_scope_boundary_response(request.message, request.history)
2532
- if boundary_response is not None:
2533
- return ChatResponse(response=boundary_response)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2534
 
2535
  system_prompt = MATH_TUTOR_SYSTEM_PROMPT
2536
 
@@ -2678,7 +2796,19 @@ async def _update_memory_after_response(
2678
  async def chat_tutor_stream(request: ChatRequest):
2679
  """SSE stream endpoint for AI Math Tutor chat responses."""
2680
  try:
2681
- boundary_response = get_scope_boundary_response(request.message, request.history)
 
 
 
 
 
 
 
 
 
 
 
 
2682
 
2683
  # โ”€โ”€โ”€ Memory Context Injection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2684
  memory_context = ""
 
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
+ from routes.at_risk_resolution import router as at_risk_resolution_router
112
 
113
  # Rate limiting (slowapi)
114
  try:
 
1172
  app.include_router(intervention_router)
1173
  app.include_router(pipeline_router)
1174
  app.include_router(deepseek_rag_router)
1175
+ app.include_router(at_risk_resolution_router)
1176
 
1177
 
1178
  # โ”€โ”€โ”€ Global Exception Handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
1896
  return followup_token in _CONTINUATION_FOLLOWUP_TOKENS
1897
 
1898
 
1899
+ # โ”€โ”€โ”€ Context-Aware Intent Gate (Fix: out-of-scope false positives) โ”€โ”€โ”€โ”€
1900
+ # This function prevents short/vague student replies from being rejected
1901
+ # as off-topic when an active tutoring session is in progress.
1902
+
1903
+ _FILIPINO_CONTINUATION_PHRASES: Set[str] = {
1904
+ "di ko alam", "hindi ko alam", "di ko gets", "hindi ko gets",
1905
+ "di ko maintindihan", "hindi ko maintindihan", "ano ulit", "ano yun",
1906
+ "bakit", "paano", "paano po", "bakit po", "sige", "sige po",
1907
+ "oo", "oo nga", "okay", "ok", "gets ko na", "gets", "ayaw ko na",
1908
+ "hindi", "hindi po", "wala akong idea", "di ko alam yan",
1909
+ "ano", "ano po", "huh", "what", "wait", "idk", "i don't know",
1910
+ "i dont know", "not sure", "unsure", "help", "help me",
1911
+ "di ko pa gets", "di ko pa alam", "try ko", "ano nga ulit",
1912
+ "thanks", "thank you", "salamat", "nice", "wow", "oh",
1913
+ }
1914
+
1915
+
1916
+ def is_continuation_reply(user_message: str, active_state: Any, recent_turns: Optional[list]) -> bool:
1917
+ """
1918
+ Returns True if the message should be treated as a continuation of
1919
+ the current tutoring session rather than a new or off-topic request.
1920
+
1921
+ A message is a continuation if ANY of the following are true:
1922
+ - It matches known Filipino/Taglish continuation patterns
1923
+ - It is short (under 8 words) AND active_state has active_topic or current_problem
1924
+ - The last assistant message in recent_turns was a question or problem prompt
1925
+ """
1926
+ msg = user_message.strip().lower()
1927
+ if not msg:
1928
+ return False
1929
+
1930
+ # Check Filipino/Taglish continuation signals
1931
+ words = msg.split()
1932
+ word_count = len(words)
1933
+ word_set = set(words)
1934
+ for phrase in _FILIPINO_CONTINUATION_PHRASES:
1935
+ if ' ' in phrase:
1936
+ # Multi-word: substring match
1937
+ if phrase in msg:
1938
+ return True
1939
+ else:
1940
+ # Single-word: only match if message is short (โ‰ค3 words)
1941
+ if word_count <= 3 and phrase in word_set:
1942
+ return True
1943
+
1944
+ # Short messages (under 8 words) with active math context are continuations
1945
+ has_active_context = active_state and (
1946
+ getattr(active_state, "active_topic", "") or
1947
+ getattr(active_state, "current_problem", "")
1948
+ )
1949
+
1950
+ if word_count <= 7 and has_active_context:
1951
+ return True
1952
+
1953
+ # Last assistant turn was a question or problem prompt
1954
+ if recent_turns:
1955
+ for turn in reversed(recent_turns):
1956
+ role = turn.get("role") if isinstance(turn, dict) else getattr(turn, "role", "")
1957
+ content = (turn.get("content") if isinstance(turn, dict) else getattr(turn, "content", "")) or ""
1958
+ if str(role).lower() in ("assistant", "ai"):
1959
+ content_stripped = content.strip()
1960
+ if content_stripped.endswith("?") or "solve" in content_stripped.lower() or "try" in content_stripped.lower():
1961
+ return True
1962
+ break
1963
+
1964
+ return False
1965
+
1966
+
1967
  def _extract_latest_assistant_message(history: Optional[Sequence[Any]]) -> Optional[str]:
1968
  if not history:
1969
  return None
 
2386
  - Build on what was previously taught โ€” don't repeat from scratch unless the student asks.
2387
  - If the student refers to "that problem" or "last time", use memory context to infer what they mean.
2388
  - If memory is not available, continue tutoring normally without it.
2389
+ - Never mention the memory system to the student โ€” just use it naturally.
2390
+
2391
+ CONTEXT-AWARE FALLBACK RULE:
2392
+ You have access to a MEMORY CONTEXT block above that contains:
2393
+ - CURRENT SESSION STATE: active_topic and current_problem fields
2394
+ - RECENT CONVERSATION: the last 10 turns of this session
2395
+
2396
+ RULE: If the MEMORY CONTEXT shows an active_topic OR current_problem OR the previous
2397
+ assistant turn asked the student a question, you MUST treat any short, vague, or
2398
+ conversational student reply as a continuation of that tutoring thread.
2399
+
2400
+ NEVER trigger the out-of-scope response for:
2401
+ - "di ko alam" / "hindi ko alam" / "di ko gets" (Filipino: I don't know)
2402
+ - "idk", "I don't know", "not sure"
2403
+ - "huh?", "what?", "ano?", "bakit?", "paano?"
2404
+ - "okay", "ok", "sige", "oo", "gets ko na"
2405
+ - "help", "help me", "ayaw ko na"
2406
+ - any reply under 8 words when a math problem is active
2407
+
2408
+ For ALL of the above, continue the tutoring session. If the student doesn't know
2409
+ the answer, guide them with a hint or a simpler step. Do not abandon them.
2410
+
2411
+ Only use the out-of-scope response when:
2412
+ 1. The student explicitly asks about something completely unrelated to mathematics
2413
+ (e.g., "who is the president?", "write my English essay", "what is noli me tangere about")
2414
+ 2. AND there is no active_topic, current_problem, or recent math context in memory
2415
+
2416
+ When in doubt, ask a clarifying math question rather than triggering out-of-scope.
2417
+
2418
+ LANGUAGE:
2419
+ You understand and respond naturally in Filipino, Tagalog, Taglish, and English.
2420
+ Always match the student's language. Filipino and Taglish expressions are valid input.
2421
+ Treat all Filipino conversational replies as normal student communication, never as
2422
+ off-topic or unrecognized input."""
2423
 
2424
 
2425
  _STREAM_COMPLETION_MODES: Set[str] = {"auto", "marker", "none"}
 
2631
  """AI Math Tutor powered by Hugging Face Inference routing."""
2632
  _start_ms = int(time.monotonic() * 1000)
2633
  try:
2634
+ # โ”€โ”€โ”€ Context-Aware Intent Gate (before scope check) โ”€โ”€โ”€โ”€โ”€โ”€
2635
+ # Load active state to determine if student is mid-session.
2636
+ # If so, skip scope check for short/vague replies.
2637
+ _skip_scope_check = False
2638
+ if request.userId and request.sessionId and HAS_MEMORY_SERVICE and get_active_state is not None:
2639
+ try:
2640
+ _active = get_active_state(request.userId, request.sessionId)
2641
+ except Exception:
2642
+ _active = None
2643
+ _history_dicts = [{"role": m.role, "content": m.content} for m in (request.history or [])[-10:]]
2644
+ if is_continuation_reply(request.message, _active, _history_dicts):
2645
+ _skip_scope_check = True
2646
+ # โ”€โ”€โ”€ End Intent Gate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2647
+
2648
+ if not _skip_scope_check:
2649
+ boundary_response = get_scope_boundary_response(request.message, request.history)
2650
+ if boundary_response is not None:
2651
+ return ChatResponse(response=boundary_response)
2652
 
2653
  system_prompt = MATH_TUTOR_SYSTEM_PROMPT
2654
 
 
2796
  async def chat_tutor_stream(request: ChatRequest):
2797
  """SSE stream endpoint for AI Math Tutor chat responses."""
2798
  try:
2799
+ # โ”€โ”€โ”€ Context-Aware Intent Gate (before scope check) โ”€โ”€โ”€โ”€โ”€โ”€
2800
+ _skip_scope_check = False
2801
+ if request.userId and request.sessionId and HAS_MEMORY_SERVICE and get_active_state is not None:
2802
+ try:
2803
+ _active = get_active_state(request.userId, request.sessionId)
2804
+ except Exception:
2805
+ _active = None
2806
+ _history_dicts = [{"role": m.role, "content": m.content} for m in (request.history or [])[-10:]]
2807
+ if is_continuation_reply(request.message, _active, _history_dicts):
2808
+ _skip_scope_check = True
2809
+ # โ”€โ”€โ”€ End Intent Gate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2810
+
2811
+ boundary_response = None if _skip_scope_check else get_scope_boundary_response(request.message, request.history)
2812
 
2813
  # โ”€โ”€โ”€ Memory Context Injection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2814
  memory_context = ""
routes/at_risk_resolution.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ At-Risk + Locked Module Resolution Logic.
3
+
4
+ POST /api/at-risk/resolve โ€” Classify flagged topics into resolution states + generate fallback content
5
+ GET /api/at-risk/fallback/{uid}/{topic_id} โ€” Fetch cached fallback study brief
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Optional
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+ from pydantic import BaseModel, Field
14
+
15
+ from services.ai_client import 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
+ format_retrieved_chunks,
20
+ summarize_retrieval_confidence,
21
+ )
22
+
23
+ import firebase_admin
24
+ from firebase_admin import firestore as fs
25
+
26
+ logger = logging.getLogger("mathpulse.at_risk_resolution")
27
+ router = APIRouter(prefix="/api/at-risk", tags=["at-risk-resolution"])
28
+
29
+
30
+ # โ”€โ”€โ”€ Models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
31
+
32
+ class FlaggedTopicInput(BaseModel):
33
+ topic_id: str
34
+ topic_name: str
35
+ subject: str = "General Mathematics"
36
+ quarter: int = 1
37
+ confidence_score: float = 0.0
38
+
39
+
40
+ class ResolveRequest(BaseModel):
41
+ uid: str
42
+ flagged_topics: list[FlaggedTopicInput]
43
+
44
+
45
+ class ResolvedTopic(BaseModel):
46
+ topic_id: str
47
+ subject: str
48
+ quarter: int
49
+ confidence_score: float
50
+ resolution_state: str # accessible | coming_soon | progression_locked | no_module
51
+ module_id: Optional[str] = None
52
+
53
+
54
+ class FallbackContent(BaseModel):
55
+ summary: str = ""
56
+ key_concepts: list[str] = Field(default_factory=list)
57
+ one_worked_example: dict = Field(default_factory=dict)
58
+ what_to_focus_on: str = ""
59
+ rag_confidence: str = "low"
60
+
61
+
62
+ class ResolveResponse(BaseModel):
63
+ resolved: list[ResolvedTopic]
64
+ fallback_generated: int = 0
65
+
66
+
67
+ # โ”€โ”€โ”€ Resolution Logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
68
+
69
+ def _resolve_topic(
70
+ topic: FlaggedTopicInput,
71
+ firestore_client: Any,
72
+ uid: str,
73
+ ) -> ResolvedTopic:
74
+ """Classify a flagged topic into a resolution state by checking Firestore modules collection."""
75
+ # Query modules collection for matching topic
76
+ modules_ref = firestore_client.collection("modules")
77
+ # Try exact match on moduleId == topic_id first
78
+ doc = modules_ref.document(topic.topic_id).get()
79
+
80
+ module_id: Optional[str] = None
81
+ module_status: Optional[str] = None
82
+
83
+ if doc.exists:
84
+ data = doc.to_dict() or {}
85
+ module_id = topic.topic_id
86
+ module_status = data.get("moduleStatus") or data.get("status") or "unavailable"
87
+ else:
88
+ # Fallback: query by topicId field
89
+ query = modules_ref.where("topicId", "==", topic.topic_id).limit(1).stream()
90
+ for match in query:
91
+ data = match.to_dict() or {}
92
+ module_id = match.id
93
+ module_status = data.get("moduleStatus") or data.get("status") or "unavailable"
94
+ break
95
+
96
+ if not module_id or not module_status:
97
+ return ResolvedTopic(
98
+ topic_id=topic.topic_id,
99
+ subject=topic.subject,
100
+ quarter=topic.quarter,
101
+ confidence_score=topic.confidence_score,
102
+ resolution_state="no_module",
103
+ module_id=None,
104
+ )
105
+
106
+ # Check if module is accessible
107
+ if module_status in ("available", "teacher_uploaded"):
108
+ # Check progression unlock
109
+ progress_doc = (
110
+ firestore_client.collection("studentProgress")
111
+ .document(uid)
112
+ .collection("modules")
113
+ .document(module_id)
114
+ .get()
115
+ )
116
+ if progress_doc.exists:
117
+ prog_data = progress_doc.to_dict() or {}
118
+ if prog_data.get("unlocked") is False:
119
+ return ResolvedTopic(
120
+ topic_id=topic.topic_id,
121
+ subject=topic.subject,
122
+ quarter=topic.quarter,
123
+ confidence_score=topic.confidence_score,
124
+ resolution_state="progression_locked",
125
+ module_id=module_id,
126
+ )
127
+ return ResolvedTopic(
128
+ topic_id=topic.topic_id,
129
+ subject=topic.subject,
130
+ quarter=topic.quarter,
131
+ confidence_score=topic.confidence_score,
132
+ resolution_state="accessible",
133
+ module_id=module_id,
134
+ )
135
+
136
+ if module_status == "coming_soon":
137
+ return ResolvedTopic(
138
+ topic_id=topic.topic_id,
139
+ subject=topic.subject,
140
+ quarter=topic.quarter,
141
+ confidence_score=topic.confidence_score,
142
+ resolution_state="coming_soon",
143
+ module_id=module_id,
144
+ )
145
+
146
+ # unavailable or unknown
147
+ return ResolvedTopic(
148
+ topic_id=topic.topic_id,
149
+ subject=topic.subject,
150
+ quarter=topic.quarter,
151
+ confidence_score=topic.confidence_score,
152
+ resolution_state="no_module",
153
+ module_id=module_id,
154
+ )
155
+
156
+
157
+ # โ”€โ”€โ”€ Fallback Content Generation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
158
+
159
+ def _generate_fallback_content(
160
+ topic: FlaggedTopicInput,
161
+ resolution_state: str,
162
+ ) -> Optional[dict]:
163
+ """Generate RAG-grounded fallback study brief for a non-accessible topic."""
164
+ if not is_enabled():
165
+ return None
166
+
167
+ # STEP 1 โ€” RAG retrieval: key concepts
168
+ concept_chunks = retrieve_curriculum_context(
169
+ query=f"core concepts and learning competency for {topic.topic_name}",
170
+ subject=topic.subject,
171
+ quarter=topic.quarter,
172
+ chunk_type="key_concepts",
173
+ top_k=5,
174
+ )
175
+ if not concept_chunks:
176
+ concept_chunks = retrieve_curriculum_context(
177
+ query=f"core concepts and learning competency for {topic.topic_name}",
178
+ subject=topic.subject,
179
+ quarter=topic.quarter,
180
+ chunk_type="learning_competency",
181
+ top_k=5,
182
+ )
183
+ if not concept_chunks:
184
+ concept_chunks = retrieve_curriculum_context(
185
+ query=f"core concepts and learning competency for {topic.topic_name}",
186
+ subject=topic.subject,
187
+ quarter=topic.quarter,
188
+ top_k=5,
189
+ )
190
+
191
+ # Worked examples
192
+ example_chunks = retrieve_curriculum_context(
193
+ query=f"worked examples for {topic.topic_name}",
194
+ subject=topic.subject,
195
+ quarter=topic.quarter,
196
+ chunk_type="worked_examples",
197
+ top_k=3,
198
+ )
199
+
200
+ # Merge and deduplicate
201
+ seen: set[str] = set()
202
+ merged: list[dict] = []
203
+ for chunk in concept_chunks + example_chunks:
204
+ key = f"{chunk.get('source_file')}::{chunk.get('page')}::{chunk.get('content', '')[:60]}"
205
+ if key not in seen:
206
+ seen.add(key)
207
+ merged.append(chunk)
208
+
209
+ rag_context = format_retrieved_chunks(merged)
210
+ confidence_info = summarize_retrieval_confidence(merged)
211
+ rag_band = confidence_info.get("band", "low")
212
+
213
+ # STEP 2 โ€” DeepSeek call
214
+ system_prompt = (
215
+ "You are a DepEd SHS math tutor. A student has been flagged as at-risk on a topic "
216
+ "but the full module is not yet available. Generate a compact, self-contained "
217
+ "study brief using ONLY the retrieved DepEd curriculum content below. "
218
+ "Do NOT invent content outside the curriculum context."
219
+ )
220
+ user_prompt = (
221
+ f"[CURRICULUM CONTEXT]\n{rag_context}\n\n"
222
+ f"The student is at risk in: '{topic.topic_name}' ({topic.subject}, Q{topic.quarter}).\n"
223
+ f"The full module is currently [{resolution_state}].\n\n"
224
+ "Generate a compact study brief with this exact JSON structure:\n"
225
+ "{\n"
226
+ ' "summary": "2-3 sentence overview of the topic",\n'
227
+ ' "key_concepts": ["concept 1", "concept 2", "concept 3"],\n'
228
+ ' "one_worked_example": { "problem": "...", "solution": "..." },\n'
229
+ ' "what_to_focus_on": "1-2 sentences on what the student should prioritize",\n'
230
+ f' "rag_confidence": "{rag_band}"\n'
231
+ "}\n\n"
232
+ "Return ONLY valid JSON."
233
+ )
234
+
235
+ raw = rag_grounded_completion(CHAT_MODEL, system_prompt, user_prompt, temperature=0.2)
236
+ parsed = parse_json_response(raw)
237
+
238
+ if parsed:
239
+ parsed.setdefault("rag_confidence", rag_band)
240
+ return parsed
241
+
242
+ return None
243
+
244
+
245
+ # โ”€โ”€โ”€ Endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
246
+
247
+ @router.post("/resolve", response_model=ResolveResponse)
248
+ async def resolve_at_risk_topics(request: ResolveRequest):
249
+ """Resolve flagged topics into resolution states and generate fallback content."""
250
+ try:
251
+ firestore_client = fs.client()
252
+ except Exception:
253
+ raise HTTPException(status_code=503, detail="Database unavailable")
254
+
255
+ resolved: list[ResolvedTopic] = []
256
+ fallback_count = 0
257
+
258
+ for topic in request.flagged_topics:
259
+ result = _resolve_topic(topic, firestore_client, request.uid)
260
+ resolved.append(result)
261
+
262
+ # Write resolution state to Firestore
263
+ try:
264
+ firestore_client.collection("students").document(request.uid).collection(
265
+ "flaggedTopics"
266
+ ).document(topic.topic_id).set({
267
+ "topicId": topic.topic_id,
268
+ "subject": topic.subject,
269
+ "quarter": topic.quarter,
270
+ "confidenceScore": topic.confidence_score,
271
+ "resolutionState": result.resolution_state,
272
+ "moduleId": result.module_id,
273
+ "resolvedAt": fs.SERVER_TIMESTAMP,
274
+ })
275
+ except Exception as e:
276
+ logger.warning(f"Failed to write flaggedTopics for {topic.topic_id}: {e}")
277
+
278
+ # Generate fallback content for non-accessible topics
279
+ if result.resolution_state != "accessible":
280
+ fallback = _generate_fallback_content(topic, result.resolution_state)
281
+ if fallback:
282
+ try:
283
+ firestore_client.collection("students").document(request.uid).collection(
284
+ "atRiskFallbackContent"
285
+ ).document(topic.topic_id).set({
286
+ **fallback,
287
+ "generated_at": fs.SERVER_TIMESTAMP,
288
+ "resolutionState": result.resolution_state,
289
+ })
290
+ fallback_count += 1
291
+ except Exception as e:
292
+ logger.warning(f"Failed to cache fallback for {topic.topic_id}: {e}")
293
+
294
+ return ResolveResponse(resolved=resolved, fallback_generated=fallback_count)
295
+
296
+
297
+ @router.get("/fallback/{uid}/{topic_id}")
298
+ async def get_fallback_content(uid: str, topic_id: str):
299
+ """Fetch cached fallback study brief for a flagged topic."""
300
+ try:
301
+ firestore_client = fs.client()
302
+ except Exception:
303
+ raise HTTPException(status_code=503, detail="Database unavailable")
304
+
305
+ doc = (
306
+ firestore_client.collection("students")
307
+ .document(uid)
308
+ .collection("atRiskFallbackContent")
309
+ .document(topic_id)
310
+ .get()
311
+ )
312
+
313
+ if not doc.exists:
314
+ raise HTTPException(status_code=404, detail="No fallback content found")
315
+
316
+ return doc.to_dict()
routes/pipeline_routes.py CHANGED
@@ -48,6 +48,24 @@ async def receive_event(payload: PipelineEventPayload, background_tasks: Backgro
48
  return {"status": "accepted", "student_id": payload.student_id}
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  @router.get("/profile/{student_id}")
52
  async def get_profile(student_id: str, request: Request):
53
  """Get full student profile."""
 
48
  return {"status": "accepted", "student_id": payload.student_id}
49
 
50
 
51
+ @router.post("/nudge/{student_id}", status_code=200)
52
+ async def request_nudge(student_id: str, background_tasks: BackgroundTasks, request: Request):
53
+ """Check if a nudge should be generated for an existing at-risk student."""
54
+ user = _require_auth(request)
55
+ if user.role == "student" and student_id != user.uid:
56
+ raise HTTPException(status_code=403, detail="Students can only request nudges for themselves")
57
+
58
+ async def _generate():
59
+ try:
60
+ from services.tutor_nudge_service import check_and_generate_nudge
61
+ await check_and_generate_nudge(student_id)
62
+ except Exception as e:
63
+ logger.warning(f"Nudge check failed for {student_id}: {e}")
64
+
65
+ background_tasks.add_task(_generate)
66
+ return {"status": "accepted"}
67
+
68
+
69
  @router.get("/profile/{student_id}")
70
  async def get_profile(student_id: str, request: Request):
71
  """Get full student profile."""
services/intervention_engine.py CHANGED
@@ -48,6 +48,7 @@ class LearningStep(BaseModel):
48
  difficulty: Literal["easy", "medium", "hard"] = "easy"
49
  is_completed: bool = False
50
  completion_score: Optional[float] = None
 
51
 
52
 
53
  class LearningPath(BaseModel):
@@ -404,6 +405,7 @@ Create a 4-6 step learning path that:
404
  2. Uses varied methodology: video โ†’ practice โ†’ assessment โ†’ review cycle
405
  3. Scales difficulty: start easy, progress to grade-level
406
  4. Total estimated time: {estimated_days} days
 
407
 
408
  Return ONLY valid JSON:
409
  {{
@@ -420,7 +422,8 @@ Return ONLY valid JSON:
420
  "num_items": null,
421
  "topic": "Topic Name",
422
  "competency_tag": "M11GM-Ia-1",
423
- "difficulty": "easy"
 
424
  }}
425
  ]
426
  }}"""
@@ -453,6 +456,7 @@ Return ONLY valid JSON:
453
  topic=s.get("topic", weakest_topic),
454
  competency_tag=s.get("competency_tag", ""),
455
  difficulty=s.get("difficulty", "easy"),
 
456
  ))
457
 
458
  return LearningPath(
@@ -474,7 +478,8 @@ Return ONLY valid JSON:
474
  """Generate a basic learning path without AI."""
475
  steps = [
476
  LearningStep(step_number=1, type="video_lesson", title=f"{weakest_topic} - Fundamentals",
477
- description="Review core concepts", duration_minutes=8, topic=weakest_topic, difficulty="easy"),
 
478
  LearningStep(step_number=2, type="practice", title=f"{weakest_topic} - Guided Practice",
479
  description="Work through examples", duration_minutes=12, num_items=10, topic=weakest_topic, difficulty="easy"),
480
  LearningStep(step_number=3, type="practice", title=f"{weakest_topic} - Independent Practice",
 
48
  difficulty: Literal["easy", "medium", "hard"] = "easy"
49
  is_completed: bool = False
50
  completion_score: Optional[float] = None
51
+ youtube_query: Optional[str] = None
52
 
53
 
54
  class LearningPath(BaseModel):
 
405
  2. Uses varied methodology: video โ†’ practice โ†’ assessment โ†’ review cycle
406
  3. Scales difficulty: start easy, progress to grade-level
407
  4. Total estimated time: {estimated_days} days
408
+ 5. For video_lesson steps, include a youtube_query field with a specific YouTube search query targeting Filipino DepEd math content. Format: "{{topic}} Grade {{level}} {{subtopic}} tutorial Philippines"
409
 
410
  Return ONLY valid JSON:
411
  {{
 
422
  "num_items": null,
423
  "topic": "Topic Name",
424
  "competency_tag": "M11GM-Ia-1",
425
+ "difficulty": "easy",
426
+ "youtube_query": "Topic Name Grade Level basics tutorial Philippines DepEd"
427
  }}
428
  ]
429
  }}"""
 
456
  topic=s.get("topic", weakest_topic),
457
  competency_tag=s.get("competency_tag", ""),
458
  difficulty=s.get("difficulty", "easy"),
459
+ youtube_query=s.get("youtube_query"),
460
  ))
461
 
462
  return LearningPath(
 
478
  """Generate a basic learning path without AI."""
479
  steps = [
480
  LearningStep(step_number=1, type="video_lesson", title=f"{weakest_topic} - Fundamentals",
481
+ description="Review core concepts", duration_minutes=8, topic=weakest_topic, difficulty="easy",
482
+ youtube_query=f"{weakest_topic} Grade 11 basics tutorial Philippines DepEd"),
483
  LearningStep(step_number=2, type="practice", title=f"{weakest_topic} - Guided Practice",
484
  description="Work through examples", duration_minutes=12, num_items=10, topic=weakest_topic, difficulty="easy"),
485
  LearningStep(step_number=3, type="practice", title=f"{weakest_topic} - Independent Practice",
services/student_intelligence_pipeline.py CHANGED
@@ -155,6 +155,25 @@ class StudentIntelligencePipeline:
155
  # 6. Write to managedStudents (update P, WRI, riskStatus)
156
  self._update_managed_student(db, event.student_id, wri_result, new_p)
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  # 7. AI context generation (cost-controlled)
159
  if self._should_regenerate_ai(event, profile, result):
160
  ai_ctx = await self._generate_ai_context(profile, event)
 
155
  # 6. Write to managedStudents (update P, WRI, riskStatus)
156
  self._update_managed_student(db, event.student_id, wri_result, new_p)
157
 
158
+ # 6b. Proactive tutor nudge (fire-and-forget, non-blocking)
159
+ new_status = profile.get("risk_status", "safe")
160
+ if new_status in ("watch", "intervene", "critical", "at_risk"):
161
+ weak_topics = (
162
+ profile.get("quiz_performance", {}).get("lowest_accuracy_topics", [])
163
+ or profile.get("diagnostic", {}).get("weak_topics", [])
164
+ )
165
+ if weak_topics:
166
+ try:
167
+ from services.tutor_nudge_service import generate_tutor_nudge_for_student
168
+ await generate_tutor_nudge_for_student(
169
+ student_id=event.student_id,
170
+ weak_topics=weak_topics[:3],
171
+ grade_level=profile.get("grade_level", "Grade 11"),
172
+ recent_score=profile.get("system_performance_avg"),
173
+ )
174
+ except Exception as e:
175
+ logger.warning(f"Nudge generation failed (non-critical): {e}")
176
+
177
  # 7. AI context generation (cost-controlled)
178
  if self._should_regenerate_ai(event, profile, result):
179
  ai_ctx = await self._generate_ai_context(profile, event)
services/tutor_nudge_service.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MathPulse AI โ€” Tutor Nudge Service
3
+
4
+ Generates proactive AI tutor nudges for at-risk students using DeepSeek,
5
+ then writes them to Firestore for the floating tutor to surface.
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime, timezone
10
+
11
+ logger = logging.getLogger("mathpulse.tutor_nudge")
12
+
13
+ NUDGE_COOLDOWN_HOURS = 24
14
+
15
+ SYSTEM_PROMPT = (
16
+ "You are MathPulse's AI tutor. Write a single short, friendly message "
17
+ "to nudge the student to work on their weakest topic. "
18
+ "No long explanation, just a nudge plus a concrete action. "
19
+ "1-2 sentences max. No code, no LaTeX. Be warm and encouraging."
20
+ )
21
+
22
+
23
+ def _get_db():
24
+ try:
25
+ from firebase_admin import firestore as ff
26
+ return ff.client()
27
+ except Exception:
28
+ return None
29
+
30
+
31
+ def _has_recent_nudge(db, student_id: str, topic: str) -> bool:
32
+ """Check if an unconsumed nudge for this topic exists within cooldown."""
33
+ from datetime import timedelta
34
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=NUDGE_COOLDOWN_HOURS)
35
+ nudges_ref = db.collection("tutorNudges").document(student_id).collection("nudges")
36
+ existing = (
37
+ nudges_ref
38
+ .where("topic", "==", topic)
39
+ .where("createdAt", ">=", cutoff)
40
+ .limit(1)
41
+ .get()
42
+ )
43
+ return len(existing) > 0
44
+
45
+
46
+ async def generate_tutor_nudge_for_student(
47
+ student_id: str,
48
+ weak_topics: list[str],
49
+ grade_level: str = "Grade 11",
50
+ recent_score: float | None = None,
51
+ ) -> dict | None:
52
+ """Generate a nudge message via DeepSeek and write to Firestore."""
53
+ if not weak_topics:
54
+ return None
55
+
56
+ db = _get_db()
57
+ if not db:
58
+ logger.warning("Firestore unavailable, skipping nudge generation")
59
+ return None
60
+
61
+ # Pick the first weak topic that doesn't have a recent nudge
62
+ topic = None
63
+ for t in weak_topics[:3]:
64
+ if not _has_recent_nudge(db, student_id, t):
65
+ topic = t
66
+ break
67
+
68
+ if not topic:
69
+ return None # All topics have recent nudges
70
+
71
+ # Generate nudge via DeepSeek
72
+ try:
73
+ from services.ai_client import get_deepseek_client, CHAT_MODEL
74
+
75
+ client = get_deepseek_client()
76
+ user_content = (
77
+ f"Student grade: {grade_level}. "
78
+ f"Weak topic: {topic}. "
79
+ f"{'Recent score: ' + str(round(recent_score)) + '%.' if recent_score else ''} "
80
+ f"Write a short nudge to encourage them to practice this topic."
81
+ )
82
+
83
+ response = client.chat.completions.create(
84
+ model=CHAT_MODEL,
85
+ messages=[
86
+ {"role": "system", "content": SYSTEM_PROMPT},
87
+ {"role": "user", "content": user_content},
88
+ ],
89
+ temperature=0.7,
90
+ max_tokens=100,
91
+ )
92
+ message = (response.choices[0].message.content or "").strip()
93
+ if not message:
94
+ return None
95
+
96
+ except Exception as e:
97
+ logger.error(f"DeepSeek nudge generation failed for {student_id}: {e}")
98
+ return None
99
+
100
+ # Write to Firestore
101
+ nudge_data = {
102
+ "message": message,
103
+ "topic": topic,
104
+ "createdAt": datetime.now(timezone.utc),
105
+ "consumed": False,
106
+ }
107
+
108
+ try:
109
+ db.collection("tutorNudges").document(student_id).collection("nudges").add(nudge_data)
110
+ logger.info(f"Nudge written for {student_id}: topic={topic}")
111
+ except Exception as e:
112
+ logger.error(f"Failed to write nudge for {student_id}: {e}")
113
+ return None
114
+
115
+ return {"message": message, "topic": topic, "created_at": nudge_data["createdAt"].isoformat()}
116
+
117
+
118
+ async def check_and_generate_nudge(student_id: str) -> dict | None:
119
+ """
120
+ Check if a student already has risk data warranting a nudge,
121
+ and generate one if no recent unconsumed nudge exists.
122
+ Used for students who completed diagnostics but have no new pipeline events.
123
+ """
124
+ db = _get_db()
125
+ if not db:
126
+ return None
127
+
128
+ # Read existing risk profile from managedStudents
129
+ managed_ref = db.collection("managedStudents").document(student_id)
130
+ managed_snap = managed_ref.get()
131
+ if not managed_snap.exists:
132
+ return None
133
+
134
+ data = managed_snap.to_dict() or {}
135
+ risk_status = data.get("riskStatus")
136
+ if risk_status not in ("watch", "intervene", "critical", "at_risk"):
137
+ return None
138
+
139
+ # Get weak topics from student_profiles
140
+ profile_ref = db.collection("student_profiles").document(student_id)
141
+ profile_snap = profile_ref.get()
142
+ weak_topics: list[str] = []
143
+ if profile_snap.exists:
144
+ profile = profile_snap.to_dict() or {}
145
+ weak_topics = (
146
+ profile.get("quiz_performance", {}).get("lowest_accuracy_topics", [])
147
+ or profile.get("diagnostic", {}).get("weak_topics", [])
148
+ )
149
+
150
+ if not weak_topics:
151
+ # Fallback: use atRiskSubjects from user doc
152
+ user_ref = db.collection("users").document(student_id)
153
+ user_snap = user_ref.get()
154
+ if user_snap.exists:
155
+ weak_topics = (user_snap.to_dict() or {}).get("atRiskSubjects", [])
156
+
157
+ if not weak_topics:
158
+ return None
159
+
160
+ return await generate_tutor_nudge_for_student(
161
+ student_id=student_id,
162
+ weak_topics=weak_topics[:3],
163
+ grade_level=data.get("grade", "Grade 11"),
164
+ recent_score=data.get("systemPerformanceAvg") or data.get("diagnosticScore"),
165
+ )
tests/test_fallback_intent_gate.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the context-aware intent gate (is_continuation_reply).
3
+
4
+ Verifies that short/vague student replies during active tutoring sessions
5
+ are NOT rejected as off-topic, while genuinely off-topic messages still are.
6
+ """
7
+
8
+ import sys
9
+ import os
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ import pytest
13
+ from unittest.mock import MagicMock
14
+ from main import is_continuation_reply
15
+
16
+
17
+ def _make_active_state(active_topic: str = "", current_problem: str = ""):
18
+ """Create a mock WorkingMemoryState."""
19
+ state = MagicMock()
20
+ state.active_topic = active_topic
21
+ state.current_problem = current_problem
22
+ return state
23
+
24
+
25
+ def _make_history(messages: list[tuple[str, str]]) -> list[dict]:
26
+ """Create history list from (role, content) tuples."""
27
+ return [{"role": role, "content": content} for role, content in messages]
28
+
29
+
30
+ # โ”€โ”€โ”€ Tests: Should return True (continuation) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
31
+
32
+ class TestContinuationPositive:
33
+ def test_di_ko_alam_with_active_topic(self):
34
+ state = _make_active_state(active_topic="Functions and Their Graphs")
35
+ assert is_continuation_reply("di ko alam", state, []) is True
36
+
37
+ def test_idk_with_current_problem(self):
38
+ state = _make_active_state(current_problem="Solve x^2 + 3x - 4 = 0")
39
+ assert is_continuation_reply("idk", state, []) is True
40
+
41
+ def test_huh_after_tutor_question(self):
42
+ history = _make_history([
43
+ ("user", "what is a function?"),
44
+ ("assistant", "A function maps each input to exactly one output. Can you give me an example?"),
45
+ ])
46
+ assert is_continuation_reply("huh?", None, history) is True
47
+
48
+ def test_thanks_with_active_topic(self):
49
+ state = _make_active_state(active_topic="Quadratic Equations")
50
+ assert is_continuation_reply("thanks", state, []) is True
51
+
52
+ def test_hindi_ko_gets_no_state_but_phrase_match(self):
53
+ # Filipino phrase match should trigger even without active state
54
+ assert is_continuation_reply("hindi ko gets", None, []) is True
55
+
56
+ def test_short_reply_with_active_context(self):
57
+ state = _make_active_state(active_topic="Linear Equations")
58
+ assert is_continuation_reply("I'm confused", state, []) is True
59
+
60
+ def test_ano_with_active_problem(self):
61
+ state = _make_active_state(current_problem="Find the derivative of f(x)=3x^2")
62
+ assert is_continuation_reply("ano?", state, []) is True
63
+
64
+ def test_paano_po(self):
65
+ assert is_continuation_reply("paano po", None, []) is True
66
+
67
+ def test_sige_with_context(self):
68
+ state = _make_active_state(active_topic="Statistics")
69
+ assert is_continuation_reply("sige", state, []) is True
70
+
71
+ def test_help_me(self):
72
+ assert is_continuation_reply("help me", None, []) is True
73
+
74
+ def test_short_msg_after_solve_prompt(self):
75
+ history = _make_history([
76
+ ("assistant", "Try to solve this: What is 2x + 5 = 11?"),
77
+ ])
78
+ assert is_continuation_reply("what", None, history) is True
79
+
80
+ def test_ok_with_active_topic(self):
81
+ state = _make_active_state(active_topic="Trigonometry")
82
+ assert is_continuation_reply("ok", state, []) is True
83
+
84
+
85
+ # โ”€โ”€โ”€ Tests: Should return False (not a continuation) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
86
+
87
+ class TestContinuationNegative:
88
+ def test_explicit_offtopic_no_context(self):
89
+ assert is_continuation_reply(
90
+ "who is the president of the philippines?", None, []
91
+ ) is False
92
+
93
+ def test_write_essay_no_context(self):
94
+ assert is_continuation_reply("write my essay", None, []) is False
95
+
96
+ def test_long_offtopic_no_context(self):
97
+ assert is_continuation_reply(
98
+ "can you tell me about the history of the roman empire", None, []
99
+ ) is False
100
+
101
+ def test_empty_message(self):
102
+ assert is_continuation_reply("", None, []) is False
103
+
104
+ def test_long_non_math_no_state(self):
105
+ assert is_continuation_reply(
106
+ "what is the meaning of life and why are we here on earth",
107
+ None,
108
+ [],
109
+ ) is False
110
+
111
+ def test_explicit_offtopic_even_with_history_no_question(self):
112
+ history = _make_history([
113
+ ("assistant", "Great job solving that equation!"),
114
+ ])
115
+ # Long off-topic message, no active state, last assistant msg not a question
116
+ assert is_continuation_reply(
117
+ "tell me about basketball players in the NBA this season",
118
+ None,
119
+ history,
120
+ ) is False