github-actions[bot] commited on
Commit
468b0c0
ยท
1 Parent(s): 449921c

๐Ÿš€ Auto-deploy backend from GitHub (00d80ad)

Browse files
Files changed (3) hide show
  1. main.py +124 -2
  2. requirements.txt +2 -0
  3. services/memory_service.py +969 -0
main.py CHANGED
@@ -120,6 +120,30 @@ from rag.curriculum_rag import (
120
  summarize_retrieval_confidence,
121
  )
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  try:
124
  import firebase_admin # type: ignore[import-not-found]
125
  from firebase_admin import auth as firebase_auth # type: ignore[import-not-found]
@@ -1760,6 +1784,10 @@ class ChatRequest(BaseModel):
1760
  message: str
1761
  history: List[ChatMessage] = Field(default_factory=list)
1762
  userId: Optional[str] = None
 
 
 
 
1763
  verify: bool = Field(default=False, description="Enable self-consistency verification for math answers")
1764
  expectedEndMarker: Optional[str] = Field(
1765
  default=None,
@@ -1959,7 +1987,16 @@ RESPONSE FORMAT FOR MATH EXPLANATIONS:
1959
  AWARENESS OF FULL CURRICULUM:
1960
  You have complete knowledge of all topics in the MathPulse topic registry
1961
  (NA-*, BM-*, SP-*, FM1-*, FM2-* topic codes). When a student asks "what's next?"
1962
- refer to their suggested_learning_path from the diagnostic result."""
 
 
 
 
 
 
 
 
 
1963
 
1964
 
1965
  _STREAM_COMPLETION_MODES: Set[str] = {"auto", "marker", "none"}
@@ -2175,6 +2212,22 @@ async def chat_tutor(request: ChatRequest):
2175
  return ChatResponse(response=boundary_response)
2176
 
2177
  system_prompt = MATH_TUTOR_SYSTEM_PROMPT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2178
 
2179
  if request.userId and HAS_FIREBASE_ADMIN and firebase_firestore:
2180
  try:
@@ -2238,6 +2291,21 @@ Overall Risk Level: {risk.get('overall_risk', 'unknown')}
2238
  detail="AI model service is temporarily unavailable. Please try again.",
2239
  )
2240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2241
  # Optional self-consistency verification
2242
  if request.verify:
2243
  logger.info("Running self-consistency verification for chat response")
@@ -2258,12 +2326,51 @@ Overall Risk Level: {risk.get('overall_risk', 'unknown')}
2258
  raise HTTPException(status_code=500, detail=f"Chat service error: {str(e)}")
2259
 
2260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2261
  @app.post("/api/chat/stream")
2262
  async def chat_tutor_stream(request: ChatRequest):
2263
  """SSE stream endpoint for AI Math Tutor chat responses."""
2264
  try:
2265
  boundary_response = get_scope_boundary_response(request.message, request.history)
2266
- messages = [{"role": "system", "content": MATH_TUTOR_SYSTEM_PROMPT}]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2267
  for msg in request.history[-10:]:
2268
  messages.append({"role": msg.role, "content": msg.content})
2269
  messages.append({"role": "user", "content": request.message})
@@ -2418,6 +2525,21 @@ async def chat_tutor_stream(request: ChatRequest):
2418
  })
2419
  yield _sse("error", err_payload)
2420
  finally:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2421
  yield _sse("end", "done")
2422
 
2423
  return StreamingResponse(
 
120
  summarize_retrieval_confidence,
121
  )
122
 
123
+ # Memory-aware tutoring service โ€” optional, no-ops if unavailable
124
+ try:
125
+ from services.memory_service import (
126
+ collect_memory_context,
127
+ update_memory_after_response,
128
+ get_active_state,
129
+ set_active_state,
130
+ increment_turn_count,
131
+ update_active_topic,
132
+ load_profile,
133
+ finalize_session,
134
+ )
135
+ HAS_MEMORY_SERVICE = True
136
+ except ImportError:
137
+ HAS_MEMORY_SERVICE = False
138
+ collect_memory_context = None
139
+ update_memory_after_response = None
140
+ get_active_state = None
141
+ set_active_state = None
142
+ increment_turn_count = None
143
+ update_active_topic = None
144
+ load_profile = None
145
+ finalize_session = None
146
+
147
  try:
148
  import firebase_admin # type: ignore[import-not-found]
149
  from firebase_admin import auth as firebase_auth # type: ignore[import-not-found]
 
1784
  message: str
1785
  history: List[ChatMessage] = Field(default_factory=list)
1786
  userId: Optional[str] = None
1787
+ sessionId: Optional[str] = Field(
1788
+ default=None,
1789
+ description="Session ID for memory-aware tutoring. If absent, no memory features are used.",
1790
+ )
1791
  verify: bool = Field(default=False, description="Enable self-consistency verification for math answers")
1792
  expectedEndMarker: Optional[str] = Field(
1793
  default=None,
 
1987
  AWARENESS OF FULL CURRICULUM:
1988
  You have complete knowledge of all topics in the MathPulse topic registry
1989
  (NA-*, BM-*, SP-*, FM1-*, FM2-* topic codes). When a student asks "what's next?"
1990
+ refer to their suggested_learning_path from the diagnostic result.
1991
+
1992
+ MEMORY AWARENESS:
1993
+ - You will receive a MEMORY CONTEXT block before these instructions when memory is available.
1994
+ - Use the student's name from the profile to personalize responses.
1995
+ - Reference previously covered topics and struggles from session summaries.
1996
+ - Build on what was previously taught โ€” don't repeat from scratch unless the student asks.
1997
+ - If the student refers to "that problem" or "last time", use memory context to infer what they mean.
1998
+ - If memory is not available, continue tutoring normally without it.
1999
+ - Never mention the memory system to the student โ€” just use it naturally."""
2000
 
2001
 
2002
  _STREAM_COMPLETION_MODES: Set[str] = {"auto", "marker", "none"}
 
2212
  return ChatResponse(response=boundary_response)
2213
 
2214
  system_prompt = MATH_TUTOR_SYSTEM_PROMPT
2215
+
2216
+ # โ”€โ”€โ”€ Memory Context Injection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2217
+ memory_context = ""
2218
+ _mem = collect_memory_context # local alias for type safety
2219
+ if request.userId and request.sessionId and _mem is not None:
2220
+ try:
2221
+ memory_context = await _mem(
2222
+ uid=request.userId,
2223
+ session_id=request.sessionId,
2224
+ current_message=request.message,
2225
+ )
2226
+ except Exception as mem_err:
2227
+ logger.debug(f"Memory context injection skipped: {mem_err}")
2228
+ if memory_context:
2229
+ system_prompt = memory_context + "\n\n" + system_prompt
2230
+ # โ”€โ”€โ”€ End Memory Context โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2231
 
2232
  if request.userId and HAS_FIREBASE_ADMIN and firebase_firestore:
2233
  try:
 
2291
  detail="AI model service is temporarily unavailable. Please try again.",
2292
  )
