Gustav-Proxi commited on
Commit
cffeda7
Β·
verified Β·
1 Parent(s): e5fa90a

sync: src/nodes/socratic_generator.py

Browse files
Files changed (1) hide show
  1. src/nodes/socratic_generator.py +306 -76
src/nodes/socratic_generator.py CHANGED
@@ -8,6 +8,8 @@ Only visible_response.socratic_question + encouragement reach the student.
8
  from __future__ import annotations
9
 
10
  import os
 
 
11
  from typing import Literal, Optional
12
 
13
  import yaml
@@ -16,6 +18,38 @@ from pydantic import BaseModel
16
 
17
  from src.state import TutoringState, Phase
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  with open("config.yaml") as f:
20
  _cfg = yaml.safe_load(f)
21
 
@@ -25,9 +59,9 @@ with open("config.yaml") as f:
25
  class InternalAnalysis(BaseModel):
26
  """Stripped by app layer β€” never shown to student."""
27
  correct_answer: str
28
- student_misconception: str
29
- planned_hint_sequence: list[str]
30
- relevant_textbook_section: str
31
 
32
 
33
  class VisibleResponse(BaseModel):
@@ -148,16 +182,17 @@ Do NOT add any transition like "let's get started" β€” the first question will a
148
  Do NOT ask any anatomy question. Do NOT end with "?". No more than 2 sentences."""
149
 
150
  _RAPPORT_SYSTEM = """\
151
- You are UnMask, a friendly Socratic tutor helping OT students prepare for the NBCOT exam.
152
  You are running a short diagnostic. The next diagnostic question will be shown automatically β€” \
153
  you MUST NOT ask any question yourself. Do NOT end your response with "?".
154
- React to the student's answer in 1-2 sentences ONLY: briefly acknowledge correct/incorrect \
155
- (without revealing the full answer) and offer brief encouragement. Then stop completely.
156
- Examples of good responses:
157
- - "Exactly right β€” C5 to T1 are the five roots. Nice start!"
158
- - "Close! You've got the right range in mind. Keep going."
159
- - "Not quite, but good attempt β€” we'll revisit that."
160
- Do not ask follow-up questions. Do not say 'which topic' or 'what would you like to explore'."""
 
161
 
162
  _TUTORING_SYSTEM = """\
163
  You are UnMask, a warm and encouraging Socratic tutor for OT anatomy (NBCOT prep).
@@ -181,7 +216,7 @@ TURN: {turn} | CONSECUTIVE INCORRECT: {consecutive_incorrect}
181
  STUDY FOCUS: {study_focus} β€” ALL questions MUST stay within this topic area. Do not stray to unrelated anatomy concepts. If study_focus starts with "topic:", focus exclusively on that topic's clinical syndromes, mechanisms, and signs. | LEARNING MODE: {learning_mode} β€” if "visual", use spatial anatomical descriptions and reference diagram layouts; if "text", use clear prose explanations.
182
 
183
  TONE GUIDE β€” encouragement must be exactly ONE sentence, original, not canned:
184
- - If the student's message is "idk", "don't know", "no idea", "not sure", or any short non-answer (< 10 words with no anatomy content) β†’ neutral pivot ONLY e.g. "No worries, let's build it up together." Do NOT say "you're on the right track", "great job", or any positive feedback regardless of consecutive_incorrect.
185
  - consecutive_incorrect = 0 AND student gave a substantive correct answer β†’ specific praise referencing what they got right e.g. "Exactly β€” C5 to T1 are the five roots."
186
  - consecutive_incorrect = 1 β†’ warm redirect e.g. "Not quite, but think about the movement involved."
187
  - consecutive_incorrect = 2 β†’ empathetic e.g. "This one trips a lot of people β€” let's try a fresh angle."
@@ -189,7 +224,12 @@ TONE GUIDE β€” encouragement must be exactly ONE sentence, original, not canned:
189
  CRITICAL: Write ONE sentence only for encouragement. No joining two phrases with a dash or period.
190
  NEVER say "great job" / "well done" / "you're doing great" / "you've got X right" when the student expressed uncertainty or said they don't know.
191
  Evaluate ONLY the CURRENT student message β€” ignore how well they answered in previous turns.
192
- ANTI-REPETITION: The conversation history above contains your previous responses. Your new socratic_question MUST be DIFFERENT from your last question β€” use different wording, a different angle, or a more concrete scaffold. If the student has said "idk"/"no idea" twice in a row, do NOT ask the same question again; instead give a specific leading clue or a simpler sub-question.
 
 
 
 
 
193
  {revisit_block}"""
194
 
195
  _REVEAL_SYSTEM = """\
@@ -200,8 +240,10 @@ CONTEXT (textbook source of truth):
200
  {context}
201
 
202
  WHAT TO DO (in visible_response):
203
- - encouragement: Acknowledge their effort honestly in ONE natural sentence. If they asked directly, say something like "No problem β€” let me just walk you through it." NOT fake praise.
204
- - socratic_question: Give the correct answer clearly in 1-2 sentences (you can name it β€” they need to know it now). Then briefly explain WHY (mechanism/clinical link). End with ONE simple check question like "Does that click now?" or "Want to try a quick follow-up?"
 
 
205
 
