Spaces:
Sleeping
Sleeping
sync: src/nodes/socratic_generator.py
Browse files- 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
|
| 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
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
- "
|
| 158 |
-
-
|
| 159 |
-
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 204 |
-
- socratic_question:
|
|
|
|
|
|
|
| 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
|
| 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
|
| 386 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
client = _get_client()
|
|
|
|
|
|
|
| 434 |
prompt = _SUMMARY_PROMPT.format(
|
| 435 |
mastery_json=json.dumps(visited_mastery, indent=2),
|
| 436 |
-
mistakes_json=json.dumps(
|
| 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 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
)
|
| 449 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
|
|
|
|
|
|
| 465 |
|
| 466 |
# Mistake highlights
|
| 467 |
-
|
| 468 |
-
lines.
|
| 469 |
-
|
| 470 |
-
|
|
|
|
| 471 |
lines.append("")
|
| 472 |
|
| 473 |
# Study recommendations
|
| 474 |
-
|
|
|
|
| 475 |
lines.append("### π Study Recommendations\n")
|
| 476 |
-
for tip in
|
| 477 |
lines.append(f"- {tip}")
|
| 478 |
lines.append("")
|
| 479 |
|
| 480 |
# Resources
|
| 481 |
-
|
|
|
|
| 482 |
lines.append("### π Study Resources\n")
|
| 483 |
-
for r in
|
| 484 |
lines.append(f"- {r}")
|
| 485 |
lines.append("")
|
| 486 |
|
| 487 |
# YouTube recommendations
|
| 488 |
-
|
|
|
|
| 489 |
lines.append("### π¬ Recommended Videos\n")
|
| 490 |
-
for yt in
|
| 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 |
-
|
|
|
|
| 504 |
lines.append("### π Practice Questions for Next Session\n")
|
| 505 |
-
for i, q in enumerate(
|
| 506 |
lines.append(f"**{i}.** {q}")
|
| 507 |
lines.append("")
|
| 508 |
|
| 509 |
# Closing reflection
|
| 510 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 543 |
model=os.getenv("OPENAI_MODEL", _cfg["llm"]["model"]),
|
| 544 |
-
messages=[
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
| 546 |
temperature=0.3,
|
|
|
|
| 547 |
)
|
| 548 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 752 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
try:
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
)
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 794 |
messages = [
|
| 795 |
-
{"role": "system", "content": system + "\n\
|
| 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 |
]
|