2293
 
2294
+ # โ”€โ”€โ”€ Background Memory Update (async, non-blocking) โ”€โ”€โ”€โ”€โ”€
2295
+ if request.userId and request.sessionId and HAS_MEMORY_SERVICE:
2296
+ _gs = get_active_state
2297
+ if _gs is not None:
2298
+ active_state = _gs(request.userId, request.sessionId)
2299
+ turn_count = active_state.turn_count if active_state else 0
2300
+ asyncio.create_task(_update_memory_after_response(
2301
+ uid=request.userId,
2302
+ session_id=request.sessionId,
2303
+ user_message=request.message,
2304
+ ai_response=answer,
2305
+ turn_count=turn_count,
2306
+ ))
2307
+ # โ”€โ”€โ”€ End Background Memory Update โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2308
+
2309
  # Optional self-consistency verification
2310
  if request.verify:
2311
  logger.info("Running self-consistency verification for chat response")
 
2326
  raise HTTPException(status_code=500, detail=f"Chat service error: {str(e)}")
2327
 
2328
 
2329
+ async def _update_memory_after_response(
2330
+ uid: str, session_id: str, user_message: str,
2331
+ ai_response: str, turn_count: int,
2332
+ ) -> None:
2333
+ """Fire-and-forget memory update after chat response.
2334
+ Never blocks the response. All errors are caught and logged as debug."""
2335
+ _upd = update_memory_after_response
2336
+ if _upd is None:
2337
+ return
2338
+ try:
2339
+ await _upd(
2340
+ uid=uid,
2341
+ session_id=session_id,
2342
+ user_message=user_message,
2343
+ ai_response=ai_response,
2344
+ turn_count=turn_count,
2345
+ )
2346
+ except Exception as e:
2347
+ logger.debug(f"Background memory update failed (non-fatal): {e}")
2348
+
2349
+
2350
  @app.post("/api/chat/stream")
2351
  async def chat_tutor_stream(request: ChatRequest):
2352
  """SSE stream endpoint for AI Math Tutor chat responses."""
2353
  try:
2354
  boundary_response = get_scope_boundary_response(request.message, request.history)
2355
+
2356
+ # โ”€โ”€โ”€ Memory Context Injection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2357
+ memory_context = ""
2358
+ _mem = collect_memory_context # local alias for type safety
2359
+ if request.userId and request.sessionId and _mem is not None:
2360
+ try:
2361
+ memory_context = await _mem(
2362
+ uid=request.userId,
2363
+ session_id=request.sessionId,
2364
+ current_message=request.message,
2365
+ )
2366
+ except Exception as mem_err:
2367
+ logger.debug(f"Memory context injection skipped: {mem_err}")
2368
+ prompt_content = MATH_TUTOR_SYSTEM_PROMPT
2369
+ if memory_context:
2370
+ prompt_content = memory_context + "\n\n" + MATH_TUTOR_SYSTEM_PROMPT
2371
+ # โ”€โ”€โ”€ End Memory Context โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2372
+
2373
+ messages = [{"role": "system", "content": prompt_content}]
2374
  for msg in request.history[-10:]:
2375
  messages.append({"role": msg.role, "content": msg.content})
2376
  messages.append({"role": "user", "content": request.message})
 
2525
  })
2526
  yield _sse("error", err_payload)
2527
  finally:
2528
+ # โ”€โ”€โ”€ Background Memory Update after stream โ”€โ”€
2529
+ if (request.userId and request.sessionId and HAS_MEMORY_SERVICE
2530
+ and assembled_response and emitted_any_chunk):
2531
+ _gs = get_active_state
2532
+ if _gs is not None:
2533
+ active_state = _gs(request.userId, request.sessionId)
2534
+ turn_count = active_state.turn_count if active_state else 0
2535
+ asyncio.create_task(_update_memory_after_response(
2536
+ uid=request.userId,
2537
+ session_id=request.sessionId,
2538
+ user_message=request.message,
2539
+ ai_response=assembled_response,
2540
+ turn_count=turn_count,
2541
+ ))
2542
+ # โ”€โ”€โ”€ End Background Memory Update โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2543
  yield _sse("end", "done")
2544
 