206
  In internal_analysis, still compute the correct answer and misconception as usual.
207
  STUDY FOCUS: {study_focus} | CONSECUTIVE INCORRECT: {consecutive_incorrect}"""
@@ -219,8 +261,8 @@ CONSECUTIVE INCORRECT / NO-ANSWER: {consecutive_incorrect}
219
  STUDENT'S CURRENT MESSAGE: "{student_message}"
220
 
221
  TONE RULES β€” apply to encouragement field ONLY (1 sentence max):
222
- - If the student's current message is "idk", "don't know", "no idea", "not sure", or any short non-answer:
223
- β†’ encouragement MUST be a neutral pivot e.g. "No worries β€” let me give you a hint to work from."
224
  β†’ Do NOT say "you're on the right track", "great job", "you've got it", or any positive feedback.
225
  β†’ socratic_question MUST provide a concrete leading clue or scaffold, not just repeat the question.
226
  - consecutive_incorrect = 0 AND substantive correct answer β†’ specific praise referencing what they got right.
@@ -382,8 +424,9 @@ resources: 3-4 specific study resources, mixing:
382
  - NBCOT-specific practice
383
  Example: 'NBCOT Prep: clinical scenarios β€” peripheral nerve injury splinting'
384
 
385
- youtube_resources: 2-4 curated YouTube videos, one per WEAKEST topic (mastery < 0.5 from mastery_json above).
386
- Prioritize the lowest-mastery concepts β€” only recommend videos for topics the student actually struggled with.
 
387
  Use real videos from reputable anatomy channels: Osmosis, Khan Academy, AnatomyZone, Armando Hasudungan,
388
  Netter Anatomy, Acland's Video Atlas, Ninja Nerd Science.
389
  Each resource must include:
@@ -428,67 +471,174 @@ def _generate_session_summary(state: TutoringState) -> tuple[str, SessionSummary
428
  [(k, v) for k, v in visited_mastery.items() if v < 0.5],
429
  key=lambda x: x[1]
430
  )
431
- weak_topics_str = ", ".join(f"{k} ({v:.0%})" for k, v in weak_topics) or "none"
 
 
 
 
 
 
 
 
432
 
433
  client = _get_client()
 
 
434
  prompt = _SUMMARY_PROMPT.format(
435
  mastery_json=json.dumps(visited_mastery, indent=2),
436
- mistakes_json=json.dumps(mistake_log, indent=2) if mistake_log else "[]",
437
  topics_covered=", ".join(topics_visited) or "none",
438
  weak_topics=weak_topics_str,
439
  duration_min=elapsed / 60,
440
  total_turns=turn,
441
  )
442
 
443
- resp = client.beta.chat.completions.parse(
444
- model=os.getenv("OPENAI_MODEL", _cfg["llm"]["model"]),
445
- messages=[{"role": "user", "content": prompt}],
446
- response_format=SessionSummary,
447
- temperature=0.3,
 
 
 
 
 
 
 
 
448
  )
449
- summary: SessionSummary = resp.choices[0].message.parsed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
 
451
  # ── Format as readable markdown ────────────────────────────────────────
452
  lines = ["## πŸ“‹ Session Report\n"]
453
- lines.append(f"{summary.overall_assessment}\n")
454
 
455
  # Per-topic report card
456
- lines.append("### Topic Breakdown\n")
457
- status_icon = {"mastered": "βœ…", "progressing": "🟑", "needs_review": "❌"}
458
- for tr in summary.topic_reports:
459
- icon = status_icon.get(tr.status, "⬜")
460
- concept_readable = tr.concept.replace("_", " ").replace(".", " β€Ί ")
461
- lines.append(
462
- f"{icon} **{concept_readable}** β€” mastery {tr.mastery_score:.0%}\n"
463
- f"> {tr.honest_feedback}\n"
464
- )
 
 
465
 
466
  # Mistake highlights
467
- if summary.mistake_highlights:
468
- lines.append("### ⚠️ Misconceptions to Address\n")
469
- for m in summary.mistake_highlights:
470
- lines.append(f"- {m}")
 
471
  lines.append("")
472
 
473
  # Study recommendations
474
- if summary.study_recommendations:
 
475
  lines.append("### πŸ“š Study Recommendations\n")
476
- for tip in summary.study_recommendations:
477
  lines.append(f"- {tip}")
478
  lines.append("")
479
 
480
  # Resources
481
- if summary.resources:
 
482
  lines.append("### πŸ“– Study Resources\n")
483
- for r in summary.resources:
484
  lines.append(f"- {r}")
485
  lines.append("")
486
 
487
  # YouTube recommendations
488
- if summary.youtube_resources:
 
489
  lines.append("### 🎬 Recommended Videos\n")
490
- for yt in summary.youtube_resources:
491
- search_url = "https://www.youtube.com/results?search_query=" + yt.search_query.replace(" ", "+")
492
  lines.append(
493
  f"- **{yt.title}** β€” *{yt.creator}*\n"
494
  f" {yt.description}\n"
@@ -496,18 +646,17 @@ def _generate_session_summary(state: TutoringState) -> tuple[str, SessionSummary
496
  )
497
  lines.append("")
498
 
499
- # Flashcards and diagrams are sent as separate SSE events by api.py
500
- # (_send_followup_resources) β€” omit them here to avoid duplication.
501
-
502
  # Next session questions
503
- if summary.next_session_questions:
 
504
  lines.append("### πŸ” Practice Questions for Next Session\n")
505
- for i, q in enumerate(summary.next_session_questions, 1):
506
  lines.append(f"**{i}.** {q}")
507
  lines.append("")
508
 
509
  # Closing reflection
510
- lines.append(f"---\n**Before next session:** {summary.closing_reflection}")
 
511
 
512
  return "\n".join(lines), summary
513
 
@@ -529,23 +678,30 @@ def _generate_assessment_feedback(state: TutoringState, scenario: str, student_r
529
  """Generate explicit structured feedback on a student's assessment answer."""
