BuddyMath / domain /semantic_bank.py
dotandru's picture
Fix: Clean production deployment with sse-starlette
9d29c62
# domain/semantic_bank.py — V7.2.5: Semantic Composer
#
# The SemanticBank is a governed, deterministic source of pedagogical narratives.
# Instead of letting LLM #2 generate free text (risky), the NarrativeComposer picks
# from curated, teacher-grade Hebrew templates and injects server-signed results.
#
# CTO directive: text must sound like a real private tutor speaking to a 10th/12th grader.
# Write at eye level — encouraging, intuitive, NOT like system error messages.
import random
import logging
from dataclasses import dataclass, field
from collections import Counter
from typing import List, Dict, Optional
from domain import telemetry
logger = logging.getLogger(__name__)
DRIFT_ALERT_THRESHOLD = 0.35 # Emit alert when one variant dominates above this fraction
# ─── Data Model ───────────────────────────────────────────────────────────────
@dataclass
class SemanticEntry:
"""
A single pedagogical concept in the bank.
Each entry holds multiple variant fragments to ensure narrative diversity.
"""
concept_tag: str # matches Planner Enum action (e.g. "SOLVE_EQUATION")
openings: List[str] # 3 opening phrases — how to START the explanation
bridges: List[str] # 3 logic bridges — HOW to connect the steps
analogies: List[str] # 2 analogy/metaphor sentences — the "aha!" moments
closing: str # 1 closing encouragement sentence
# ─── The Bank ─────────────────────────────────────────────────────────────────
SEMANTIC_BANK: Dict[str, SemanticEntry] = {
"SOLVE_EQUATION": SemanticEntry(
concept_tag="SOLVE_EQUATION",
openings=[
"בואו נפתור את המשוואה צעד אחר צעד, אל תדאג, זה פחות מפחיד משנראה.",
"כדי לגלות את ערך הנעלם, אנחנו הולכים לבודד אותו משני צידי המשוואה.",
"נחשוב על המשוואה כמו מאזניים: כל מה שעושים בצד ימין, עושים גם בצד שמאל.",
],
bridges=[
"אחרי הפעולה הזו, כל מה שנותר הוא:",
"וכשנפשט את כל מה שקיבלנו, התוצאה מתגלה:",
"המשוואה 'התנקתה' ועכשיו ברור שהתשובה היא:",
],
analogies=[
"חשוב על זה כמו חידה — אנחנו מסירים רמזים אחד אחד עד שהנעלם חשוף לגמרי.",
"בדיוק כמו שמשחקים מחבואים — אנחנו מחפשים את x עד שהוא כבר לא יכול להתחבא.",
],
closing="עשית את זה! זו הדרך הרשמית שבה מתמטיקאים פותרים משוואות.",
),
"SIMPLIFY": SemanticEntry(
concept_tag="SIMPLIFY",
openings=[
"הביטוי הזה נראה מסובך, אבל אם נסדר אותו — הוא מתקפל לצורה הרבה יותר נקייה.",
"בפישוט, המטרה היא לכתוב את אותו הדבר — רק בצורה המינימלית ביותר.",
"כמו לסדר חדר מבולגן — אנחנו אוספים איברים דומים ומסלקים מה שמיותר.",
],
bridges=[
"אחרי שנאסוף את כל האיברים הדומים יחד, הביטוי הפשוט הוא:",
"כשמסלקים את כל 'הרעש המתמטי', מה שנשאר הוא:",
"הצורה הפשוטה והנקייה ביותר של הביטוי:",
],
analogies=[
"דמיין שיש לך הרבה שטרות של כסף — אתה מחליף אותם לשטר אחד גדול. זה בדיוק מה שאנחנו עושים עם הביטוי.",
"כמו לקפל מפת ענק — בסוף קיבלת את אותו המידע, רק בגודל שנוח לשאת.",
],
closing="יופי! פישוט זה אחת הכישורים הכי שימושיים במתמטיקה — ועכשיו אתה יודע לעשות את זה.",
),
"FACTOR": SemanticEntry(
concept_tag="FACTOR",
openings=[
"פירוק לגורמים זה כמו למצוא את 'הבניינים' שמרכיבים את הביטוי שלנו.",
"במקום לראות ביטוי אחד גדול, אנחנו מחפשים שני ביטויים קטנים שמוכפלים זה בזה.",
"הטריק בפירוק לגורמים הוא לזהות מה 'מסתתר' בתוך הביטוי ואפשר להוציא החוצה.",
],
bridges=[
"כשנוציא את הגורם המשותף, נקבל:",
"אחרי הפירוק, הביטוי נכתב בצורה הכפלית:",
"הגורמים שמרכיבים את הביטוי הם:",
],
analogies=[
"פירוק לגורמים הוא כמו לפרק מספר לחלוקה ראשונית — אנחנו מוצאים את ה-DNA המתמטי של הביטוי.",
"דמיין שאתה מפרק ארגז גדול לקופסאות קטנות — כל קופסא היא גורם. ביחד הן מרכיבות את המקור.",
],
closing="פירוק לגורמים הוא בדיוק מה שמאפשר לנו לפתור משוואות ריבועיות — תכיר את הכלי הזה טוב.",
),
"EXPAND": SemanticEntry(
concept_tag="EXPAND",
openings=[
"עכשיו נפרוש את הסוגריים — נכפיל כל איבר בתוך הסוגריים עם כל מה שמחוצה להם.",
"פתיחת סוגריים היא כמו 'לפתוח' מתנה עטופה — מה שמסתתר בפנים יוצא לאור.",
"נשתמש בחוק הפילוג כדי להרחיב את הביטוי ולראות את כל האיברים בנפרד.",
],
bridges=[
"לאחר פתיחת הסוגריים ואיסוף האיברים הדומים:",
"כשמרחיבים הכל ומסדרים:",
"הביטוי המורחב, עם כל האיברים גלויים:",
],
analogies=[
"חשוב על זה כמו להכפיל תמחיר — אם קנית שלושה שקים, כל אחד עם תפוחים ובננות, אתה מחשב כמה תפוחים וכמה בננות יש בסך הכל.",
"פתיחת סוגריים היא כמו לגלגל בצק — לוחצים ומרחיבים עד שהכל שטוח ונראה.",
],
closing="הרחבת הביטוי זו מיומנות בסיסית שתשתמש בה שוב ושוב — וכבר שלטת בה.",
),
"FIND_DERIVATIVE": SemanticEntry(
concept_tag="FIND_DERIVATIVE",
openings=[
"הנגזרת אומרת לנו כמה מהר הפונקציה עולה או יורדת — היא כמו 'מד המהירות' של הגרף.",
"כדי למצוא את הנגזרת, אנחנו שואלים: אם x זז קצת קדימה — כמה y משתנה?",
"נחשב את הנגזרת לפי כללי הגזירה שנלמדו — זה הרבה יותר מהיר מההגדרה הבסיסית.",
],
bridges=[
"לפי כלל הגזירה ורשימת הנגזרות, קיבלנו:",
"הנגזרת, שמייצגת את שיפוע המשיק לפונקציה, היא:",
"קצב השינוי המיידי של הפונקציה הוא:",
],
analogies=[
"אם הפונקציה היא מסלול נסיעה, הנגזרת היא המד-מהירות — היא אומרת לך כמה מהר אתה נע בכל רגע.",
"דמיין שאתה מטפס על הר — הנגזרת אומרת לך בכל נקודה עד כמה התלילות. חיובית = עולים, שלילית = יורדים.",
],
closing="הנגזרת פותחת עולם שלם — מניתוח קצוות ועד מכניקה. כל הכבוד על המיומנות הזו.",
),
"FIND_INTEGRAL": SemanticEntry(
concept_tag="FIND_INTEGRAL",
openings=[
"האינטגרל הוא הפעולה ההפוכה לגזירה — אנחנו שואלים 'מה הפונקציה שהנגזרת שלה היא זו?'.",
"כדי לחשב את האינטגרל, נשתמש בנוסחאות הבסיסיות ובחוק ה-C, ה-קבוע האינטגרציה.",
"האינטגרל הלא מסוים נותן לנו משפחה שלמה של פונקציות — שונות זו מזו בקבוע בלבד.",
],
bridges=[
"אחרי אינטגרציה לפי כלל ההפיכה של הנגזרת:",
"הפונקציה הפרימיטיבית שמרכיבה את האינטגרל היא:",
"ביצוע האינטגרציה נותן לנו:",
],
analogies=[
"אם הנגזרת היא מד-המהירות, האינטגרל הוא מד-המרחק — הוא אוסף את כל השינויים הקטנים לסכום אחד.",
"חשוב על אינטגרציה כמו לגלגל סרט לאחור — אנחנו 'מחזירים' את הגזירה לנקודת המוצא שלה.",
],
closing="אינטגרציה היא לב חשבון אינפיניטסימלי — ועכשיו יש לך את הכלי לחשב שטחים, נפחים ועוד.",
),
"SUBSTITUTE": SemanticEntry(
concept_tag="SUBSTITUTE",
openings=[
"כדי לבדוק את הפתרון (או להשלים חישוב), נציב את הערך הידוע ונפשט.",
"ההצבה היא הדרך שלנו לחבר בין שני חלקי הבעיה — מציבים מה שיודעים ורואים מה יוצא.",
"נחליף את המשתנה בערך הנתון ונחשב — זה ישאיר לנו ביטוי הרבה יותר פשוט.",
],
bridges=[
"לאחר ההצבה והפישוט:",
"כשנציב ונפשט את הביטוי שקיבלנו:",
"ערך ההצבה מניב:",
],
analogies=[
"הצבה היא כמו למלא שם בטופס — אנחנו מחליפים את 'הנעלם' בערך הממשי שמצאנו.",
"דמיין מתכון שאומר 'ספל סוכר' — ברגע שאתה יודע כמה גדול הספל, אתה יכול לחשב כמות מדויקת.",
],
closing="מצוין! ההצבה היא גם דרך מצוינת לבדוק שהפתרון שמצאת הוא נכון.",
),
# ── V7.3: Geometry / Analytic Entries ────────────────────────────────────
"FIND_AXIS_INTERSECTIONS": SemanticEntry(
concept_tag="FIND_AXIS_INTERSECTIONS",
openings=[
"כדי לגלות איפה המעגל פוגש את הצירים, נשאל שאלה פשוטה: מה קורה כשנקודה נמצאת ממש על ציר ה-x? ה-y שלה שווה לאפס! ולהפך.",
"נקודות חיתוך עם הצירים הן הנקודות שבהן המעגל חוצה את 'הכבישים' של מערכת הצירים.",
"הדרך לאתר את נקודות החיתוך עם הצירים היא להציב אפס: פעם עבור x ופעם עבור y, ולראות מה הפתרון שיוצא.",
],
bridges=[
"כשנציב ונפתור, הנקודות שמצאנו הן:",
"לאחר ההצבה וקבלת הפתרונות, נקודות החיתוך עם הצירים הן:",
"הפתרונות שקיבלנו מציינים את הנקודות שבהן המעגל חוצה את הצירים:",
],
analogies=[
"חשוב על מעגל כמו כדור שמתגלגל על רצפה — נקודות החיתוך עם ציר ה-x הן בדיוק המקומות שהכדור נוגע ברצפה.",
"דמיין שאתה מסתכל על מפה ומחפש איפה כביש ראשי חוצה את הנהר — זה בדיוק מה שאנחנו עושים עם המעגל והצירים.",
],
closing="מצאנו את כל נקודות המגע של המעגל עם מערכת הצירים — עבודה מדויקת!",
),
"CALCULATE_SLOPE_AND_LINE": SemanticEntry(
concept_tag="CALCULATE_SLOPE_AND_LINE",
openings=[
"כדי למצוא את משוואת הישר העובר דרך שתי נקודות, נחשב קודם את השיפוע — כמה 'תלול' הישר.",
"שיפוע הישר הוא היחס בין השינוי בגובה לשינוי בהיקף: עלייה חלקי הזזה אופקית.",
"הישר שמחבר שתי נקודות מוגדר לחלוטין על ידי השיפוע שלו ונקודת מעבר אחת.",
],
bridges=[
"לאחר חישוב השיפוע, משוואת הישר היא:",
"כשנוסיף את השיפוע שמצאנו לנוסחת הישר, נקבל:",
"הישר שמחבר מרכז המעגל לראשית הצירים הוא:",
],
analogies=[
"שיפוע הוא כמו מדרגות — אם עולים ארבע קומות לכל שלושה צעדים קדימה, השיפוע הוא ארבעה חלקי שלושה.",
"דמיין ישר כמו דרך בין שתי ערים — השיפוע הוא כמה מטרים עולים לכל קילומטר שנוסעים.",
],
closing="קיבלנו את משוואת הישר בצורה המפורשת — קו ישר שעובר בדיוק דרך שתי הנקודות הנתונות.",
),
"CALCULATE_DISTANCE": SemanticEntry(
concept_tag="CALCULATE_DISTANCE",
openings=[
"כדי לבדוק האם נקודה נמצאת על המעגל, נמדוד את המרחק בינה לבין מרכז המעגל ונשווה לרדיוס.",
"נוסחת המרחק בין שתי נקודות היא שורש של סכום ריבועי ההפרשים — בדיוק כמו משפט פיתגורס.",
"השאלה פשוטה: האם הנקודה נמצאת בדיוק ברדיוס מהמרכז, קרוב יותר, או רחוק יותר?",
],
bridges=[
"לאחר חישוב המרחק, הממצא הוא:",
"כשמשווים את המרחק שמצאנו לרדיוס המעגל, מתברר ש:",
"תוצאת חישוב המרחק מול הרדיוס:",
],
analogies=[
"דמיין מדוזה עגולה — כל נקודה על גבה נמצאת בדיוק באותו מרחק מהמרכז. אם נקודה קרובה יותר — היא בתוכה. רחוקה יותר — מחוצה לה.",
"מרחק שתי נקודות זה כמו למדוד עם סרגל בין שתי ערים על מפה — פיתגורס עושה את העבודה.",
],
closing="בדקנו מדויק: המרחק חושב, הושווה לרדיוס, ומיקום הנקודה נקבע בוודאות מתמטית.",
),
}
# ─── Diversity Engine ──────────────────────────────────────────────────────────
class DiversityEngine:
"""
Selects narrative variants from the SemanticBank with diversity enforcement.
Tracks selection history per concept to detect "narrative laziness" (Drift):
if one variant is chosen more than DRIFT_ALERT_THRESHOLD fraction of the time,
emit a PEDAGOGICAL_DRIFT telemetry alert.
"""
def __init__(self):
# Counter per concept_tag: tracks how many times each (variant_type, index) was picked
self._history: Dict[str, Counter] = {}
def _record(self, concept_tag: str, variant_key: str):
if concept_tag not in self._history:
self._history[concept_tag] = Counter()
self._history[concept_tag][variant_key] += 1
def drift_score(self, concept_tag: str) -> float:
"""
Returns the dominance fraction of the most-selected variant for this concept.
Score = max_count / total_count.
0.0 = perfectly uniform, 1.0 = always the same variant.
"""
if concept_tag not in self._history or not self._history[concept_tag]:
return 0.0
counts = self._history[concept_tag]
total = sum(counts.values())
if total == 0:
return 0.0
return max(counts.values()) / total
def _pick(self, concept_tag: str, variant_type: str, options: List[str]) -> str:
"""
Weighted random selection that avoids the most recently over-used variants.
Falls back to uniform random if history is thin (< 5 picks).
"""
n = len(options)
history_key_prefix = f"{variant_type}:"
if concept_tag not in self._history or sum(self._history[concept_tag].values()) < 5:
# Not enough history — uniform random
idx = random.randrange(n)
else:
counts = self._history[concept_tag]
# Weight = inverse of how often we've picked this index
weights = []
for i in range(n):
pick_count = counts.get(f"{history_key_prefix}{i}", 0)
weights.append(1.0 / (1 + pick_count))
# Weighted choice
total_w = sum(weights)
r = random.uniform(0, total_w)
cumulative = 0.0
idx = n - 1 # fallback
for i, w in enumerate(weights):
cumulative += w
if r <= cumulative:
idx = i
break
key = f"{history_key_prefix}{idx}"
self._record(concept_tag, key)
# Check drift and emit telemetry
score = self.drift_score(concept_tag)
if score > DRIFT_ALERT_THRESHOLD:
logger.warning(
f"[DRIFT_MONITOR] Pedagogical drift detected for '{concept_tag}': "
f"drift_score={score:.2f} > threshold={DRIFT_ALERT_THRESHOLD}. "
f"Selection history: {dict(self._history[concept_tag])}"
)
telemetry.emit_pedagogical_drift(concept_tag, score)
return options[idx]
def compose(self, action: str, signed_steps: list) -> str:
"""
Main entry point: builds a full Hebrew pedagogical narrative for the given action.
Inserts {{step_id}} placeholders where the server results will be shown.
Returns a string with {{placeholder}} notation (not yet injected).
"""
entry = SEMANTIC_BANK.get(action)
if entry is None:
logger.warning(
f"[SEMANTIC_BANK] No entry for action '{action}'. "
f"Falling back to LLM #2 renderer."
)
return None # Signal to caller: use LLM fallback
concept_tag = entry.concept_tag
# Pick diverse variants
opening = self._pick(concept_tag, "opening", entry.openings)
bridge = self._pick(concept_tag, "bridge", entry.bridges)
analogy = self._pick(concept_tag, "analogy", entry.analogies)
closing = entry.closing # Always the same (one closing per concept by design)
# Build placeholder string from signed steps
if not signed_steps:
placeholder_str = ""
elif len(signed_steps) == 1:
placeholder_str = f"{{{{{signed_steps[0]['id']}}}}}"
else:
parts = [f"{{{{{s['id']}}}}}" for s in signed_steps]
placeholder_str = " ← ".join(parts)
# Compose the narrative (V7.2.5: no emojis/em-dashes — must pass scan_for_math_leakage)
narrative = (
f"{opening}\n\n"
f"{analogy}\n\n"
f"{bridge} {placeholder_str}\n\n"
f"{closing}"
)
logger.info(
f"[SEMANTIC_BANK] Composed narrative for '{action}' | "
f"drift_score={self.drift_score(concept_tag):.2f}"
)
return narrative
# Module-level singleton — shared across all requests in the process
_engine = DiversityEngine()
def get_diversity_engine() -> DiversityEngine:
"""Returns the process-level DiversityEngine singleton."""
return _engine