2545
  return StreamingResponse(
requirements.txt CHANGED
@@ -9,6 +9,8 @@ pdfplumber==0.11.5
9
  chromadb>=0.5.0
10
  sentence-transformers>=3.0.0
11
  langchain-text-splitters>=0.3.0
 
 
12
  python-docx==1.1.2
13
  python-multipart>=0.0.6
14
  sympy==1.13.3
 
9
  chromadb>=0.5.0
10
  sentence-transformers>=3.0.0
11
  langchain-text-splitters>=0.3.0
12
+ langchain-google-firestore>=0.5.0
13
+ langchain-core>=0.3.0
14
  python-docx==1.1.2
15
  python-multipart>=0.0.6
16
  sympy==1.13.3
services/memory_service.py ADDED
@@ -0,0 +1,969 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Memory Service for MathPulse AI โ€” hybrid memory architecture.
3
+
4
+ Layers:
5
+ 1. Working Memory (LangChain FirestoreChatMessageHistory) โ€” session turns
6
+ 2. Persistent Profile Memory (Firestore doc) โ€” long-term student profile
7
+ 3. Episodic / Session Memory (Firestore doc) โ€” session summaries
8
+ 4. Active State (Firestore doc) โ€” current topic, problem, unresolved context
9
+ 5. Retrieval Pipeline โ€” collect_memory_context()
10
+ 6. Update Pipeline โ€” profile updates, session summaries, pruning
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import logging
16
+ from datetime import datetime, timezone
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # โ”€โ”€โ”€ Lazy Firebase Init โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
22
+ # Follows existing pattern from main.py
23
+
24
+ _firestore_client = None
25
+
26
+ def _get_firestore():
27
+ global _firestore_client
28
+ if _firestore_client is not None:
29
+ return _firestore_client
30
+ try:
31
+ import firebase_admin
32
+ from firebase_admin import firestore as _fs
33
+ if firebase_admin._apps:
34
+ _firestore_client = _fs.client()
35
+ return _firestore_client
36
+ except Exception:
37
+ logger.debug("Firestore not available for memory service")
38
+ return None
39
+
40
+ def _has_firestore():
41
+ return _get_firestore() is not None
42
+
43
+ # โ”€โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
44
+
45
+ MAX_HISTORY_TOKENS = 4000 # Max tokens for working memory turns
46
+ PROFILE_DOC_PATH = "tutorMemory/profile/current"
47
+ SESSION_COLLECTION = "tutorMemory/sessions"
48
+ WORKING_COLLECTION = "tutorMemory/working"
49
+ ACTIVE_STATE_DOC = "tutorMemory/working/active_state"
50
+ MEMORY_CONTEXT_TEMPLATE = """MEMORY CONTEXT โ€” Previous conversation, student profile, and tutoring state:
51
+
52
+ {content}
53
+
54
+ Use this context to provide personalized, continuous tutoring."""
55
+
56
+ # โ”€โ”€โ”€ Data Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
57
+
58
+ class WorkingMemoryState:
59
+ """Active tutoring session state stored in Firestore."""
60
+ def __init__(self, session_id: str = "", active_topic: str = "",
61
+ current_problem: str = "", turn_count: int = 0,
62
+ unresolved_context: Optional[List[str]] = None,
63
+ corrections: Optional[List[str]] = None):
64
+ self.session_id = session_id
65
+ self.active_topic = active_topic
66
+ self.current_problem = current_problem
67
+ self.turn_count = turn_count
68
+ self.unresolved_context = unresolved_context or []
69
+ self.corrections = corrections or []
70
+
71
+ def to_dict(self) -> dict:
72
+ return {
73
+ "session_id": self.session_id,
74
+ "active_topic": self.active_topic,
75
+ "current_problem": self.current_problem,
76
+ "turn_count": self.turn_count,
77
+ "unresolved_context": self.unresolved_context,
78
+ "corrections": self.corrections,
79
+ "updated_at": datetime.now(timezone.utc).isoformat(),
80
+ }
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: dict) -> "WorkingMemoryState":
84
+ return cls(
85
+ session_id=data.get("session_id", ""),
86
+ active_topic=data.get("active_topic", ""),
87
+ current_problem=data.get("current_problem", ""),
88
+ turn_count=data.get("turn_count", 0),
89
+ unresolved_context=data.get("unresolved_context", []),
90
+ corrections=data.get("corrections", []),
91
+ )
92
+
93
+
94
+ class ProfileMemory:
95
+ """Long-term student profile stored in Firestore."""
96
+ def __init__(self, preferred_name: str = "", grade_level: str = "",
97
+ strand: str = "", weak_topics: Optional[List[str]] = None,
98
+ learning_style: str = "", explanation_depth: str = "auto",
99
+ language_tone: str = "english", prior_goals: Optional[List[str]] = None,
100
+ stable_tutoring_facts: Optional[List[str]] = None,
101
+ recurring_mistakes: Optional[List[str]] = None):
102
+ self.preferred_name = preferred_name
103
+ self.grade_level = grade_level
104
+ self.strand = strand
105
+ self.weak_topics = weak_topics or []
106
+ self.learning_style = learning_style
107
+ self.explanation_depth = explanation_depth # "auto", "basic", "detailed", "advanced"
108
+ self.language_tone = language_tone # "english", "filipino-friendly"
109
+ self.prior_goals = prior_goals or []
110
+ self.stable_tutoring_facts = stable_tutoring_facts or []
111
+ self.recurring_mistakes = recurring_mistakes or []
112
+
113
+ def to_dict(self) -> dict:
114
+ return {
115
+ "preferred_name": self.preferred_name,
116
+ "grade_level": self.grade_level,
117
+ "strand": self.strand,
118
+ "weak_topics": self.weak_topics,
119
+ "learning_style": self.learning_style,
120
+ "explanation_depth": self.explanation_depth,
121
+ "language_tone": self.language_tone,
122
+ "prior_goals": self.prior_goals,
123
+ "stable_tutoring_facts": self.stable_tutoring_facts,
124
+ "recurring_mistakes": self.recurring_mistakes,
125
+ "updated_at": datetime.now(timezone.utc).isoformat(),
126
+ }
127
+
128
+ @classmethod
129
+ def from_dict(cls, data: dict) -> "ProfileMemory":
130
+ return cls(
131
+ preferred_name=data.get("preferred_name", ""),
132
+ grade_level=data.get("grade_level", ""),
133
+ strand=data.get("strand", ""),
134
+ weak_topics=data.get("weak_topics", []),
135
+ learning_style=data.get("learning_style", ""),
136
+ explanation_depth=data.get("explanation_depth", "auto"),
137
+ language_tone=data.get("language_tone", "english"),
138
+ prior_goals=data.get("prior_goals", []),
139
+ stable_tutoring_facts=data.get("stable_tutoring_facts", []),
140
+ recurring_mistakes=data.get("recurring_mistakes", []),
141
+ )
142
+
143
+
144
+ class SessionSummary:
145
+ """Episodic memory โ€” summary of a completed tutoring session."""
146
+ def __init__(self, session_id: str = "", topics_covered: Optional[List[str]] = None,
147
+ what_learned: Optional[List[str]] = None,
148
+ what_struggled: Optional[List[str]] = None,
149
+ unfinished_items: Optional[List[str]] = None,
150
+ summary: str = "", session_start: str = "",
151
+ session_end: str = "", turn_count: int = 0,
152
+ competency_progress: Optional[List[str]] = None):
153
+ self.session_id = session_id
154
+ self.topics_covered = topics_covered or []
155
+ self.what_learned = what_learned or []
156
+ self.what_struggled = what_struggled or []
157
+ self.unfinished_items = unfinished_items or []
158
+ self.summary = summary
159
+ self.session_start = session_start
160
+ self.session_end = session_end
161
+ self.turn_count = turn_count
162
+ self.competency_progress = competency_progress or []
163
+
164
+ def to_dict(self) -> dict:
165
+ return {
166
+ "session_id": self.session_id,
167
+ "topics_covered": self.topics_covered,
168
+ "what_learned": self.what_learned,
169
+ "what_struggled": self.what_struggled,
170
+ "unfinished_items": self.unfinished_items,
171
+ "summary": self.summary,
172
+ "session_start": self.session_start,
173
+ "session_end": self.session_end,
174
+ "turn_count": self.turn_count,
175
+ "competency_progress": self.competency_progress,
176
+ "created_at": datetime.now(timezone.utc).isoformat(),
177
+ }
178
+
179
+ @classmethod
180
+ def from_dict(cls, data: dict) -> "SessionSummary":
181
+ return cls(
182
+ session_id=data.get("session_id", ""),
183
+ topics_covered=data.get("topics_covered", []),
184
+ what_learned=data.get("what_learned", []),
185
+ what_struggled=data.get("what_struggled", []),
186
+ unfinished_items=data.get("unfinished_items", []),
187
+ summary=data.get("summary", ""),
188
+ session_start=data.get("session_start", ""),
189
+ session_end=data.get("session_end", ""),
190
+ turn_count=data.get("turn_count", 0),
191
+ competency_progress=data.get("competency_progress", []),
192
+ )
193
+
194
+
195
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
196
+ # 1. WORKING MEMORY โ€” LangChain FirestoreChatMessageHistory
197
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
198
+
199
+ def get_working_memory(uid: str, session_id: str):
200
+ """Get LangChain FirestoreChatMessageHistory for the session.
201
+ Returns None if LangChain or Firestore unavailable (graceful degradation)."""
202
+ try:
203
+ from langchain_google_firestore import FirestoreChatMessageHistory
204
+ client = _get_firestore()
205
+ if client is None:
206
+ return None
207
+ return FirestoreChatMessageHistory(
208
+ collection="users",
209
+ doc=f"{uid}/tutorMemory/working",
210
+ session_id=session_id,
211
+ firestore_client=client,
212
+ )
213
+ except ImportError:
214
+ logger.debug("langchain-google-firestore not installed โ€” working memory disabled")
215
+ return None
216
+ except Exception as e:
217
+ logger.debug(f"Failed to initialize working memory: {e}")
218
+ return None
219
+
220
+
221
+ def load_recent_turns(
222
+ uid: str, session_id: str, max_tokens: int = MAX_HISTORY_TOKENS
223
+ ) -> List[Dict[str, str]]:
224
+ """Load recent conversation turns within token budget.
225
+ Returns list of dicts with 'role' and 'content' keys.
226
+ Falls back to active_state stored turns if LangChain unavailable."""
227
+ try:
228
+ wm = get_working_memory(uid, session_id)
229
+ if wm is not None:
230
+ all_messages = wm.messages
231
+ selected = _select_within_token_budget(all_messages, max_tokens)
232
+ result = []
233
+ for msg in selected:
234
+ role = "assistant" if msg.type == "ai" else msg.type
235
+ result.append({"role": role, "content": msg.content})
236
+ return result
237
+
238
+ # Fallback: load from active state stored messages
239
+ state = get_active_state(uid, session_id)
240
+ stored = _get_stored_turns(uid, session_id)
241
+ return _select_within_token_budget_dict(stored, max_tokens)
242
+
243
+ except Exception as e:
244
+ logger.debug(f"Failed to load recent turns: {e}")
245
+ return []
246
+
247
+
248
+ def persist_turns(uid: str, session_id: str, messages: List[Dict[str, str]]) -> None:
249
+ """Append messages to working memory. Fire-and-forget."""
250
+ try:
251
+ wm = get_working_memory(uid, session_id)
252
+ if wm is not None:
253
+ from langchain.schema import HumanMessage, AIMessage
254
+ for msg in messages:
255
+ role = msg.get("role", "user")
256
+ content = msg.get("content", "")
257
+ if not content:
258
+ continue
259
+ if role == "assistant":
260
+ wm.add_message(AIMessage(content=content))
261
+ elif role == "user":
262
+ wm.add_message(HumanMessage(content=content))
263
+
264
+ # Also store raw turns in Firestore for fallback
265
+ _append_stored_turns(uid, session_id, messages)
266
+
267
+ except ImportError:
268
+ _append_stored_turns(uid, session_id, messages)
269
+ except Exception as e:
270
+ logger.debug(f"Failed to persist turns: {e}")
271
+
272
+
273
+ def _stored_turns_ref(uid: str, session_id: str):
274
+ """Get Firestore doc reference for stored turns."""
275
+ try:
276
+ db = _get_firestore()
277
+ if db is None:
278
+ return None
279
+ return db.collection("users").document(uid).collection(WORKING_COLLECTION).document(session_id)
280
+ except Exception:
281
+ return None
282
+
283
+
284
+ def _get_stored_turns(uid: str, session_id: str) -> List[Dict[str, str]]:
285
+ """Get raw stored turns from Firestore fallback."""
286
+ try:
287
+ ref = _stored_turns_ref(uid, session_id)
288
+ if ref is None:
289
+ return []
290
+ doc = ref.get()
291
+ if doc.exists:
292
+ data = doc.to_dict() or {}
293
+ return data.get("turns", [])
294
+ return []
295
+ except Exception as e:
296
+ logger.debug(f"Failed to get stored turns: {e}")
297
+ return []
298
+
299
+
300
+ def _append_stored_turns(uid: str, session_id: str, new_turns: List[Dict[str, str]]) -> None:
301
+ """Append turns to Firestore fallback storage."""
302
+ try:
303
+ ref = _stored_turns_ref(uid, session_id)
304
+ if ref is None:
305
+ return
306
+ # Use arrayUnion for atomic append
307
+ from google.cloud.firestore import ArrayUnion
308
+ ref.set({
309
+ "turns": ArrayUnion(new_turns),
310
+ "updated_at": datetime.now(timezone.utc).isoformat(),
311
+ }, merge=True)
312
+ except Exception as e:
313
+ logger.debug(f"Failed to append stored turns: {e}")
314
+
315
+
316
+ def _select_within_token_budget(messages: list, max_tokens: int) -> list:
317
+ """Select messages from end to fit within token budget (rough char/4 estimate)."""
318
+ total = 0
319
+ selected = []
320
+ for msg in reversed(messages):
321
+ estimated = len(msg.content) // 4 # rough token estimate
322
+ if total + estimated > max_tokens and total > 0:
323
+ break
324
+ total += estimated
325
+ selected.insert(0, msg)
326
+ return selected
327
+
328
+
329
+ def _select_within_token_budget_dict(messages: List[Dict], max_tokens: int) -> List[Dict]:
330
+ """Same as above but for dict messages."""
331
+ total = 0
332
+ selected = []
333
+ for msg in reversed(messages):
334
+ estimated = len(msg.get("content", "")) // 4
335
+ if total + estimated > max_tokens and total > 0:
336
+ break
337
+ total += estimated
338
+ selected.insert(0, msg)
339
+ return selected
340
+
341
+
342
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
343
+ # 2. ACTIVE STATE โ€” Current topic, problem, unresolved context
344
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
345
+
346
+ def get_active_state(uid: str, session_id: str) -> Optional[WorkingMemoryState]:
347
+ """Load the active tutoring state for the session."""
348
+ try:
349
+ db = _get_firestore()
350
+ if db is None:
351
+ return None
352
+ doc = db.collection("users").document(uid).collection(WORKING_COLLECTION).document("active_state").get()
353
+ if doc.exists:
354
+ data = doc.to_dict() or {}
355
+ # Filter by session_id to handle multiple sessions
356
+ if data.get("session_id") == session_id:
357
+ return WorkingMemoryState.from_dict(data)
358
+ return None
359
+ except Exception as e:
360
+ logger.debug(f"Failed to load active state: {e}")
361
+ return None
362
+
363
+
364
+ def set_active_state(uid: str, session_id: str, state: WorkingMemoryState) -> None:
365
+ """Save the active tutoring state for the session."""
366
+ try:
367
+ db = _get_firestore()
368
+ if db is None:
369
+ return
370
+ state.session_id = session_id
371
+ db.collection("users").document(uid).collection(WORKING_COLLECTION).document("active_state").set(
372
+ state.to_dict(), merge=True
373
+ )
374
+ except Exception as e:
375
+ logger.debug(f"Failed to save active state: {e}")
376
+
377
+
378
+ def increment_turn_count(uid: str, session_id: str) -> int:
379
+ """Atomically increment turn count and return new value."""
380
+ try:
381
+ db = _get_firestore()
382
+ if db is None:
383
+ return 0
384
+ doc_ref = db.collection("users").document(uid).collection(WORKING_COLLECTION).document("active_state")
385
+ doc = doc_ref.get()
386
+ current = doc.to_dict().get("turn_count", 0) if doc.exists else 0
387
+ new_count = current + 1
388
+ doc_ref.set({
389
+ "session_id": session_id,
390
+ "turn_count": new_count,
391
+ "updated_at": datetime.now(timezone.utc).isoformat(),
392
+ }, merge=True)
393
+ return new_count
394
+ except Exception as e:
395
+ logger.debug(f"Failed to increment turn count: {e}")
396
+ return 0
397
+
398
+
399
+ def update_active_topic(uid: str, session_id: str, topic: str) -> None:
400
+ """Update the current tutoring topic."""
401
+ try:
402
+ db = _get_firestore()
403
+ if db is None:
404
+ return
405
+ db.collection("users").document(uid).collection(WORKING_COLLECTION).document("active_state").set({
406
+ "session_id": session_id,
407
+ "active_topic": topic,
408
+ "updated_at": datetime.now(timezone.utc).isoformat(),
409
+ }, merge=True)
410
+ except Exception as e:
411
+ logger.debug(f"Failed to update active topic: {e}")
412
+
413
+
414
+ def update_current_problem(uid: str, session_id: str, problem: str) -> None:
415
+ """Update the current problem being solved."""
416
+ try:
417
+ db = _get_firestore()
418
+ if db is None:
419
+ return
420
+ db.collection("users").document(uid).collection(WORKING_COLLECTION).document("active_state").set({
421
+ "session_id": session_id,
422
+ "current_problem": problem,
423
+ "updated_at": datetime.now(timezone.utc).isoformat(),
424
+ }, merge=True)
425
+ except Exception as e:
426
+ logger.debug(f"Failed to update current problem: {e}")
427
+
428
+
429
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
430
+ # 3. PERSISTENT PROFILE MEMORY
431
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
432
+
433
+ def load_profile(uid: str) -> Optional[ProfileMemory]:
434
+ """Load student profile memory from Firestore."""
435
+ try:
436
+ db = _get_firestore()
437
+ if db is None:
438
+ return None
439
+ doc = db.collection("users").document(uid).collection("tutorMemory").document("profile").get()
440
+ if doc.exists:
441
+ return ProfileMemory.from_dict(doc.to_dict() or {})
442
+ return None
443
+ except Exception as e:
444
+ logger.debug(f"Failed to load profile: {e}")
445
+ return None
446
+
447
+
448
+ def upsert_profile(uid: str, profile: ProfileMemory) -> None:
449
+ """Save or update profile memory. All fields merge (never delete)."""
450
+ try:
451
+ db = _get_firestore()
452
+ if db is None:
453
+ return
454
+ db.collection("users").document(uid).collection("tutorMemory").document("profile").set(
455
+ profile.to_dict(), merge=True
456
+ )
457
+ except Exception as e:
458
+ logger.debug(f"Failed to save profile: {e}")
459
+
460
+
461
+ def extract_profile_info_from_message(text: str) -> dict:
462
+ """Heuristic extraction of profile info from student messages.
463
+ Uses simple regex patterns โ€” not AI. Returns dict of detected fields."""
464
+ updates = {}
465
+
466
+ # Preferred name: "my name is X", "I'm X", "call me X"
467
+ name_patterns = [
468
+ r"(?:my\s+name\s+is|i'm|i\s+am|call\s+me)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)",
469
+ ]
470
+ for pat in name_patterns:
471
+ m = re.search(pat, text, re.IGNORECASE)
472
+ if m and not _is_negative_context(text, m.start()):
473
+ name = m.group(1).strip()
474
+ if len(name) > 1 and len(name) < 50:
475
+ updates["preferred_name"] = name
476
+ break
477
+
478
+ # Grade level: "Grade X", "I'm in Grade X", "Grade 11-STEM"
479
+ grade_patterns = [
480
+ r"(?:grade\s*)(\d{1,2})",
481
+ r"(?:i'?m?\s+(?:in\s+)?)?grade\s*[โ€“\-]?\s*(\d{1,2})\s*(?:[โ€“\-]\s*\d{1,2})?(?:\s*(STEM|ABM|HUMSS|GAS|TVL))?",
482
+ ]
483
+ for pat in grade_patterns:
484
+ m = re.search(pat, text, re.IGNORECASE)
485
+ if m:
486
+ grade_num = m.group(1)
487
+ if grade_num in {"11", "12", "9", "10", "7", "8"}:
488
+ strand = m.group(2) if m.lastindex and m.group(2) else ""
489
+ updates["grade_level"] = f"Grade {grade_num}"
490
+ if strand:
491
+ updates["strand"] = strand.upper()
492
+ break
493
+
494
+ # Weak topics: "I struggle with X", "I don't understand X", "I always get confused by X"
495
+ weak_patterns = [
496
+ r"(?:struggle\s+with|don'?t\s+(?:understand|get)\s+|confused\s+(?:by|about|with)\s+|weak\s+(?:in|at|on)\s+|hard\s+time\s+(?:with|on)\s+|bad\s+at\s+)([^.!?]{3,60})",
497
+ ]
498
+ for pat in weak_patterns:
499
+ m = re.search(pat, text, re.IGNORECASE)
500
+ if m:
501
+ topic = m.group(1).strip().lower()
502
+ if len(topic) > 2 and not _is_negative_context(text, m.start()):
503
+ if "weak_topics" not in updates:
504
+ updates["weak_topics"] = []
505
+ updates["weak_topics"].append(topic)
506
+
507
+ # Learning style / preference
508
+ style_patterns = [
509
+ (r"(?:prefer|like|want)\s+(step[-\s]by[-\s]step|detailed|simple|short|long|visual|example)", "explanation_depth"),
510
+ (r"(?:keep|make)\s+(?:it\s+)?(?:short|simple|brief|concise)", "explanation_depth"),
511
+ ]
512
+ for pat, field in style_patterns:
513
+ m = re.search(pat, text, re.IGNORECASE)
514
+ if m:
515
+ if field == "explanation_depth":
516
+ matched = m.group(1).lower() if m.lastindex else ""
517
+ if matched in ("simple", "short", "brief", "concise"):
518
+ updates[field] = "basic"
519
+ elif matched in ("detailed", "long", "step-by-step"):
520
+ updates[field] = "detailed"
521
+ else:
522
+ updates[field] = matched
523
+
524
+ # Language preference
525
+ if re.search(r"(?:tagalog|filipino|bisaya|ilocano)", text, re.IGNORECASE):
526
+ updates["language_tone"] = "filipino-friendly"
527
+
528
+ return updates
529
+
530
+
531
+ def _is_negative_context(text: str, pos: int) -> bool:
532
+ """Check if the position is in a negative context like 'I don't have a name'."""
533
+ surrounding = text[max(0, pos-30):pos+30].lower()
534
+ negative_triggers = ["don't have", "not my", "not sure", "don't know", "no name"]
535
+ for trigger in negative_triggers:
536
+ if trigger in surrounding:
537
+ return True
538
+ return False
539
+
540
+
541
+ def update_profile_from_chat(uid: str, user_message: str) -> None:
542
+ """Detect profile-relevant info in user message and update Firestore.
543
+ Called asynchronously after each chat response."""
544
+ try:
545
+ updates = extract_profile_info_from_message(user_message)
546
+ if not updates:
547
+ return
548
+
549
+ profile = load_profile(uid)
550
+ if profile is None:
551
+ profile = ProfileMemory()
552
+
553
+ if "preferred_name" in updates and not profile.preferred_name:
554
+ profile.preferred_name = updates["preferred_name"]
555
+ if "grade_level" in updates and not profile.grade_level:
556
+ profile.grade_level = updates["grade_level"]
557
+ if "strand" in updates and not profile.strand:
558
+ profile.strand = updates["strand"]
559
+ if "weak_topics" in updates:
560
+ for topic in updates["weak_topics"]:
561
+ if topic not in profile.weak_topics:
562
+ profile.weak_topics.append(topic)
563
+ if "explanation_depth" in updates:
564
+ profile.explanation_depth = updates["explanation_depth"]
565
+ if "language_tone" in updates:
566
+ profile.language_tone = updates["language_tone"]
567
+
568
+ upsert_profile(uid, profile)
569
+ # Extract stable tutoring fact if profile was updated meaningfully
570
+ if updates:
571
+ _maybe_record_stable_fact(uid, profile, user_message)
572
+
573
+ except Exception as e:
574
+ logger.debug(f"Failed to update profile from chat: {e}")
575
+
576
+
577
+ def _maybe_record_stable_fact(uid: str, profile: ProfileMemory, message: str) -> None:
578
+ """Record a stable tutoring fact when student reveals important info."""
579
+ facts_to_record = []
580
+
581
+ if profile.weak_topics:
582
+ facts_to_record.append(f"Student struggles with: {', '.join(profile.weak_topics[-3:])}")
583
+ if profile.explanation_depth and profile.explanation_depth != "auto":
584
+ facts_to_record.append(f"Student prefers {profile.explanation_depth} explanations")
585
+ if profile.language_tone == "filipino-friendly":
586
+ facts_to_record.append("Student prefers Filipino-friendly language")
587
+
588
+ if facts_to_record:
589
+ for fact in facts_to_record:
590
+ if fact not in profile.stable_tutoring_facts:
591
+ profile.stable_tutoring_facts.append(fact)
592
+
593
+
594
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
595
+ # 4. EPISODIC / SESSION MEMORY
596
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
597
+
598
+ def load_session_summary(uid: str, session_id: str) -> Optional[SessionSummary]:
599
+ """Load session summary from Firestore."""
600
+ try:
601
+ db = _get_firestore()
602
+ if db is None:
603
+ return None
604
+ doc = db.collection("users").document(uid).collection(SESSION_COLLECTION).document(session_id).get()
605
+ if doc.exists:
606
+ return SessionSummary.from_dict(doc.to_dict() or {})
607
+ return None
608
+ except Exception as e:
609
+ logger.debug(f"Failed to load session summary: {e}")
610
+ return None
611
+
612
+
613
+ def load_latest_session_summary(uid: str, exclude_session_id: str = "") -> Optional[SessionSummary]:
614
+ """Load the most recent previous session summary, excluding current session."""
615
+ try:
616
+ db = _get_firestore()
617
+ if db is None:
618
+ return None
619
+ docs = (
620
+ db.collection("users").document(uid).collection(SESSION_COLLECTION)
621
+ .order_by("created_at", direction="DESCENDING")
622
+ .limit(5)
623
+ .get()
624
+ )
625
+ for doc in docs:
626
+ data = doc.to_dict() or {}
627
+ if data.get("session_id") != exclude_session_id:
628
+ return SessionSummary.from_dict(data)
629
+ return None
630
+ except Exception as e:
631
+ logger.debug(f"Failed to load latest session summary: {e}")
632
+ return None
633
+
634
+
635
+ def generate_session_summary(uid: str, session_id: str, messages: List[Dict[str, str]]) -> SessionSummary:
636
+ """Generate a structured session summary from conversation messages.
637
+ Uses content analysis rather than AI call to keep it fast and cheap."""
638
+ try:
639
+ topics_covered = set()
640
+ struggles = []
641
+ learned = []
642
+ summary_parts = []
643
+
644
+ for msg in messages:
645
+ role = msg.get("role", "")
646
+ content = msg.get("content", "")
647
+
648
+ if role == "user":
649
+ # Detect topics from user messages (look for math keywords)
650
+ topic = _detect_topic(content)
651
+ if topic:
652
+ topics_covered.add(topic)
653
+ # Detect struggles
654
+ if re.search(r"(?:don'?t\s+understand|confused|struggl|hard|difficult)", content, re.IGNORECASE):
655
+ struggles.append(content[:100])
656
+
657
+ elif role == "assistant":
658
+ # Detect topics from assistant explanations
659
+ topic = _detect_topic(content)
660
+ if topic:
661
+ topics_covered.add(topic)
662
+ # Look for teaching signals
663
+ if "correct" in content.lower() and "good" in content.lower():
664
+ learned.append(content[:100])
665
+
666
+ # Build summary
667
+ covered = list(topics_covered)[:10]
668
+ summary_text = f"Session covered: {', '.join(covered) if covered else 'general math topics'}."
669
+ if struggles:
670
+ summary_text += f" Student struggled with {len(struggles)} concepts."
671
+ if learned:
672
+ summary_text += f" Demonstrated understanding of {len(learned)} concepts."
673
+
674
+ return SessionSummary(
675
+ session_id=session_id,
676
+ topics_covered=covered,
677
+ what_struggled=struggles[:5],
678
+ what_learned=learned[:5],
679
+ summary=summary_text,
680
+ session_start=datetime.now(timezone.utc).isoformat(),
681
+ session_end=datetime.now(timezone.utc).isoformat(),
682
+ turn_count=len(messages) // 2,
683
+ )
684
+ except Exception as e:
685
+ logger.debug(f"Failed to generate session summary: {e}")
686
+ return SessionSummary(session_id=session_id, summary="Session summary unavailable.")
687
+
688
+
689
+ _DETECTED_TOPICS = {
690
+ "algebra": r"\b(algebra|equation|inequalit|polynomial|factor|quadratic|linear)",
691
+ "arithmetic": r"\b(arithmetic|addition|subtract|multiply|division|fraction|decimal|percent)",
692
+ "geometry": r"\b(geometry|angle|triangle|circle|polygon|area|perimeter|volume)",
693
+ "trigonometry": r"\b(trig|sin|cos|tan|sine|cosine|tangent|identity)",
694
+ "statistics": r"\b(statistic|mean|median|mode|probability|distribution|variance|standard.dev)",
695
+ "calculus": r"\b(calculus|derivative|integral|limit|differentiat|optimization)",
696
+ "functions": r"\b(function|domain|range|composition|inverse|piecewise)",
697
+ "matrices": r"\b(matrix|determinant|inverse|transpose|linear.equation)",
698
+ "business_math": r"\b(interest|loan|investment|profit|loss|markup|discount|commission|tax)",
699
+ "logic": r"\b(logic|proposition|truth|table|argument|premise|conclusion)",
700
+ }
701
+
702
+
703
+ def _detect_topic(text: str) -> str:
704
+ """Detect math topic from text content."""
705
+ text_lower = text.lower()
706
+ for topic, pattern in _DETECTED_TOPICS.items():
707
+ if re.search(pattern, text_lower):
708
+ return topic
709
+ return ""
710
+
711
+
712
+ def save_session_summary(uid: str, summary: SessionSummary) -> None:
713
+ """Save session summary to Firestore."""
714
+ try:
715
+ db = _get_firestore()
716
+ if db is None:
717
+ return
718
+ db.collection("users").document(uid).collection(SESSION_COLLECTION).document(
719
+ summary.session_id
720
+ ).set(summary.to_dict(), merge=True)
721
+ except Exception as e:
722
+ logger.debug(f"Failed to save session summary: {e}")
723
+
724
+
725
+ def finalize_session(uid: str, session_id: str, messages: List[Dict[str, str]]) -> None:
726
+ """Generate and save session summary at session end.
727
+ Safe to call multiple times โ€” updates existing summary."""
728
+ try:
729
+ summary = generate_session_summary(uid, session_id, messages)
730
+ save_session_summary(uid, summary)
731
+ except Exception as e:
732
+ logger.debug(f"Failed to finalize session: {e}")
733
+
734
+
735
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
736
+ # 5. MEMORY RETRIEVAL PIPELINE
737
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
738
+
739
+ # Rate at which we auto-summarize sessions (every N turns)
740
+ AUTO_SUMMARIZE_INTERVAL = 15
741
+
742
+
743
+ async def collect_memory_context(
744
+ uid: str, session_id: str, current_message: str,
745
+ include_profile: bool = True, include_recent_turns: bool = True,
746
+ include_active_state: bool = True, include_previous_session: bool = True,
747
+ ) -> str:
748
+ """Collect all memory sources into a formatted context string.
749
+ This is injected into the system prompt before the tutor instructions.
750
+
751
+ Returns empty string if no memory available (graceful degradation)."""
752
+ try:
753
+ context_parts = []
754
+
755
+ # 1. Profile memory
756
+ if include_profile:
757
+ profile = load_profile(uid)
758
+ if profile:
759
+ context_parts.append(_format_profile_context(profile))
760
+
761
+ # 2. Active state
762
+ if include_active_state:
763
+ state = get_active_state(uid, session_id)
764
+ if state and (state.active_topic or state.current_problem):
765
+ context_parts.append(_format_active_state(state))
766
+
767
+ # 3. Previous session summary (for continuity across sessions)
768
+ if include_previous_session:
769
+ prev_summary = load_latest_session_summary(uid, exclude_session_id=session_id)
770
+ if prev_summary:
771
+ context_parts.append(_format_previous_session(prev_summary))
772
+
773
+ # 4. Recent turns
774
+ if include_recent_turns:
775
+ recent = load_recent_turns(uid, session_id, max_tokens=2000)
776
+ if recent:
777
+ context_parts.append(_format_recent_turns(recent))
778
+
779
+ if not context_parts:
780
+ return ""
781
+
782
+ combined = "\n\n".join(context_parts)
783
+ return MEMORY_CONTEXT_TEMPLATE.format(content=combined)
784
+
785
+ except Exception as e:
786
+ logger.debug(f"Failed to collect memory context: {e}")
787
+ return ""
788
+
789
+
790
+ def _format_profile_context(profile: ProfileMemory) -> str:
791
+ """Format profile memory for prompt injection."""
792
+ lines = ["=== STUDENT PROFILE ==="]
793
+ if profile.preferred_name:
794
+ lines.append(f"Name: {profile.preferred_name}")
795
+ if profile.grade_level:
796
+ lines.append(f"Grade Level: {profile.grade_level}")
797
+ if profile.strand:
798
+ lines.append(f"Strand: {profile.strand}")
799
+ if profile.weak_topics:
800
+ lines.append(f"Weak Topics: {', '.join(profile.weak_topics[:5])}")
801
+ if profile.explanation_depth and profile.explanation_depth != "auto":
802
+ lines.append(f"Explanation Preference: {profile.explanation_depth} explanations")
803
+ if profile.language_tone and profile.language_tone != "english":
804
+ lines.append(f"Language: {profile.language_tone}")
805
+ if profile.stable_tutoring_facts:
806
+ lines.append(f"Tutoring Facts: {'; '.join(profile.stable_tutoring_facts[-3:])}")
807
+ if profile.prior_goals:
808
+ lines.append(f"Prior Goals: {'; '.join(profile.prior_goals[-3:])}")
809
+ if profile.recurring_mistakes:
810
+ lines.append(f"Watch For: Student tends to {profile.recurring_mistakes[-1]}")
811
+ return "\n".join(lines)
812
+
813
+
814
+ def _format_active_state(state: WorkingMemoryState) -> str:
815
+ """Format active tutoring state for prompt injection."""
816
+ lines = ["=== CURRENT SESSION STATE ==="]
817
+ if state.active_topic:
818
+ lines.append(f"Current Topic: {state.active_topic}")
819
+ if state.current_problem:
820
+ lines.append(f"Current Problem: {state.current_problem}")
821
+ if state.turn_count:
822
+ lines.append(f"Turn Count: {state.turn_count}")
823
+ if state.unresolved_context:
824
+ lines.append(f"Unresolved: {'; '.join(state.unresolved_context[-3:])}")
825
+ if state.corrections:
826
+ lines.append(f"Recent Corrections: {'; '.join(state.corrections[-2:])}")
827
+ return "\n".join(lines)
828
+
829
+
830
+ def _format_previous_session(prev: SessionSummary) -> str:
831
+ """Format previous session summary for prompt injection."""
832
+ lines = ["=== PREVIOUS SESSION ==="]
833
+ if prev.summary:
834
+ lines.append(f"Summary: {prev.summary}")
835
+ if prev.topics_covered:
836
+ lines.append(f"Topics Covered: {', '.join(prev.topics_covered)}")
837
+ if prev.what_struggled:
838
+ lines.append(f"Previously Struggled: {len(prev.what_struggled)} concepts")
839
+ if prev.unfinished_items:
840
+ lines.append(f"Unfinished: {'; '.join(prev.unfinished_items[:3])}")
841
+ return "\n".join(lines)
842
+
843
+
844
+ def _format_recent_turns(turns: List[Dict[str, str]]) -> str:
845
+ """Format recent conversation turns for prompt injection."""
846
+ if not turns:
847
+ return ""
848
+ lines = ["=== RECENT CONVERSATION ==="]
849
+ for turn in turns[-10:]: # Last 10 turns max
850
+ role = turn.get("role", "user")
851
+ content = turn.get("content", "")
852
+ prefix = "Student" if role == "user" else "Tutor"
853
+ content_trimmed = content[:300] if content else ""
854
+ lines.append(f"{prefix}: {content_trimmed}")
855
+ return "\n".join(lines)
856
+
857
+
858
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
859
+ # 6. MEMORY UPDATE PIPELINE
860
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
861
+
862
+ async def update_memory_after_response(
863
+ uid: str, session_id: str, user_message: str,
864
+ ai_response: str, turn_count: int,
865
+ ) -> None:
866
+ """Update all memory layers after a chat response.
867
+ Called asynchronously โ€” never blocks the response.
868
+
869
+ Updates:
870
+ 1. Working memory (persist turns)
871
+ 2. Active state (increment turn count)
872
+ 3. Profile (extract info from user message)
873
+ 4. Session summary (auto-summarize every N turns)"""
874
+ try:
875
+ # 1. Persist turns to working memory
876
+ persist_turns(uid, session_id, [
877
+ {"role": "user", "content": user_message},
878
+ {"role": "assistant", "content": ai_response},
879
+ ])
880
+
881
+ # 2. Update active state
882
+ increment_turn_count(uid, session_id)
883
+
884
+ # 3. Update profile from chat (heuristic extraction)
885
+ update_profile_from_chat(uid, user_message)
886
+
887
+ # 4. Auto-summarize at interval
888
+ if turn_count > 0 and turn_count % AUTO_SUMMARIZE_INTERVAL == 0:
889
+ recent = load_recent_turns(uid, session_id, max_tokens=0) # get all
890
+ # Convert to dict format
891
+ all_turns = []
892
+ for turn in recent:
893
+ all_turns.append(turn if isinstance(turn, dict) else {"role": "assistant", "content": str(turn)})
894
+ all_turns.append({"role": "user", "content": user_message})
895
+ all_turns.append({"role": "assistant", "content": ai_response})
896
+ finalize_session(uid, session_id, all_turns)
897
+
898
+ except Exception as e:
899
+ logger.debug(f"Background memory update failed (non-fatal): {e}")
900
+
901
+
902
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
903
+ # 7. PRUNING & CLEANUP
904
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
905
+
906
+ def prune_working_memory(uid: str, session_id: str, max_turns: int = 100) -> None:
907
+ """Keep only the most recent turns in working memory to prevent bloat."""
908
+ try:
909
+ db = _get_firestore()
910
+ if db is None:
911
+ return
912
+ ref = db.collection("users").document(uid).collection(WORKING_COLLECTION).document(session_id)
913
+ doc = ref.get()
914
+ if not doc.exists:
915
+ return
916
+ data = doc.to_dict() or {}
917
+ turns = data.get("turns", [])
918
+ if len(turns) > max_turns:
919
+ pruned = turns[-max_turns:]
920
+ ref.update({"turns": pruned, "pruned_at": datetime.now(timezone.utc).isoformat()})
921
+ except Exception as e:
922
+ logger.debug(f"Failed to prune working memory: {e}")
923
+
924
+
925
+ def prune_old_sessions(uid: str, max_sessions: int = 50) -> None:
926
+ """Delete oldest session summaries beyond max_sessions threshold."""
927
+ try:
928
+ db = _get_firestore()
929
+ if db is None:
930
+ return
931
+ docs = (
932
+ db.collection("users").document(uid).collection(SESSION_COLLECTION)
933
+ .order_by("created_at", direction="DESCENDING")
934
+ .offset(max_sessions)
935
+ .get()
936
+ )
937
+ for doc in docs:
938
+ doc.reference.delete()
939
+ except Exception as e:
940
+ logger.debug(f"Failed to prune old sessions: {e}")
941
+
942
+
943
+ def clear_session(uid: str, session_id: str) -> None:
944
+ """Clear all working memory for a session."""
945
+ try:
946
+ db = _get_firestore()
947
+ if db is None:
948
+ return
949
+ batch = db.batch()
950
+ # Clear stored turns
951
+ ref1 = db.collection("users").document(uid).collection(WORKING_COLLECTION).document(session_id)
952
+ batch.delete(ref1)
953
+ # Reset active state if it matches this session
954
+ state_ref = db.collection("users").document(uid).collection(WORKING_COLLECTION).document("active_state")
955
+ state_doc = state_ref.get()
956
+ if state_doc.exists and state_doc.to_dict().get("session_id") == session_id:
957
+ batch.delete(state_ref)
958
+ batch.commit()
959
+ except Exception as e:
960
+ logger.debug(f"Failed to clear session: {e}")
961
+
962
+
963
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
964
+ # 8. MEMORY AVAILABILITY CHECK
965
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
966
+
967
+ def is_memory_available() -> bool:
968
+ """Check if memory system is operational."""
969
+ return _has_firestore()