530
  import json
531
  chunks = state.get("retrieved_chunks", [])
532
- context_text = "\n\n".join(
533
  f"[{c.get('chunk_type','ctx').upper()}] {c['text']}" for c in chunks
534
  ) or "(no context)"
 
535
 
 
536
  client = _get_client()
537
  prompt = _ASSESSMENT_FEEDBACK_SYSTEM.format(
538
  scenario=scenario,
539
  student_response=student_response,
540
  context=context_text,
541
  )
542
- resp = client.beta.chat.completions.parse(
543
  model=os.getenv("OPENAI_MODEL", _cfg["llm"]["model"]),
544
- messages=[{"role": "user", "content": prompt}],
545
- response_format=AssessmentFeedback,
 
 
 
546
  temperature=0.3,
 
547
  )
548
- fb: AssessmentFeedback = resp.choices[0].message.parsed
 
549
 
550
  score_icon = {"excellent": "βœ…", "good": "🟒", "partial": "🟑", "needs_work": "❌"}.get(fb.score, "⬜")
551
  lines = [
@@ -572,13 +728,15 @@ def socratic_generator(state: TutoringState) -> dict:
572
  history = state.get("conversation_history", [])
573
  mastery = state.get("mastery_scores", {})
574
  topic = state.get("current_topic", "")
 
575
 
576
  _max_turns = _cfg["llm"].get("max_history_turns", 8)
577
 
578
- context_text = "\n\n".join(
579
  f"[{c.get('chunk_type','context').upper()}] {c['text']}"
580
  for c in chunks
581
  ) or "(No context retrieved)"
 
582
 
583
  recent_history = "\n".join(
584
  f"{m['role'].capitalize()}: {m['content']}" for m in history[-_max_turns:]
@@ -654,6 +812,9 @@ def socratic_generator(state: TutoringState) -> dict:
654
  "give me the answer", "i give up", "i have no idea", "no idea whatsoever",
655
  "i don't know at all", "i dont know at all", "i'm clueless", "im clueless",
656
  "i don't have time", "dont have time",
 
 
 
657
  )
658
  consecutive_incorrect = state.get("consecutive_incorrect", 0)
659
  _student_msg_lower = (state.get("student_message") or "").lower()
@@ -748,21 +909,90 @@ def socratic_generator(state: TutoringState) -> dict:
748
  internal_analysis = None
749
  response_text = ""
750
 
751
- for attempt in range(2):
752
- for api_attempt in range(3):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
753
  try:
754
- resp = client.beta.chat.completions.parse(
755
- model=os.getenv("OPENAI_MODEL", _cfg["llm"]["model"]),
756
- temperature=_cfg["llm"]["temperature"] if attempt == 0 else 0,
757
- messages=messages,
758
- response_format=SocraticOutput,
759
- )
760
- break # success
761
- except Exception as e:
762
- if api_attempt == 2:
763
- raise
764
- time.sleep(2 ** api_attempt) # exponential backoff: 1s, 2s, 4s
765
- output: SocraticOutput = resp.choices[0].message.parsed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  visible = output.visible_response
767
  candidate = visible.socratic_question
768
  if visible.encouragement:
@@ -790,9 +1020,9 @@ def socratic_generator(state: TutoringState) -> dict:
790
  cand_words = set(visible.socratic_question.lower().split())
791
  prev_words = set(last_assistant_q.lower().split())
792
  overlap = len(cand_words & prev_words) / max(len(cand_words), 1)
793
- if overlap > 0.65:
794
  messages = [
795
- {"role": "system", "content": system + "\n\nYou just asked: \"" + last_assistant_q + "\"\nThe student still doesn't know. Do NOT repeat or rephrase that question. Ask a completely different, simpler sub-question or give a concrete leading clue instead."},
796
  *history[-10:],
797
  {"role": "user", "content": user_msg},
798
  ]
 
8
  from __future__ import annotations
9
 
10
  import os
11
+ import queue
12
+ import threading
13
  from typing import Literal, Optional
14
 
15
  import yaml
 
18
 
19
  from src.state import TutoringState, Phase
20
 
21
+ # ── Per-session streaming token queues ───────────────────────────────────────
22
+ # api.py registers a queue before calling graph.invoke; socratic_generator
23
+ # puts tokens in it; api.py drains the queue and yields SSE events.
24
+ # Sentinel None marks end-of-stream.
25
+
26
+ _token_queues: dict[str, queue.Queue] = {}
27
+ _token_queues_lock = threading.Lock()
28
+
29
+
30
+ def register_token_queue(session_id: str) -> queue.Queue:
31
+ q: queue.Queue = queue.Queue()
32
+ with _token_queues_lock:
33
+ _token_queues[session_id] = q
34
+ return q
35
+
36
+
37
+ def unregister_token_queue(session_id: str) -> None:
38
+ with _token_queues_lock:
39
+ _token_queues.pop(session_id, None)
40
+
41
+
42
+ def _get_token_queue(session_id: str) -> Optional[queue.Queue]:
43
+ with _token_queues_lock:
44
+ return _token_queues.get(session_id)
45
+
46
+
47
+ def push_phase_event(session_id: str, from_phase: str, to_phase: str) -> None:
48
+ """Push a phase-change marker into the token queue before any tokens stream."""
49
+ q = _get_token_queue(session_id)
50
+ if q is not None:
51
+ q.put({"_phase_change": True, "from": from_phase, "to": to_phase})
52
+
53
  with open("config.yaml") as f:
54
  _cfg = yaml.safe_load(f)
55
 
 
59
  class InternalAnalysis(BaseModel):
60
  """Stripped by app layer β€” never shown to student."""
61
  correct_answer: str
62
+ student_misconception: Optional[str] = ""
63
+ planned_hint_sequence: list[str] = []
64
+ relevant_textbook_section: str = ""
65
 
66
 
67
  class VisibleResponse(BaseModel):
 
182
  Do NOT ask any anatomy question. Do NOT end with "?". No more than 2 sentences."""
183
 
184
  _RAPPORT_SYSTEM = """\
185
+ You are UnMask, a Socratic tutor helping OT students prepare for the NBCOT exam.
186
  You are running a short diagnostic. The next diagnostic question will be shown automatically β€” \
187
  you MUST NOT ask any question yourself. Do NOT end your response with "?".
188
+ React to the student's answer in 1 sentence ONLY. Then stop completely.
189
+
190
+ TONE RULES (apply strictly):
191
+ - If student says "idk", "don't know", "no idea", "not sure", or any phrase under 8 words with no anatomy β†’ respond ONLY with a neutral one-liner e.g. "That one's tricky β€” we'll build it up." NEVER say "no worries", "that's okay", "don't stress", or anything that sounds like consolation.
192
+ - If correct β†’ brief specific praise e.g. "Exactly β€” C5 to T1 are the five roots."
193
+ - If partially correct or wrong β†’ neutral acknowledgment e.g. "Not quite β€” we'll come back to that."
194
+ Do not ask follow-up questions. Do not say 'which topic' or 'what would you like to explore'.
195
+ Do not give study tips or motivational speeches."""
196
 
197
  _TUTORING_SYSTEM = """\
198
  You are UnMask, a warm and encouraging Socratic tutor for OT anatomy (NBCOT prep).
 
216
  STUDY FOCUS: {study_focus} β€” ALL questions MUST stay within this topic area. Do not stray to unrelated anatomy concepts. If study_focus starts with "topic:", focus exclusively on that topic's clinical syndromes, mechanisms, and signs. | LEARNING MODE: {learning_mode} β€” if "visual", use spatial anatomical descriptions and reference diagram layouts; if "text", use clear prose explanations.
217
 
218
  TONE GUIDE β€” encouragement must be exactly ONE sentence, original, not canned:
219
+ - If the student's message is "idk", "don't know", "no idea", "not sure", "oh", "ok", "hm", or any short non-answer (< 10 words with no anatomy content) β†’ neutral pivot ONLY. VARY the phrasing every turn β€” do NOT repeat "No worries, let's build it up together." Use alternatives like: "That one catches people β€” let's approach it differently." / "Fair enough β€” let's try a clinical angle." / "Totally understandable β€” here's a starting point." / "Let's back up a bit." Never repeat the same opener twice in a session.
220
  - consecutive_incorrect = 0 AND student gave a substantive correct answer β†’ specific praise referencing what they got right e.g. "Exactly β€” C5 to T1 are the five roots."
221
  - consecutive_incorrect = 1 β†’ warm redirect e.g. "Not quite, but think about the movement involved."
222
  - consecutive_incorrect = 2 β†’ empathetic e.g. "This one trips a lot of people β€” let's try a fresh angle."
 
224
  CRITICAL: Write ONE sentence only for encouragement. No joining two phrases with a dash or period.
225
  NEVER say "great job" / "well done" / "you're doing great" / "you've got X right" when the student expressed uncertainty or said they don't know.
226
  Evaluate ONLY the CURRENT student message β€” ignore how well they answered in previous turns.
227
+ CLASSIFY THE STUDENT'S MESSAGE before responding:
228
+ A) SUBSTANTIVE ANATOMY ATTEMPT β€” contains anatomy terms, a diagnosis, a mechanism, a description of function, or a genuine attempt at an answer. β†’ Evaluate it, give honest feedback, ask a follow-up that builds on it.
229
+ B) ACKNOWLEDGMENT / REACTION β€” no anatomy content; just a social/emotional reaction (e.g. "oh", "ok", "neat", "interesting", "oh nice", "cool", "got it", "makes sense", "clean intro", "oh nice clean neat", "sure", "yeah", "that makes sense", "oh wow"). These could be 1 word or a short casual phrase. β†’ Do NOT repeat your last question. Advance the scaffold with a completely new angle: different clinical scenario, a simpler sub-question, or a concrete clue.
230
+ C) UNCERTAINTY β€” "idk", "no idea", "not sure", "still confused", etc. β†’ Same as B: advance, don't repeat.
231
+ If in doubt between B and C, treat it as B.
232
+ ANTI-REPETITION: Your socratic_question MUST be DIFFERENT from the last assistant message in the conversation history. If the student gave a B/C response, your question MUST use a different scenario and different wording. NEVER return the previous question verbatim or near-verbatim.
233
  {revisit_block}"""
234
 
235
  _REVEAL_SYSTEM = """\
 
