| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| @dataclass |
| class SemanticEntry: |
| """ |
| A single pedagogical concept in the bank. |
| Each entry holds multiple variant fragments to ensure narrative diversity. |
| """ |
| concept_tag: str |
| openings: List[str] |
| bridges: List[str] |
| analogies: List[str] |
| closing: str |
|
|
|
|
| |
|
|
| 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="מצוין! ההצבה היא גם דרך מצוינת לבדוק שהפתרון שמצאת הוא נכון.", |
| ), |
|
|
| |
|
|
| "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="בדקנו מדויק: המרחק חושב, הושווה לרדיוס, ומיקום הנקודה נקבע בוודאות מתמטית.", |
| ), |
|
|
| } |
|
|
|
|
| |
|
|
| 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): |
| |
| 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: |
| |
| idx = random.randrange(n) |
| else: |
| counts = self._history[concept_tag] |
| |
| weights = [] |
| for i in range(n): |
| pick_count = counts.get(f"{history_key_prefix}{i}", 0) |
| weights.append(1.0 / (1 + pick_count)) |
| |
| total_w = sum(weights) |
| r = random.uniform(0, total_w) |
| cumulative = 0.0 |
| idx = n - 1 |
| 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) |
|
|
| |
| 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 |
|
|
| concept_tag = entry.concept_tag |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
|
|
| |
| _engine = DiversityEngine() |
|
|
| def get_diversity_engine() -> DiversityEngine: |
| """Returns the process-level DiversityEngine singleton.""" |
| return _engine |
|
|