240
  {context}
241
 
242
  WHAT TO DO (in visible_response):
243
+ - encouragement: ONE natural sentence acknowledging their effort. e.g. "No problem β€” let me walk you through it." NOT fake praise.
244
+ - socratic_question: DIRECTLY EXPLAIN the answer. Name the nerve/muscle/structure explicitly. Explain the mechanism or clinical link in 2-3 sentences. DO NOT ask a new anatomy or clinical scenario question β€” this is an explanation, not a quiz. You may end with a soft check like "Does that make sense?" or "Does that click?" but NOT a new clinical scenario or anatomy question.
245
+
246
+ CRITICAL: The student asked for an explanation. Give it. Do NOT respond with another Socratic question about the concept.
247
 
248
  In internal_analysis, still compute the correct answer and misconception as usual.
249
  STUDY FOCUS: {study_focus} | CONSECUTIVE INCORRECT: {consecutive_incorrect}"""
 
261
  STUDENT'S CURRENT MESSAGE: "{student_message}"
262
 
263
  TONE RULES β€” apply to encouragement field ONLY (1 sentence max):
264
+ - If the student's current message is "idk", "don't know", "no idea", "not sure", "oh", "ok", "hm", or any short non-answer:
265
+ β†’ encouragement MUST be a neutral pivot. VARY phrasing β€” avoid repeating "No worries" or "let's build it up". Use e.g. "That one catches people.", "Let's try a different angle.", "Fair enough β€” here's a clue."
266
  β†’ Do NOT say "you're on the right track", "great job", "you've got it", or any positive feedback.
267
  β†’ socratic_question MUST provide a concrete leading clue or scaffold, not just repeat the question.
268
  - consecutive_incorrect = 0 AND substantive correct answer β†’ specific praise referencing what they got right.
 
424
  - NBCOT-specific practice
425
  Example: 'NBCOT Prep: clinical scenarios β€” peripheral nerve injury splinting'
426
 
427
+ youtube_resources: 2-4 curated YouTube videos for the weak topics listed above. ALWAYS include at least 2 videos β€”
428
+ even if the session was brief, recommend videos for the topics listed in weak_topics.
429
+ Prioritize the lowest-mastery concepts.
430
  Use real videos from reputable anatomy channels: Osmosis, Khan Academy, AnatomyZone, Armando Hasudungan,
431
  Netter Anatomy, Acland's Video Atlas, Ninja Nerd Science.
432
  Each resource must include:
 
471
  [(k, v) for k, v in visited_mastery.items() if v < 0.5],
472
  key=lambda x: x[1]
473
  )
474
+
475
+ # Fallback: if no mastery data, use the study focus topic so YouTube recs are always generated
476
+ if not weak_topics:
477
+ fallback = (state.get("study_focus") or "brachial_plexus").replace("topic:", "").strip()
478
+ weak_topics = [(fallback, 0.0)]
479
+ if fallback not in topics_visited:
480
+ topics_visited.add(fallback)
481
+
482
+ weak_topics_str = ", ".join(f"{k} ({v:.0%})" for k, v in weak_topics)
483
 
484
  client = _get_client()
485
+ # Cap mistake_log to avoid prompt bloat on long sessions
486
+ capped_mistakes = mistake_log[-30:] if len(mistake_log) > 30 else mistake_log
487
  prompt = _SUMMARY_PROMPT.format(
488
  mastery_json=json.dumps(visited_mastery, indent=2),
489
+ mistakes_json=json.dumps(capped_mistakes, indent=2) if capped_mistakes else "[]",
490
  topics_covered=", ".join(topics_visited) or "none",
491
  weak_topics=weak_topics_str,
492
  duration_min=elapsed / 60,
493
  total_turns=turn,
494
  )
495
 
496
+ # Use plain create + system-message JSON instruction.
497
+ # (beta.parse embeds the full Pydantic schema, exceeding Mercury-2's context limit.)
498
+ # max_tokens=4096: 16-topic report with flashcards+youtube easily hits 3k tokens.
499
+ import re as _re
500
+ _SYSTEM_JSON = (
501
+ "You are a JSON-only assistant. Respond ONLY with a valid JSON object β€” "
502
+ "no markdown fences, no prose before or after. "
503
+ "Required keys: overall_assessment, topic_reports, mistake_highlights, "
504
+ "study_recommendations, resources, youtube_resources, diagram_suggestions, "
505
+ "flashcards, next_session_questions, closing_reflection. "
506
+ "topic_reports items: {concept, mastery_score, status (mastered|progressing|needs_review), honest_feedback}. "
507
+ "youtube_resources items: {concept, title, creator, search_query, description}. "
508
+ "flashcards items: {concept, front, back}."
509
  )
510
+ import logging as _logging
511
+ raw_content = ""
512
+ for _attempt in range(2):
513
+ try:
514
+ resp_raw = client.chat.completions.create(
515
+ model=os.getenv("OPENAI_MODEL", _cfg["llm"]["model"]),
516
+ messages=[
517
+ {"role": "system", "content": _SYSTEM_JSON},
518
+ {"role": "user", "content": prompt},
519
+ ],
520
+ max_tokens=4096,
521
+ temperature=0.3,
522
+ )
523
+ raw_content = (resp_raw.choices[0].message.content or "").strip()
524
+ if raw_content:
525
+ break
526
+ _logging.warning(f"[session_summary] attempt {_attempt+1}: empty response from model")
527
+ except Exception as _api_err:
528
+ _logging.warning(f"[session_summary] attempt {_attempt+1}: API error {_api_err!r}")
529
+
530
+ # Strip markdown code fences and extract first {...} block
531
+ raw_content = _re.sub(r"```(?:json)?\s*", "", raw_content).replace("```", "").strip()
532
+ _brace = raw_content.find("{")
533
+ _rbrace = raw_content.rfind("}")
534
+ if _brace != -1 and _rbrace != -1 and _rbrace > _brace:
535
+ raw_content = raw_content[_brace : _rbrace + 1]
536
+
537
+ summary: SessionSummary | None = None
538
+ try:
539
+ data = json.loads(raw_content)
540
+ # Fill in any missing optional fields before validation
541
+ data.setdefault("topic_reports", [])
542
+ data.setdefault("mistake_highlights", [])
543
+ data.setdefault("study_recommendations", [])
544
+ data.setdefault("resources", [])
545
+ data.setdefault("youtube_resources", [])
546
+ data.setdefault("diagram_suggestions", [])
547
+ data.setdefault("flashcards", [])
548
+ data.setdefault("next_session_questions", [])
549
+ data.setdefault("closing_reflection", "Keep reviewing!")
550
+
551
+ # Normalize list[str] fields β€” model often returns list[dict] for these
552
+ def _coerce_str_list(items: list, *keys: str) -> list[str]:
553
+ result = []
554
+ for item in items:
555
+ if isinstance(item, str):
556
+ result.append(item)
557
+ elif isinstance(item, dict):
558
+ # Try common keys the model uses, fall back to joined values
559
+ text = next((item[k] for k in keys if k in item and isinstance(item[k], str)), None)
560
+ if text is None:
561
+ text = " β€” ".join(str(v) for v in item.values() if v)
562
+ result.append(text)
563
+ return result
564
+
565
+ data["mistake_highlights"] = _coerce_str_list(
566
+ data["mistake_highlights"], "description", "text", "misconception", "highlight"
567
+ )
568
+ data["resources"] = _coerce_str_list(
569
+ data["resources"], "title", "description", "text", "resource"
570
+ )
571
+ data["study_recommendations"] = _coerce_str_list(
572
+ data["study_recommendations"], "recommendation", "text", "tip", "description"
573
+ )
574
+ data["diagram_suggestions"] = _coerce_str_list(
575
+ data["diagram_suggestions"], "description", "text", "suggestion", "diagram"
576
+ )
577
+ data["next_session_questions"] = _coerce_str_list(
578
+ data["next_session_questions"], "question", "text", "description"
579
+ )
580
+
581
+ # Tolerate extra keys
582
+ summary = SessionSummary.model_validate(data)
583
+ except Exception as _parse_err:
584
+ _logging.warning(f"[session_summary] JSON parse failed: {_parse_err!r} | raw[:400]={raw_content[:400]!r}")
585
+
586
+ if summary is None:
587
+ fallback = "## πŸ“‹ Session Report\n\nSession complete. Great work today β€” review your weak topics before next time."
588
+ return fallback, SessionSummary(
589
+ overall_assessment="Session complete.",
590
+ topic_reports=[], mistake_highlights=[], study_recommendations=[],
591
+ resources=[], youtube_resources=[], diagram_suggestions=[],
592
+ flashcards=[], next_session_questions=[], closing_reflection="Keep reviewing!",
593
+ )
594
 
595
  # ── Format as readable markdown ────────────────────────────────────────
596
  lines = ["## πŸ“‹ Session Report\n"]
597
+ lines.append(f"{summary.overall_assessment or ''}\n")
598
 
599
  # Per-topic report card
600
+ topic_reports = summary.topic_reports or []
601
+ if topic_reports:
602
+ lines.append("### Topic Breakdown\n")
603
+ status_icon = {"mastered": "βœ…", "progressing": "🟑", "needs_review": "❌"}
604
+ for tr in topic_reports:
605
+ icon = status_icon.get(tr.status, "⬜")
606
+ concept_readable = tr.concept.replace("_", " ").replace(".", " β€Ί ")
607
+ lines.append(
608
+ f"{icon} **{concept_readable}** β€” mastery {tr.mastery_score:.0%}\n"
609
+ f"> {tr.honest_feedback}\n"
610
+ )
611
 
612
  # Mistake highlights
613
+ for m in (summary.mistake_highlights or []):
614
+ if not lines[-1].startswith("### ⚠️"):
615
+ lines.append("### ⚠️ Misconceptions to Address\n")
616
+ lines.append(f"- {m}")
617
+ if any("Misconceptions" in l for l in lines):
618
  lines.append("")
619
 
620
  # Study recommendations
621
+ study_recs = summary.study_recommendations or []
622
+ if study_recs:
623
  lines.append("### πŸ“š Study Recommendations\n")
624
+ for tip in study_recs:
625
  lines.append(f"- {tip}")
626
  lines.append("")
627
 
628
  # Resources
629
+ resources = summary.resources or []
630
+ if resources:
631
  lines.append("### πŸ“– Study Resources\n")
632
+ for r in resources:
633
  lines.append(f"- {r}")
634
  lines.append("")
635
 
636
  # YouTube recommendations
637
+ youtube = summary.youtube_resources or []
638
+ if youtube:
639
  lines.append("### 🎬 Recommended Videos\n")
640
+ for yt in youtube:
641
+ search_url = "https://www.youtube.com/results?search_query=" + (yt.search_query or "").replace(" ", "+")
642
  lines.append(
643
  f"- **{yt.title}** β€” *{yt.creator}*\n"
644
  f" {yt.description}\n"
 
646
  )
647
  lines.append("")
648
 
 
 
 
649
  # Next session questions
650
+ next_qs = summary.next_session_questions or []
651
+ if next_qs:
652
  lines.append("### πŸ” Practice Questions for Next Session\n")
653
+ for i, q in enumerate(next_qs, 1):
654
  lines.append(f"**{i}.** {q}")
655
  lines.append("")
656
 
657
  # Closing reflection
658
+ if summary.closing_reflection:
659
+ lines.append(f"---\n**Before next session:** {summary.closing_reflection}")
660
 
661
  return "\n".join(lines), summary
662
 
 
678
  """Generate explicit structured feedback on a student's assessment answer."""
679
  import json
680
  chunks = state.get("retrieved_chunks", [])
681
+ _raw_ctx2 = "\n\n".join(
682
  f"[{c.get('chunk_type','ctx').upper()}] {c['text']}" for c in chunks
683
  ) or "(no context)"
684
+ context_text = _raw_ctx2[:8000] + ("\n...[truncated]" if len(_raw_ctx2) > 8000 else "")
685
 
686
+ import re as _re2, json as _json2
687
  client = _get_client()
688
  prompt = _ASSESSMENT_FEEDBACK_SYSTEM.format(
689
  scenario=scenario,
690
  student_response=student_response,
691
  context=context_text,
692
  )
693
+ resp_fb = client.chat.completions.create(
694
  model=os.getenv("OPENAI_MODEL", _cfg["llm"]["model"]),
695
+ messages=[
696
+ {"role": "system", "content": "You are a JSON-only assistant. Respond ONLY with a valid JSON object. No markdown, no prose."},
697
+ {"role": "user", "content": prompt},
698
+ ],
699
+ max_tokens=600,
700
  temperature=0.3,
701
+ response_format={"type": "json_object"},
702
  )
703
+ raw_fb = _re2.sub(r"^```(?:json)?\s*|\s*```$", "", (resp_fb.choices[0].message.content or "").strip(), flags=_re2.DOTALL).strip()
704
+ fb: AssessmentFeedback = AssessmentFeedback(**_json2.loads(raw_fb))
705
 
706
  score_icon = {"excellent": "βœ…", "good": "🟒", "partial": "🟑", "needs_work": "❌"}.get(fb.score, "⬜")
707
  lines = [
 
728
  history = state.get("conversation_history", [])
729
  mastery = state.get("mastery_scores", {})
730
  topic = state.get("current_topic", "")
731
+ _session_id = state.get("session_id", "")
732
 
733
  _max_turns = _cfg["llm"].get("max_history_turns", 8)
734
 
735
+ _raw_ctx = "\n\n".join(
736
  f"[{c.get('chunk_type','context').upper()}] {c['text']}"
737
  for c in chunks
738
  ) or "(No context retrieved)"
739
+ context_text = _raw_ctx[:12000] + ("\n...[truncated]" if len(_raw_ctx) > 12000 else "")
740
 
741
  recent_history = "\n".join(
742
  f"{m['role'].capitalize()}: {m['content']}" for m in history[-_max_turns:]
 
812
  "give me the answer", "i give up", "i have no idea", "no idea whatsoever",
813
  "i don't know at all", "i dont know at all", "i'm clueless", "im clueless",
814
  "i don't have time", "dont have time",
815
+ "explain this to me", "can you explain", "explain this", "explain it to me",
816
+ "explain in detail", "explain to me", "help me understand", "break it down",
817
+ "walk me through", "just explain", "please explain",
818
  )
819
  consecutive_incorrect = state.get("consecutive_incorrect", 0)
820
  _student_msg_lower = (state.get("student_message") or "").lower()
 
909
  internal_analysis = None
910
  response_text = ""
911
 
912
+ def _parse_attempt(msgs: list, temp: float) -> SocraticOutput:
913
+ """Single API call with JSON prompt; streams tokens to queue if registered."""
914
+ import re as _re, json as _json
915
+
916
+ json_instruction = (
917
+ "\n\nRespond ONLY with a JSON object matching this schema (no markdown, no prose):\n"
918
+ '{"internal_analysis": {"correct_answer": "...", "student_misconception": "...", '
919
+ '"planned_hint_sequence": [], "relevant_textbook_section": "..."}, '
920
+ '"visible_response": {"encouragement": "...", "socratic_question": "...?"}}'
921
+ )
922
+ augmented = list(msgs)
923
+ if augmented and augmented[0]["role"] == "system":
924
+ augmented[0] = {**augmented[0], "content": augmented[0]["content"] + json_instruction}
925
+
926
+ token_q = _get_token_queue(_session_id)
927
+
928
+ if token_q is not None:
929
+ # Streaming path: collect full response, then push visible text as one token.
930
+ # Mercury 2 outputs ~1000 JSON tokens; character-level streaming of JSON keys
931
+ # leaks syntax to the frontend. Instead we stream=True for latency benefit
932
+ # (Mercury 2 starts generating immediately) and emit the visible question once done.
933
+ stream_resp = client.chat.completions.create(
934
+ model=os.getenv("OPENAI_MODEL", _cfg["llm"]["model"]),
935
+ temperature=temp,
936
+ messages=augmented,
937
+ max_tokens=1000,
938
+ stream=True,
939
+ )
940
+ raw_parts: list[str] = []
941
+ for chunk in stream_resp:
942
+ delta = chunk.choices[0].delta.content if chunk.choices else None
943
+ if delta:
944
+ raw_parts.append(delta)
945
+ raw = "".join(raw_parts).strip()
946
+ # Extract visible text from JSON, emit as single token
947
  try:
948
+ clean = _re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=_re.DOTALL).strip()
949
+ data = _json.loads(clean)
950
+ vr = data.get("visible_response", {})
951
+ enc = vr.get("encouragement", "")
952
+ sq = vr.get("socratic_question", "")
953
+ visible_text = f"{enc} {sq}".strip() if enc else sq
954
+ if visible_text:
955
+ token_q.put(visible_text)
956
+ except Exception:
957
+ # Fallback: extract question via regex
958
+ m = _re.search(r'"socratic_question"\s*:\s*"((?:[^"\\]|\\.)*)"', raw)
959
+ if m:
960
+ token_q.put(m.group(1).replace('\\"', '"').replace('\\n', ' '))
961
+ token_q.put(None) # end sentinel
962
+ else:
963
+ resp = client.chat.completions.create(
964
+ model=os.getenv("OPENAI_MODEL", _cfg["llm"]["model"]),
965
+ temperature=temp,
966
+ messages=augmented,
967
+ max_tokens=1000,
968
+ )
969
+ raw = (resp.choices[0].message.content or "").strip()
970
+
971
+ # Try JSON parse β†’ Pydantic
972
+ try:
973
+ clean = _re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=_re.DOTALL).strip()
974
+ data = _json.loads(clean)
975
+ return SocraticOutput(**data)
976
+ except Exception as _e:
977
+ import logging as _logging
978
+ _logging.warning(f"[socratic_generator] JSON parse failed (attempt): {_e!r} | raw[:300]={raw[:300]!r}")
979
+
980
+ # Fallback: regex-extract socratic_question, never return raw JSON
981
+ m_fallback = _re.search(r'"socratic_question"\s*:\s*"((?:[^"\\]|\\.)*)"', raw)
982
+ if m_fallback:
983
+ question = m_fallback.group(1).replace('\\"', '"').replace('\\n', ' ')
984
+ else:
985
+ question = "Let's try a different angle β€” what movement or sensation would be affected if this structure were damaged?"
986
+ return SocraticOutput(
987
+ internal_analysis=InternalAnalysis(
988
+ correct_answer="", student_misconception="",
989
+ planned_hint_sequence=[], relevant_textbook_section="",
990
+ ),
991
+ visible_response=VisibleResponse(encouragement="", socratic_question=question),
992
+ )
993
+
994
+ for attempt in range(2):
995
+ output: SocraticOutput = _parse_attempt(messages, _cfg["llm"]["temperature"] if attempt == 0 else 0)
996
  visible = output.visible_response
997
  candidate = visible.socratic_question
998
  if visible.encouragement:
 
1020
  cand_words = set(visible.socratic_question.lower().split())
1021
  prev_words = set(last_assistant_q.lower().split())
1022
  overlap = len(cand_words & prev_words) / max(len(cand_words), 1)
1023
+ if overlap > 0.45: # stricter β€” catch close paraphrases too
1024
  messages = [
1025
+ {"role": "system", "content": system + "\n\nCRITICAL: You JUST asked this question and must NOT repeat it: \"" + last_assistant_q[:200] + "\"\nThe student replied with a short acknowledgment. Move to a DIFFERENT approach: different clinical scenario, simpler sub-question, or direct clue. Even a single shared scenario detail counts as repeating."},
1026
  *history[-10:],
1027
  {"role": "user", "content": user_msg},
1028
  ]