.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+
+@dataclass(frozen=True)
+class Quiz:
+ question: str
+ options: tuple[str, ...]
+ answer: int # אינדקס התשובה הנכונה בתוך options
+ explanation: str
+
+
+@dataclass(frozen=True)
+class Exercise:
+ prompt: str
+ hint: str
+ solution: str
+ # מילות מפתח שחייבות להופיע בפתרון כשאין בדיקה חכמה זמינה
+ required: tuple[str, ...] = ()
+ # הפלט המדויק שהקוד אמור להדפיס, אם יש כזה (משמש את /run)
+ expected_output: str | None = None
+
+
+@dataclass(frozen=True)
+class Lesson:
+ id: str
+ title: str
+ emoji: str
+ goal: str
+ body: str
+ example: str
+ exercise: Exercise
+ quizzes: tuple[Quiz, ...] = field(default_factory=tuple)
+
+ @property
+ def display_title(self) -> str:
+ return f"{self.emoji} {self.title}"
+
+
+LESSONS: tuple[Lesson, ...] = (
+ Lesson(
+ id="01-hello",
+ title="שלום Python",
+ emoji="👋",
+ goal="להריץ את שורת הקוד הראשונה ולהדפיס טקסט למסך.",
+ body=(
+ "Python היא שפת תכנות שנקראת כמעט כמו אנגלית. כל תוכנית היא רשימת "
+ "הוראות שהמחשב מבצע לפי הסדר, שורה אחרי שורה.\n\n"
+ "הפקודה הראשונה שכדאי להכיר היא print() - היא מדפיסה "
+ "למסך כל מה שנכתוב בתוך הסוגריים.\n\n"
+ "טקסט בפייתון נקרא מחרוזת (string) ועוטפים אותו במרכאות, "
+ "בודדות או כפולות - שתי הצורות זהות.\n\n"
+ "שורה שמתחילה ב-# היא הערה: פייתון מתעלמת ממנה "
+ "לגמרי, והיא נועדה להסביר את הקוד לבני אדם."
+ ),
+ example=(
+ '# התוכנית הראשונה שלי\n'
+ 'print("שלום עולם!")\n'
+ 'print("אני לומד Python")\n'
+ '\n'
+ '# אפשר גם לחשב בתוך print\n'
+ 'print(7 * 6)\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "כתבו תוכנית שמדפיסה בדיוק שתי שורות:\n"
+ "בשורה הראשונה את השם שלכם, ובשנייה את המספר 2026."
+ ),
+ hint="צריך שתי פקודות print נפרדות. מספרים נכתבים בלי מרכאות.",
+ solution='print("דנה")\nprint(2026)\n',
+ required=("print",),
+ ),
+ quizzes=(
+ Quiz(
+ question="מה תדפיס השורה print(\"3 + 4\")?",
+ options=("7", "3 + 4", "שגיאה", '"3 + 4"'),
+ answer=1,
+ explanation=(
+ "מה שבתוך המרכאות הוא טקסט, ולכן הוא מודפס כמו שהוא. "
+ "בלי המרכאות - print(3 + 4) - היה מודפס 7."
+ ),
+ ),
+ Quiz(
+ question="איזו שורה היא הערה שפייתון מתעלמת ממנה?",
+ options=("// הערה", "# הערה", "", "-- הערה"),
+ answer=1,
+ explanation="בפייתון הערה בשורה אחת מתחילה בסולמית #.",
+ ),
+ ),
+ ),
+ Lesson(
+ id="02-variables",
+ title="משתנים",
+ emoji="📦",
+ goal="לשמור ערכים בזיכרון ולהשתמש בהם שוב.",
+ body=(
+ "משתנה הוא שם שמצביע על ערך. יוצרים אותו עם סימן שווה אחד:\n"
+ "age = 25 קורא לערך 25 בשם age.\n\n"
+ "סימן = בפייתון אינו 'שווה' מתמטי אלא השמה: קח את "
+ "הערך מימין ושמור אותו בשם שמשמאל.\n\n"
+ "כללים לשמות משתנים:\n"
+ "• אותיות, ספרות וקו תחתון בלבד, ולא מתחילים בספרה\n"
+ "• יש הבדל בין אותיות גדולות לקטנות: name ו-Name "
+ "הם שני משתנים שונים\n"
+ "• בחרו שם שמסביר את התוכן: price עדיף על p\n\n"
+ "ערך של משתנה אפשר לשנות בכל רגע - הערך החדש פשוט מחליף את הישן."
+ ),
+ example=(
+ 'name = "דנה"\n'
+ 'age = 25\n'
+ 'height = 1.68\n'
+ '\n'
+ 'print(name, age, height)\n'
+ '\n'
+ '# שינוי ערך קיים\n'
+ 'age = age + 1\n'
+ 'print("בשנה הבאה:", age)\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "צרו משתנה price עם הערך 100 ומשתנה "
+ "discount עם הערך 25.\n"
+ "חשבו את המחיר אחרי ההנחה לתוך משתנה final והדפיסו אותו."
+ ),
+ hint="המחיר הסופי הוא price פחות discount. השתמשו ב-print(final).",
+ solution="price = 100\ndiscount = 25\nfinal = price - discount\nprint(final)\n",
+ required=("price", "discount", "final", "print"),
+ expected_output="75",
+ ),
+ quizzes=(
+ Quiz(
+ question=(
+ "מה יודפס?\nx = 5\nx = 8\nprint(x)
"
+ ),
+ options=("5", "8", "13", "שגיאה"),
+ answer=1,
+ explanation="ההשמה השנייה דורסת את הראשונה, ולכן נשאר הערך 8.",
+ ),
+ Quiz(
+ question="איזה שם משתנה אינו חוקי בפייתון?",
+ options=("total_price", "price2", "2price", "_price"),
+ answer=2,
+ explanation="שם משתנה לא יכול להתחיל בספרה.",
+ ),
+ ),
+ ),
+ Lesson(
+ id="03-types",
+ title="טיפוסי נתונים",
+ emoji="🔢",
+ goal="להכיר מספרים, טקסט ובוליאנים - ולדעת להמיר ביניהם.",
+ body=(
+ "לכל ערך בפייתון יש טיפוס. ארבעת הבסיסיים:\n"
+ "• int - מספר שלם, למשל 42\n"
+ "• float - מספר עשרוני, למשל 3.14\n"
+ "• str - מחרוזת טקסט, למשל \"שלום\"\n"
+ "• bool - אמת או שקר: True / False\n\n"
+ "הפונקציה type(x) מגלה את הטיפוס של כל ערך.\n\n"
+ "הטיפוס קובע מה מותר לעשות: 2 + 3 נותן 5, אבל "
+ "\"2\" + \"3\" נותן \"23\" כי חיבור מחרוזות "
+ "מדביק אותן. \"2\" + 3 פשוט יזרוק שגיאה.\n\n"
+ "לכן ממירים בין טיפוסים: int(\"5\"), "
+ "float(\"2.5\"), str(10)."
+ ),
+ example=(
+ 'count = 10\n'
+ 'price = 19.90\n'
+ 'name = "ספר"\n'
+ 'in_stock = True\n'
+ '\n'
+ 'print(type(count), type(price), type(name), type(in_stock))\n'
+ '\n'
+ '# חיבור מחרוזות מול חיבור מספרים\n'
+ 'print("2" + "3")\n'
+ 'print(2 + 3)\n'
+ '\n'
+ '# המרה\n'
+ 'print(int("2") + 3)\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "נתונה המחרוזת year = \"1991\".\n"
+ "המירו אותה למספר שלם, הוסיפו 30 והדפיסו את התוצאה."
+ ),
+ hint="השתמשו ב-int(year) כדי לקבל מספר.",
+ solution='year = "1991"\nresult = int(year) + 30\nprint(result)\n',
+ required=("int(", "print"),
+ expected_output="2021",
+ ),
+ quizzes=(
+ Quiz(
+ question="מה הטיפוס של הערך 3.0?",
+ options=("int", "float", "str", "bool"),
+ answer=1,
+ explanation="נקודה עשרונית הופכת את המספר ל-float, גם כשהשארית אפס.",
+ ),
+ Quiz(
+ question="מה התוצאה של \"5\" * 3?",
+ options=("15", "555", "שגיאה", "53"),
+ answer=1,
+ explanation=(
+ "כפל מחרוזת במספר שלם משכפל אותה, ולכן מתקבל "
+ "\"555\"."
+ ),
+ ),
+ ),
+ ),
+ Lesson(
+ id="04-input",
+ title="קלט מהמשתמש",
+ emoji="⌨️",
+ goal="לקרוא נתונים מהמשתמש ולהמיר אותם לטיפוס הנכון.",
+ body=(
+ "הפונקציה input() עוצרת את התוכנית, מחכה שהמשתמש יקליד "
+ "שורה, ומחזירה אותה.\n\n"
+ "נקודה קריטית: input() מחזירה תמיד מחרוזת, גם כשהמשתמש "
+ "הקליד מספר. אם צריך לחשב - חייבים להמיר:\n"
+ "age = int(input(\"גיל: \"))\n\n"
+ "אפשר לשלב ערכים בתוך טקסט בעזרת f-string: מוסיפים "
+ "f לפני המרכאות וכותבים שמות משתנים בתוך סוגריים מסולסלים.\n"
+ "print(f\"שלום {name}, אתה בן {age}\")"
+ ),
+ example=(
+ 'name = input("איך קוראים לך? ")\n'
+ 'age = int(input("בן כמה אתה? "))\n'
+ '\n'
+ 'print(f"נעים להכיר, {name}!")\n'
+ 'print(f"בעוד 10 שנים תהיה בן {age + 10}")\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "קראו מהמשתמש שני מספרים שלמים והדפיסו את הסכום שלהם "
+ "במשפט מלא, למשל: הסכום הוא 12."
+ ),
+ hint="שני input נפרדים, כל אחד עטוף ב-int(), ואז f-string.",
+ solution=(
+ 'a = int(input("מספר ראשון: "))\n'
+ 'b = int(input("מספר שני: "))\n'
+ 'print(f"הסכום הוא {a + b}")\n'
+ ),
+ required=("input", "int(", "print"),
+ ),
+ quizzes=(
+ Quiz(
+ question=(
+ "המשתמש הקליד 5. מה יקרה?\n"
+ "x = input()\nprint(x + 1)
"
+ ),
+ options=("יודפס 6", "יודפס 51", "תיזרק שגיאה", "יודפס 5"),
+ answer=2,
+ explanation=(
+ "input מחזירה מחרוזת, וחיבור של מחרוזת עם מספר זורק "
+ "TypeError. הפתרון: int(x) + 1."
+ ),
+ ),
+ Quiz(
+ question="מה מדפיס print(f\"{2 + 2}\")?",
+ options=("2 + 2", "4", "{2 + 2}", "שגיאה"),
+ answer=1,
+ explanation="בתוך f-string הביטוי בסוגריים המסולסלים מחושב לפני ההדפסה.",
+ ),
+ ),
+ ),
+ Lesson(
+ id="05-conditions",
+ title="תנאים",
+ emoji="🔀",
+ goal="לגרום לתוכנית להחליט בין מסלולים שונים.",
+ body=(
+ "if מריץ בלוק קוד רק כשהתנאי נכון. אם לא - אפשר לתת חלופה "
+ "עם elif (תנאי נוסף) ו-else (כל השאר).\n\n"
+ "אופרטורים להשוואה: == שווה, != שונה, "
+ ">, <, >=, <=.\n"
+ "שימו לב: = זו השמה, == זו השוואה.\n\n"
+ "מחברים תנאים עם and, or ו-not.\n\n"
+ "הזחה קובעת מבנה: בפייתון אין סוגריים מסולסלים. מה ששייך ל-if "
+ "מוזח פנימה בארבעה רווחים, ומי שלא מוזח כבר לא חלק מהתנאי."
+ ),
+ example=(
+ 'grade = 85\n'
+ '\n'
+ 'if grade >= 90:\n'
+ ' print("מצוין")\n'
+ 'elif grade >= 70:\n'
+ ' print("טוב")\n'
+ 'else:\n'
+ ' print("צריך לתרגל")\n'
+ '\n'
+ '# שילוב תנאים\n'
+ 'age = 20\n'
+ 'has_ticket = True\n'
+ 'if age >= 18 and has_ticket:\n'
+ ' print("אפשר להיכנס")\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "כתבו תוכנית שמקבלת מספר לתוך משתנה n ומדפיסה "
+ "חיובי, שלילי או אפס בהתאם לערכו."
+ ),
+ hint="שלושה מקרים: if n > 0, elif n < 0, else.",
+ solution=(
+ 'n = 5\n'
+ 'if n > 0:\n'
+ ' print("חיובי")\n'
+ 'elif n < 0:\n'
+ ' print("שלילי")\n'
+ 'else:\n'
+ ' print("אפס")\n'
+ ),
+ required=("if", "elif", "else", "print"),
+ ),
+ quizzes=(
+ Quiz(
+ question=(
+ "מה יודפס?\n"
+ "x = 10\nif x > 5:\n print(\"א\")\nelif x > 8:\n"
+ " print(\"ב\")
"
+ ),
+ options=("א", "ב", "א ואז ב", "כלום"),
+ answer=0,
+ explanation=(
+ "ברגע שתנאי אחד מתקיים, הפייתון מדלגת על כל ה-elif שאחריו - "
+ "גם אם גם הם נכונים."
+ ),
+ ),
+ Quiz(
+ question="מה בודקים עם ==?",
+ options=(
+ "מכניסים ערך למשתנה",
+ "בודקים אם שני ערכים שווים",
+ "בודקים אם ערכים שונים",
+ "מחברים מספרים",
+ ),
+ answer=1,
+ explanation="= משים ערך, == משווה בין ערכים.",
+ ),
+ ),
+ ),
+ Lesson(
+ id="06-loops-while",
+ title="לולאת while",
+ emoji="🔄",
+ goal="לחזור על פעולה כל עוד תנאי מתקיים.",
+ body=(
+ "while מריצה בלוק שוב ושוב כל עוד התנאי נכון. היא מתאימה "
+ "כשלא יודעים מראש כמה חזרות יידרשו.\n\n"
+ "שלושת החלקים שחייבים להיות:\n"
+ "1. אתחול משתנה לפני הלולאה\n"
+ "2. תנאי עצירה\n"
+ "3. עדכון המשתנה בתוך הלולאה - בלעדיו נקבל לולאה אינסופית\n\n"
+ "שתי פקודות שימושיות בתוך לולאה:\n"
+ "• break - יציאה מיידית מהלולאה\n"
+ "• continue - דילוג לסיבוב הבא"
+ ),
+ example=(
+ 'count = 1\n'
+ 'while count <= 5:\n'
+ ' print(count)\n'
+ ' count = count + 1\n'
+ '\n'
+ 'print("סיימתי")\n'
+ '\n'
+ '# עצירה מוקדמת\n'
+ 'n = 0\n'
+ 'while True:\n'
+ ' n += 1\n'
+ ' if n == 3:\n'
+ ' break\n'
+ 'print(n)\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "השתמשו בלולאת while כדי לחשב את סכום המספרים "
+ "מ-1 עד 10 ולהדפיס אותו."
+ ),
+ hint="התחילו עם total = 0 ו-i = 1, והעלו את i בכל סיבוב עד שהוא עובר 10.",
+ solution=(
+ 'total = 0\n'
+ 'i = 1\n'
+ 'while i <= 10:\n'
+ ' total += i\n'
+ ' i += 1\n'
+ 'print(total)\n'
+ ),
+ required=("while", "print"),
+ expected_output="55",
+ ),
+ quizzes=(
+ Quiz(
+ question=(
+ "מה הבעיה בקוד?\ni = 0\nwhile i < 3:\n print(i)
"
+ ),
+ options=(
+ "שגיאת תחביר",
+ "לולאה אינסופית - i לא מתעדכן",
+ "הלולאה לא תרוץ בכלל",
+ "אין בעיה",
+ ),
+ answer=1,
+ explanation="בלי i += 1 התנאי נשאר נכון לנצח.",
+ ),
+ Quiz(
+ question="מה עושה continue בתוך לולאה?",
+ options=(
+ "עוצרת את הלולאה",
+ "מדלגת לסיבוב הבא",
+ "מאתחלת את הלולאה מחדש",
+ "מסיימת את התוכנית",
+ ),
+ answer=1,
+ explanation=(
+ "continue מדלגת על שארית הסיבוב הנוכחי וממשיכה "
+ "לסיבוב הבא, בניגוד ל-break שיוצאת מהלולאה."
+ ),
+ ),
+ ),
+ ),
+ Lesson(
+ id="07-loops-for",
+ title="לולאת for ו-range",
+ emoji="🎯",
+ goal="לעבור על רצף ערכים בצורה קצרה וברורה.",
+ body=(
+ "for עוברת על כל פריט באוסף, אחד אחרי השני, בלי לנהל "
+ "מונה ידנית.\n\n"
+ "range() מייצרת רצף מספרים:\n"
+ "• range(5) - 0,1,2,3,4\n"
+ "• range(2, 6) - 2,3,4,5\n"
+ "• range(0, 10, 2) - 0,2,4,6,8\n\n"
+ "הערך העליון תמיד לא כלול - זו טעות נפוצה למתחילים.\n\n"
+ "אפשר לעבור גם על מחרוזת (אות אחר אות) ועל רשימה (איבר אחר איבר)."
+ ),
+ example=(
+ 'for i in range(5):\n'
+ ' print(i)\n'
+ '\n'
+ 'for letter in "פייתון":\n'
+ ' print(letter)\n'
+ '\n'
+ '# לוח הכפל של 7\n'
+ 'for i in range(1, 11):\n'
+ ' print(f"7 x {i} = {7 * i}")\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "הדפיסו בעזרת for את כל המספרים הזוגיים בין 1 ל-20 "
+ "(כולל 20), כל אחד בשורה נפרדת."
+ ),
+ hint="range(2, 21, 2) מייצר בדיוק את המספרים הזוגיים.",
+ solution="for i in range(2, 21, 2):\n print(i)\n",
+ required=("for", "range", "print"),
+ ),
+ quizzes=(
+ Quiz(
+ question="כמה פעמים ירוץ הבלוק של for i in range(3, 8):?",
+ options=("3", "5", "8", "4"),
+ answer=1,
+ explanation="הרצף הוא 3,4,5,6,7 - חמישה ערכים; 8 אינו כלול.",
+ ),
+ Quiz(
+ question="מה מדפיס print(list(range(0, 10, 3)))?",
+ options=(
+ "[0, 3, 6, 9]",
+ "[0, 3, 6, 9, 12]",
+ "[3, 6, 9]",
+ "[0, 1, 2, ..., 9]",
+ ),
+ answer=0,
+ explanation="הקפיצה היא 3, והרצף נעצר לפני 10.",
+ ),
+ ),
+ ),
+ Lesson(
+ id="08-lists",
+ title="רשימות",
+ emoji="📋",
+ goal="לאחסן אוסף ערכים במשתנה אחד ולעבד אותו.",
+ body=(
+ "רשימה (list) מחזיקה כמה ערכים בסדר קבוע, בתוך סוגריים מרובעים:\n"
+ "fruits = [\"תפוח\", \"בננה\", \"תמר\"]\n\n"
+ "ניגשים לאיבר לפי אינדקס, שמתחיל מ-0:\n"
+ "fruits[0] הוא הראשון, fruits[-1] הוא האחרון.\n\n"
+ "פעולות שימושיות:\n"
+ "• len(fruits) - כמה איברים יש\n"
+ "• fruits.append(\"ענב\") - הוספה בסוף\n"
+ "• fruits.remove(\"בננה\") - הסרה לפי ערך\n"
+ "• fruits.sort() - מיון\n"
+ "• \"תמר\" in fruits - בדיקת הימצאות\n\n"
+ "פרוסות (slicing) מחזירות חלק מהרשימה: fruits[1:3]."
+ ),
+ example=(
+ 'grades = [90, 75, 88, 100, 62]\n'
+ '\n'
+ 'print(grades[0], grades[-1])\n'
+ 'print(len(grades))\n'
+ 'print(sum(grades) / len(grades))\n'
+ '\n'
+ 'grades.append(95)\n'
+ 'grades.sort()\n'
+ 'print(grades)\n'
+ '\n'
+ 'for g in grades:\n'
+ ' if g >= 90:\n'
+ ' print(f"ציון גבוה: {g}")\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "נתונה הרשימה nums = [4, 17, 8, 23, 42, 15].\n"
+ "הדפיסו את הגדול ביותר ואת הממוצע."
+ ),
+ hint="max(nums) מחזיר את הגדול, ו-sum(nums) / len(nums) את הממוצע.",
+ solution=(
+ 'nums = [4, 17, 8, 23, 42, 15]\n'
+ 'print(max(nums))\n'
+ 'print(sum(nums) / len(nums))\n'
+ ),
+ required=("nums", "print"),
+ ),
+ quizzes=(
+ Quiz(
+ question=(
+ "מה יודפס?\nitems = [10, 20, 30]\nprint(items[1])
"
+ ),
+ options=("10", "20", "30", "שגיאה"),
+ answer=1,
+ explanation="אינדקס 0 הוא 10, אינדקס 1 הוא 20 - הספירה מתחילה מאפס.",
+ ),
+ Quiz(
+ question="איזו פעולה מוסיפה איבר לסוף רשימה?",
+ options=("add()", "append()", "insert_end()", "push()"),
+ answer=1,
+ explanation="append() מוסיפה איבר יחיד בסוף הרשימה.",
+ ),
+ ),
+ ),
+ Lesson(
+ id="09-strings",
+ title="עבודה עם מחרוזות",
+ emoji="✂️",
+ goal="לחתוך, לחפש ולעצב טקסט.",
+ body=(
+ "מחרוזת היא רצף תווים, ואפשר לגשת אליה כמו לרשימה: "
+ "text[0], text[-1], text[2:5].\n\n"
+ "מתודות נפוצות:\n"
+ "• .upper() / .lower() - שינוי רישיות\n"
+ "• .strip() - מסיר רווחים מההתחלה ומהסוף\n"
+ "• .split(\",\") - מפצל לרשימה\n"
+ "• .replace(\"א\", \"ב\") - החלפה\n"
+ "• .startswith() / .endswith()\n"
+ "• \",\".join(items) - מחבר רשימה למחרוזת\n\n"
+ "מחרוזות הן בלתי ניתנות לשינוי: כל מתודה מחזירה מחרוזת חדשה "
+ "ולא משנה את המקורית. צריך לשמור את התוצאה במשתנה."
+ ),
+ example=(
+ 'text = " Hello Python World "\n'
+ '\n'
+ 'clean = text.strip()\n'
+ 'print(clean)\n'
+ 'print(clean.upper())\n'
+ 'print(clean.split(" "))\n'
+ 'print(len(clean))\n'
+ '\n'
+ 'email = "dana@example.com"\n'
+ 'user = email.split("@")[0]\n'
+ 'print(user)\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "נתונה המחרוזת s = \"python is fun\".\n"
+ "הדפיסו אותה באותיות גדולות, ואז הדפיסו כמה מילים יש בה."
+ ),
+ hint="upper() לאותיות גדולות, ו-len(s.split()) לספירת המילים.",
+ solution=(
+ 's = "python is fun"\n'
+ 'print(s.upper())\n'
+ 'print(len(s.split()))\n'
+ ),
+ required=("upper", "split", "print"),
+ ),
+ quizzes=(
+ Quiz(
+ question=(
+ "מה יודפס?\ns = \"abc\"\ns.upper()\nprint(s)
"
+ ),
+ options=("ABC", "abc", "שגיאה", "None"),
+ answer=1,
+ explanation=(
+ "מחרוזות אינן ניתנות לשינוי. upper() מחזירה מחרוזת "
+ "חדשה, ובלי השמה התוצאה נזרקת."
+ ),
+ ),
+ Quiz(
+ question="מה מחזיר \"a,b,c\".split(\",\")?",
+ options=(
+ '"abc"',
+ "['a', 'b', 'c']",
+ "['a,b,c']",
+ "שגיאה",
+ ),
+ answer=1,
+ explanation="split מפצל לפי התו שנתנו ומחזיר רשימה.",
+ ),
+ ),
+ ),
+ Lesson(
+ id="10-dicts",
+ title="מילונים",
+ emoji="🗂",
+ goal="לשמור נתונים לפי מפתח במקום לפי מיקום.",
+ body=(
+ "מילון (dict) שומר זוגות של מפתח וערך בתוך סוגריים מסולסלים:\n"
+ "person = {\"name\": \"דנה\", \"age\": 25}\n\n"
+ "ניגשים לפי מפתח ולא לפי אינדקס: person[\"name\"].\n"
+ "גישה למפתח שלא קיים זורקת KeyError, ולכן בטוח יותר "
+ "להשתמש ב-person.get(\"city\", \"לא ידוע\") שמחזיר "
+ "ברירת מחדל.\n\n"
+ "פעולות נפוצות:\n"
+ "• person[\"city\"] = \"חיפה\" - הוספה או עדכון\n"
+ "• del person[\"age\"] - מחיקה\n"
+ "• \"name\" in person - בדיקת קיום מפתח\n"
+ "• person.keys(), .values(), .items()"
+ ),
+ example=(
+ 'student = {"name": "יוסי", "grade": 88, "city": "תל אביב"}\n'
+ '\n'
+ 'print(student["name"])\n'
+ 'print(student.get("phone", "אין טלפון"))\n'
+ '\n'
+ 'student["grade"] = 92\n'
+ 'student["age"] = 17\n'
+ '\n'
+ 'for key, value in student.items():\n'
+ ' print(f"{key}: {value}")\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "צרו מילון prices עם שלושה מוצרים ומחיריהם, "
+ "והדפיסו את סכום כל המחירים."
+ ),
+ hint="sum(prices.values()) מחשב את סכום הערכים.",
+ solution=(
+ 'prices = {"לחם": 8, "חלב": 6, "גבינה": 14}\n'
+ 'print(sum(prices.values()))\n'
+ ),
+ required=("prices", "print"),
+ ),
+ quizzes=(
+ Quiz(
+ question="מה קורה בגישה למפתח שלא קיים, כמו d[\"x\"]?",
+ options=(
+ "מוחזר None",
+ "נזרקת שגיאת KeyError",
+ "המפתח נוצר אוטומטית",
+ "מוחזרת מחרוזת ריקה",
+ ),
+ answer=1,
+ explanation=(
+ "לגישה בטוחה השתמשו ב-.get() שמחזיר ברירת מחדל "
+ "במקום לזרוק שגיאה."
+ ),
+ ),
+ Quiz(
+ question="מה מחזיר .items() של מילון?",
+ options=(
+ "רק את המפתחות",
+ "רק את הערכים",
+ "זוגות של מפתח וערך",
+ "את מספר האיברים",
+ ),
+ answer=2,
+ explanation=".items() מתאים ללולאת for עם שני משתנים.",
+ ),
+ ),
+ ),
+ Lesson(
+ id="11-functions",
+ title="פונקציות",
+ emoji="⚙️",
+ goal="לארוז קוד חוזר בשם אחד ולהשתמש בו שוב ושוב.",
+ body=(
+ "פונקציה היא קטע קוד עם שם, שמקבל קלט ומחזיר פלט. מגדירים "
+ "אותה עם def:\n"
+ "def greet(name):\n return f\"שלום {name}\"\n"
+ "ההגדרה לבדה לא מריצה כלום - צריך לקרוא לפונקציה: "
+ "greet(\"דנה\").\n\n"
+ "return מחזיר ערך ומסיים את הפונקציה מיד. פונקציה בלי "
+ "return מחזירה None.\n\n"
+ "אפשר לתת לפרמטר ערך ברירת מחדל: "
+ "def greet(name, greeting=\"שלום\").\n\n"
+ "משתנה שנוצר בתוך פונקציה הוא מקומי וקיים רק בזמן הריצה שלה."
+ ),
+ example=(
+ 'def add(a, b):\n'
+ ' return a + b\n'
+ '\n'
+ '\n'
+ 'def greet(name, greeting="שלום"):\n'
+ ' return f"{greeting} {name}!"\n'
+ '\n'
+ '\n'
+ 'print(add(3, 4))\n'
+ 'print(greet("דנה"))\n'
+ 'print(greet("יוסי", "בוקר טוב"))\n'
+ '\n'
+ '\n'
+ 'def is_even(n):\n'
+ ' return n % 2 == 0\n'
+ '\n'
+ '\n'
+ 'print(is_even(10))\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "כתבו פונקציה area(width, height) שמחזירה את שטח "
+ "המלבן, וקראו לה עם 5 ו-3 בתוך print."
+ ),
+ hint="def area(width, height): ואז return width * height.",
+ solution=(
+ 'def area(width, height):\n'
+ ' return width * height\n'
+ '\n'
+ '\n'
+ 'print(area(5, 3))\n'
+ ),
+ required=("def", "return", "print"),
+ expected_output="15",
+ ),
+ quizzes=(
+ Quiz(
+ question=(
+ "מה יודפס?\ndef f(x):\n x * 2\n\nprint(f(5))
"
+ ),
+ options=("10", "5", "None", "שגיאה"),
+ answer=2,
+ explanation=(
+ "חסר return, ולכן הפונקציה מחזירה "
+ "None למרות שהחישוב בוצע."
+ ),
+ ),
+ Quiz(
+ question="מה קורה לקוד שכתוב אחרי return בתוך פונקציה?",
+ options=(
+ "הוא רץ כרגיל",
+ "הוא לא רץ - return מסיים את הפונקציה",
+ "נזרקת שגיאה",
+ "הוא רץ רק בתנאי",
+ ),
+ answer=1,
+ explanation="return מחזיר ערך ויוצא מהפונקציה מיד.",
+ ),
+ ),
+ ),
+ Lesson(
+ id="12-errors",
+ title="שגיאות וטיפול בהן",
+ emoji="🛡",
+ goal="להבין הודעות שגיאה ולמנוע קריסה של התוכנית.",
+ body=(
+ "שגיאות הן חלק מהעבודה. שלוש נפוצות:\n"
+ "• SyntaxError - טעות כתיב, למשל נקודתיים חסרות\n"
+ "• NameError - שימוש במשתנה שלא הוגדר\n"
+ "• TypeError - פעולה על טיפוס לא מתאים, כמו "
+ "\"5\" + 1\n"
+ "• ValueError - טיפוס נכון אבל ערך לא חוקי, כמו "
+ "int(\"abc\")\n\n"
+ "קראו את הודעת השגיאה מלמטה למעלה: השורה האחרונה אומרת מה קרה, "
+ "והשורה שמעליה מראה איפה.\n\n"
+ "כדי שהתוכנית לא תקרוס עוטפים קוד מסוכן ב-try / "
+ "except - ותופסים שגיאה ספציפית, לא כל שגיאה שהיא."
+ ),
+ example=(
+ 'text = input("הכניסו מספר: ")\n'
+ '\n'
+ 'try:\n'
+ ' number = int(text)\n'
+ ' print(f"הכפלה: {number * 2}")\n'
+ 'except ValueError:\n'
+ ' print("זה לא מספר תקין")\n'
+ '\n'
+ '\n'
+ '# חלוקה באפס\n'
+ 'try:\n'
+ ' print(10 / 0)\n'
+ 'except ZeroDivisionError:\n'
+ ' print("אי אפשר לחלק באפס")\n'
+ 'finally:\n'
+ ' print("הבלוק הזה תמיד רץ")\n'
+ ),
+ exercise=Exercise(
+ prompt=(
+ "כתבו קוד שמנסה להמיר את המחרוזת \"12a\" למספר שלם, "
+ "ואם זה נכשל מדפיס קלט לא תקין במקום לקרוס."
+ ),
+ hint="try: int(\"12a\") ואז except ValueError:",
+ solution=(
+ 'try:\n'
+ ' number = int("12a")\n'
+ ' print(number)\n'
+ 'except ValueError:\n'
+ ' print("קלט לא תקין")\n'
+ ),
+ required=("try", "except", "print"),
+ expected_output="קלט לא תקין",
+ ),
+ quizzes=(
+ Quiz(
+ question="איזו שגיאה תיזרק מ-int(\"hello\")?",
+ options=("TypeError", "ValueError", "NameError", "SyntaxError"),
+ answer=1,
+ explanation=(
+ "הטיפוס נכון (מחרוזת), אבל הערך לא ניתן להמרה - ולכן "
+ "ValueError."
+ ),
+ ),
+ Quiz(
+ question="מתי רץ הבלוק של finally?",
+ options=(
+ "רק כשיש שגיאה",
+ "רק כשאין שגיאה",
+ "תמיד, עם שגיאה או בלעדיה",
+ "אף פעם",
+ ),
+ answer=2,
+ explanation="finally מתאים לניקוי משאבים, למשל סגירת קובץ.",
+ ),
+ ),
+ ),
+)
+
+LESSONS_BY_ID: dict[str, Lesson] = {lesson.id: lesson for lesson in LESSONS}
+
+
+def find_lesson(lesson_id: str) -> Lesson | None:
+ return LESSONS_BY_ID.get(lesson_id)
+
+
+def lesson_index(lesson_id: str) -> int:
+ """מיקום השיעור ברשימה, או -1 אם אינו קיים."""
+ for index, lesson in enumerate(LESSONS):
+ if lesson.id == lesson_id:
+ return index
+ return -1
+
+
+def next_lesson(lesson_id: str) -> Lesson | None:
+ index = lesson_index(lesson_id)
+ if index == -1 or index + 1 >= len(LESSONS):
+ return None
+ return LESSONS[index + 1]
diff --git a/app/handlers.py b/app/handlers.py
new file mode 100644
index 0000000..42093c6
--- /dev/null
+++ b/app/handlers.py
@@ -0,0 +1,731 @@
+"""הפקודות, הכפתורים וזרימת השיחה של הבוט."""
+
+from __future__ import annotations
+
+import html
+import logging
+import re
+
+from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
+from telegram.constants import ChatAction, ParseMode
+from telegram.error import BadRequest
+from telegram.ext import (
+ Application,
+ CallbackQueryHandler,
+ CommandHandler,
+ ContextTypes,
+ MessageHandler,
+ filters,
+)
+
+from app import keyboards as kb
+from app.ai import AIUnavailable, Grade, Tutor
+from app.content import LESSONS, Lesson, find_lesson, lesson_index, next_lesson
+from app.runner import run_code
+from app.storage import Storage
+
+logger = logging.getLogger(__name__)
+
+TELEGRAM_LIMIT = 3900 # מתחת למגבלת 4096 של טלגרם, עם מרווח ביטחון
+AWAITING_KEY = "awaiting_exercise"
+CURRENT_KEY = "current_lesson"
+
+
+# --------------------------------------------------------------------- utils
+def _storage(context: ContextTypes.DEFAULT_TYPE) -> Storage:
+ return context.application.bot_data["storage"]
+
+
+def _tutor(context: ContextTypes.DEFAULT_TYPE) -> Tutor:
+ return context.application.bot_data["tutor"]
+
+
+def _code_runner_enabled(context: ContextTypes.DEFAULT_TYPE) -> bool:
+ return bool(context.application.bot_data.get("enable_code_runner"))
+
+
+def _code_block(code: str) -> str:
+ return f"{html.escape(code)}
"
+
+
+def _chunks(text: str, limit: int = TELEGRAM_LIMIT) -> list[str]:
+ """מפצל טקסט ארוך להודעות, עדיף בגבול שורה."""
+ if len(text) <= limit:
+ return [text]
+ parts: list[str] = []
+ remaining = text
+ while len(remaining) > limit:
+ cut = remaining.rfind("\n", 0, limit)
+ if cut <= 0:
+ cut = limit
+ parts.append(remaining[:cut])
+ remaining = remaining[cut:].lstrip("\n")
+ if remaining:
+ parts.append(remaining)
+ return parts
+
+
+async def _reply(update: Update, text: str, **kwargs) -> None:
+ """שולח הודעה, ומפצל אותה אם היא ארוכה מדי."""
+ message = update.effective_message
+ if message is None:
+ return
+ parts = _chunks(text)
+ for index, part in enumerate(parts):
+ is_last = index == len(parts) - 1
+ await message.reply_html(part, **(kwargs if is_last else {}))
+
+
+async def _edit_or_send(update: Update, text: str, **kwargs) -> None:
+ """מעדכן את ההודעה של הכפתור, ואם אי אפשר - שולח חדשה."""
+ query = update.callback_query
+ parts = _chunks(text)
+ if query is not None and len(parts) == 1:
+ try:
+ await query.edit_message_text(
+ text, parse_mode=ParseMode.HTML, **kwargs
+ )
+ return
+ except BadRequest as exc:
+ if "not modified" in str(exc).lower():
+ return
+ logger.debug("עריכת ההודעה נכשלה, שולח חדשה: %s", exc)
+ await _reply(update, text, **kwargs)
+
+
+async def _track(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ user = update.effective_user
+ if user is None:
+ return 0
+ return await _storage(context).touch_user(user.id, user.first_name, user.username)
+
+
+# ------------------------------------------------------------------ rendering
+def _lesson_text(lesson: Lesson) -> str:
+ position = lesson_index(lesson.id) + 1
+ return (
+ f"{lesson.display_title} ({position}/{len(LESSONS)})\n"
+ f"🎯 {lesson.goal}\n\n"
+ f"{lesson.body}"
+ )
+
+
+def _example_text(lesson: Lesson) -> str:
+ return (
+ f"💡 דוגמה - {lesson.title}\n\n"
+ f"{_code_block(lesson.example)}\n"
+ "נסו להעתיק את הקוד ולשנות אותו. ככה לומדים הכי מהר."
+ )
+
+
+def _exercise_text(lesson: Lesson, can_run: bool) -> str:
+ tail = (
+ "שלחו לי את הקוד שלכם בהודעה רגילה ואבדוק אותו."
+ if not can_run
+ else "שלחו לי את הקוד שלכם בהודעה רגילה - אריץ אותו ואבדוק את התוצאה."
+ )
+ return (
+ f"✏️ תרגיל - {lesson.title}\n\n"
+ f"{lesson.exercise.prompt}\n\n"
+ f"{tail}"
+ )
+
+
+def _quiz_text(lesson: Lesson, quiz_index: int) -> str:
+ quiz = lesson.quizzes[quiz_index]
+ return (
+ f"🧠 חידון - {lesson.title}\n"
+ f"שאלה {quiz_index + 1} מתוך {len(lesson.quizzes)}\n\n"
+ f"{quiz.question}"
+ )
+
+
+# ------------------------------------------------------------------- commands
+async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ streak = await _track(update, context)
+ user = update.effective_user
+ name = html.escape(user.first_name or "חבר") if user else "חבר"
+ completed = await _storage(context).completed_lessons(user.id) if user else set()
+
+ text = (
+ f"שלום {name}! 🐍\n\n"
+ "אני בוט שמלמד Python מאפס. יש כאן "
+ f"{len(LESSONS)} שיעורים קצרים, ובכל אחד הסבר, דוגמת קוד, תרגיל וחידון.\n\n"
+ "איך מתחילים:\n"
+ "• /lessons - תפריט כל השיעורים\n"
+ "• /next - להמשיך מאיפה שהפסקתם\n"
+ "• /ask שאלה - לשאול אותי כל דבר על Python\n"
+ "• /progress - לראות את ההתקדמות\n"
+ "• /help - כל הפקודות\n\n"
+ f"🔥 רצף למידה: {streak} ימים"
+ )
+ await _reply(update, text, reply_markup=kb.menu_keyboard(completed))
+
+
+async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ ai_line = (
+ "• /ask <שאלה> - שאלה חופשית על Python\n"
+ if _tutor(context).enabled
+ else "• /ask - כבוי כרגע (דורש מפתח ANTHROPIC_API_KEY)\n"
+ )
+ run_line = (
+ "• /run <קוד> - הרצת קוד ובדיקת הפלט\n"
+ if _code_runner_enabled(context)
+ else ""
+ )
+ await _reply(
+ update,
+ "הפקודות שלי\n\n"
+ "• /lessons - תפריט השיעורים\n"
+ "• /lesson <מספר> - מעבר ישיר לשיעור, למשל /lesson 5\n"
+ "• /next - השיעור הבא שלא השלמתם\n"
+ "• /quiz - חידון על השיעור הנוכחי\n"
+ "• /practice - התרגיל של השיעור הנוכחי\n"
+ f"{ai_line}"
+ f"{run_line}"
+ "• /progress - סטטיסטיקות והתקדמות\n"
+ "• /cancel - ביטול תרגיל פתוח\n"
+ "• /reset - איפוס כל ההתקדמות\n\n"
+ "טיפ: אפשר פשוט לשלוח לי שאלה בהודעה רגילה.",
+ )
+
+
+async def lessons_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ user = update.effective_user
+ completed = await _storage(context).completed_lessons(user.id) if user else set()
+ await _reply(
+ update,
+ f"📚 תוכנית הלימודים\n\nהושלמו {len(completed)} מתוך {len(LESSONS)} שיעורים.\nבחרו שיעור:",
+ reply_markup=kb.menu_keyboard(completed),
+ )
+
+
+async def lesson_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ if not context.args:
+ await lessons_menu(update, context)
+ return
+
+ raw = context.args[0].strip()
+ if not raw.isdigit():
+ await _reply(update, "צריך מספר שיעור, למשל /lesson 3.")
+ return
+
+ number = int(raw)
+ if not 1 <= number <= len(LESSONS):
+ await _reply(update, f"יש שיעורים מ-1 עד {len(LESSONS)}.")
+ return
+
+ await _show_lesson(update, context, LESSONS[number - 1])
+
+
+async def next_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ user = update.effective_user
+ completed = await _storage(context).completed_lessons(user.id) if user else set()
+
+ target = next((lesson for lesson in LESSONS if lesson.id not in completed), None)
+ if target is None:
+ await _reply(
+ update,
+ "🎉 סיימתם את כל השיעורים! אפשר לחזור על שיעור מ-/lessons "
+ "או לשאול אותי שאלות עם /ask.",
+ )
+ return
+ await _show_lesson(update, context, target)
+
+
+async def quiz_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ lesson = _current_lesson(context)
+ if lesson is None:
+ await _reply(update, "קודם בחרו שיעור דרך /lessons.")
+ return
+ await _show_quiz(update, context, lesson, 0)
+
+
+async def practice_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ lesson = _current_lesson(context)
+ if lesson is None:
+ await _reply(update, "קודם בחרו שיעור דרך /lessons.")
+ return
+ await _show_exercise(update, context, lesson)
+
+
+async def progress_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ user = update.effective_user
+ if user is None:
+ return
+
+ storage = _storage(context)
+ stats = await storage.stats(user.id)
+ completed = await storage.completed_lessons(user.id)
+
+ done = len(completed)
+ total = len(LESSONS)
+ filled = round(10 * done / total) if total else 0
+ bar = "▓" * filled + "░" * (10 - filled)
+
+ lines = [
+ "📊 ההתקדמות שלך\n",
+ f"{bar} {done}/{total} שיעורים",
+ f"🧠 חידונים: {stats.quiz_correct}/{stats.quiz_total} נכון ({stats.quiz_accuracy}%)",
+ f"✏️ תרגילים שנפתרו: {stats.exercises_passed}",
+ f"🔥 רצף: {stats.streak} ימים",
+ ]
+
+ upcoming = next((lesson for lesson in LESSONS if lesson.id not in completed), None)
+ if upcoming:
+ lines.append(f"\nהבא בתור: {upcoming.display_title} (/next)")
+ else:
+ lines.append("\nסיימתם את כל התוכנית! 🎉")
+
+ await _reply(update, "\n".join(lines))
+
+
+async def ask_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ question = " ".join(context.args).strip() if context.args else ""
+ if not question:
+ await _reply(
+ update,
+ "כתבו את השאלה אחרי הפקודה, למשל:\n"
+ "/ask מה ההבדל בין רשימה למילון?",
+ )
+ return
+ await _answer_question(update, context, question)
+
+
+async def run_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ if not _code_runner_enabled(context):
+ await _reply(
+ update,
+ "הרצת קוד כבויה בשרת הזה. אפשר להפעיל אותה עם "
+ "ENABLE_CODE_RUNNER=true (ראו README).",
+ )
+ return
+
+ code = update.effective_message.text.partition(" ")[2].strip()
+ code = _strip_fences(code)
+ if not code:
+ await _reply(
+ update,
+ "שלחו קוד אחרי הפקודה, למשל:\n/run print(2 + 2)",
+ )
+ return
+ await _run_and_report(update, code)
+
+
+async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ if context.user_data.pop(AWAITING_KEY, None):
+ await _reply(update, "ביטלתי את התרגיל. אפשר להמשיך עם /next או /lessons.")
+ else:
+ await _reply(update, "אין תרגיל פתוח כרגע.")
+
+
+async def reset_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await _track(update, context)
+ keyboard = InlineKeyboardMarkup(
+ [
+ [
+ InlineKeyboardButton("כן, אפס הכול", callback_data="reset:yes"),
+ InlineKeyboardButton("לא, בטל", callback_data="reset:no"),
+ ]
+ ]
+ )
+ await _reply(
+ update,
+ "לאפס את כל ההתקדמות, הציונים והרצף? אי אפשר לבטל את הפעולה.",
+ reply_markup=keyboard,
+ )
+
+
+# -------------------------------------------------------------------- display
+def _current_lesson(context: ContextTypes.DEFAULT_TYPE) -> Lesson | None:
+ lesson_id = context.user_data.get(CURRENT_KEY)
+ return find_lesson(lesson_id) if lesson_id else None
+
+
+async def _show_lesson(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, lesson: Lesson
+) -> None:
+ context.user_data[CURRENT_KEY] = lesson.id
+ context.user_data.pop(AWAITING_KEY, None)
+ await _edit_or_send(
+ update, _lesson_text(lesson), reply_markup=kb.lesson_keyboard(lesson)
+ )
+
+
+async def _show_example(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, lesson: Lesson
+) -> None:
+ context.user_data[CURRENT_KEY] = lesson.id
+ await _edit_or_send(
+ update, _example_text(lesson), reply_markup=kb.example_keyboard(lesson)
+ )
+
+
+async def _show_exercise(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, lesson: Lesson
+) -> None:
+ context.user_data[CURRENT_KEY] = lesson.id
+ context.user_data[AWAITING_KEY] = lesson.id
+ await _edit_or_send(
+ update,
+ _exercise_text(lesson, _code_runner_enabled(context)),
+ reply_markup=kb.exercise_keyboard(lesson),
+ )
+
+
+async def _show_quiz(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, lesson: Lesson, index: int
+) -> None:
+ context.user_data[CURRENT_KEY] = lesson.id
+ context.user_data.pop(AWAITING_KEY, None)
+ if not lesson.quizzes:
+ await _edit_or_send(update, "אין חידון לשיעור הזה.")
+ return
+ index = max(0, min(index, len(lesson.quizzes) - 1))
+ await _edit_or_send(
+ update, _quiz_text(lesson, index), reply_markup=kb.quiz_keyboard(lesson, index)
+ )
+
+
+# ------------------------------------------------------------------- callbacks
+async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ query = update.callback_query
+ if query is None or not query.data:
+ return
+ await query.answer()
+ await _track(update, context)
+
+ action, _, rest = query.data.partition(":")
+ parts = rest.split(":") if rest else []
+ lesson_id = parts[0] if parts else ""
+ lesson = find_lesson(lesson_id)
+
+ if action == kb.CB_MENU:
+ user = update.effective_user
+ completed = (
+ await _storage(context).completed_lessons(user.id) if user else set()
+ )
+ await _edit_or_send(
+ update,
+ f"📚 תוכנית הלימודים\n\nהושלמו {len(completed)} מתוך "
+ f"{len(LESSONS)} שיעורים.\nבחרו שיעור:",
+ reply_markup=kb.menu_keyboard(completed),
+ )
+ return
+
+ if action == "reset":
+ user = update.effective_user
+ if lesson_id == "yes" and user:
+ await _storage(context).reset(user.id)
+ context.user_data.clear()
+ await _edit_or_send(update, "ההתקדמות אופסה. אפשר להתחיל מחדש עם /next 🙂")
+ else:
+ await _edit_or_send(update, "ביטלתי - ההתקדמות נשמרה.")
+ return
+
+ if lesson is None:
+ await _edit_or_send(update, "לא מצאתי את השיעור הזה. נסו /lessons.")
+ return
+
+ if action == kb.CB_LESSON:
+ await _show_lesson(update, context, lesson)
+ elif action == kb.CB_EXAMPLE:
+ await _show_example(update, context, lesson)
+ elif action == kb.CB_EXERCISE:
+ await _show_exercise(update, context, lesson)
+ elif action == kb.CB_HINT:
+ await _reply(update, f"🔦 רמז: {lesson.exercise.hint}")
+ elif action == kb.CB_SOLUTION:
+ await _reply(
+ update,
+ f"🔑 פתרון אפשרי\n\n{_code_block(lesson.exercise.solution)}\n"
+ "יש עוד דרכים נכונות - העיקר שהתוצאה נכונה.",
+ )
+ elif action == kb.CB_QUIZ:
+ index = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
+ await _show_quiz(update, context, lesson, index)
+ elif action == kb.CB_ANSWER:
+ await _handle_quiz_answer(update, context, lesson, parts)
+ elif action == kb.CB_NEXT:
+ upcoming = next_lesson(lesson.id)
+ if upcoming is None:
+ await _edit_or_send(update, "זה היה השיעור האחרון 🎉 כל הכבוד!")
+ else:
+ await _show_lesson(update, context, upcoming)
+
+
+async def _handle_quiz_answer(
+ update: Update,
+ context: ContextTypes.DEFAULT_TYPE,
+ lesson: Lesson,
+ parts: list[str],
+) -> None:
+ if len(parts) < 3 or not parts[1].isdigit() or not parts[2].isdigit():
+ return
+ quiz_index, chosen = int(parts[1]), int(parts[2])
+ if quiz_index >= len(lesson.quizzes):
+ return
+
+ quiz = lesson.quizzes[quiz_index]
+ correct = chosen == quiz.answer
+ user = update.effective_user
+ if user:
+ await _storage(context).record_quiz_answer(
+ user.id, lesson.id, quiz_index, correct
+ )
+
+ head = "✅ נכון!" if correct else "❌ לא מדויק"
+ body = (
+ f"{head}\n\n{quiz.question}\n\n"
+ f"התשובה הנכונה: {quiz.options[quiz.answer]}\n\n"
+ f"{quiz.explanation}"
+ )
+
+ is_last = quiz_index + 1 >= len(lesson.quizzes)
+ if is_last and user:
+ await _storage(context).complete_lesson(user.id, lesson.id)
+ body += "\n\n🎉 סיימתם את השיעור! הוא סומן כהושלם."
+
+ await _edit_or_send(
+ update, body, reply_markup=kb.after_quiz_keyboard(lesson, quiz_index)
+ )
+
+
+# ---------------------------------------------------------------- free messages
+async def on_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """הודעה חופשית: פתרון תרגיל אם יש תרגיל פתוח, אחרת שאלה למורה."""
+ await _track(update, context)
+ message = update.effective_message
+ if message is None or not message.text:
+ return
+ text = message.text.strip()
+
+ lesson_id = context.user_data.get(AWAITING_KEY)
+ lesson = find_lesson(lesson_id) if lesson_id else None
+ if lesson is not None:
+ await _check_submission(update, context, lesson, _strip_fences(text))
+ return
+
+ if _looks_like_code(text):
+ await _reply(
+ update,
+ "נראה שזה קוד 🙂 כדי שאבדוק אותו פתחו קודם תרגיל עם /practice, "
+ "או שאלו אותי שאלה במילים.",
+ )
+ return
+
+ await _answer_question(update, context, text)
+
+
+async def _answer_question(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, question: str
+) -> None:
+ tutor = _tutor(context)
+ if not tutor.enabled:
+ await _reply(
+ update,
+ "שאלות חופשיות דורשות מפתח ANTHROPIC_API_KEY בשרת. "
+ "בינתיים אפשר ללמוד דרך /lessons - כל החומר שם זמין.",
+ )
+ return
+
+ chat = update.effective_chat
+ if chat:
+ await context.bot.send_chat_action(chat.id, ChatAction.TYPING)
+
+ lesson = _current_lesson(context)
+ try:
+ answer = await tutor.answer(question, lesson.title if lesson else None)
+ except AIUnavailable:
+ await _reply(update, "התכונה הזו אינה זמינה כרגע.")
+ return
+ except Exception: # noqa: BLE001 - לא מפילים את הבוט בגלל שגיאת רשת
+ logger.exception("קריאה למודל נכשלה")
+ await _reply(update, "לא הצלחתי להגיע למורה החכם כרגע. נסו שוב בעוד רגע.")
+ return
+
+ await _reply(update, _markdown_code_to_html(answer))
+
+
+async def _check_submission(
+ update: Update, context: ContextTypes.DEFAULT_TYPE, lesson: Lesson, code: str
+) -> None:
+ chat = update.effective_chat
+ if chat:
+ await context.bot.send_chat_action(chat.id, ChatAction.TYPING)
+
+ run_note = ""
+ if _code_runner_enabled(context):
+ result = await run_code(code)
+ if not result.ok:
+ await _reply(
+ update,
+ f"❌ הקוד לא רץ בהצלחה:\n{_code_block(result.combined)}\n"
+ "תקנו ושלחו שוב, או בקשו 🔦 רמז.",
+ )
+ return
+ run_note = f"\n\nהפלט שקיבלתי:\n{_code_block(result.output or '(ריק)')}"
+ expected = lesson.exercise.expected_output
+ if expected is not None and result.output.strip() != expected.strip():
+ if update.effective_user:
+ await _storage(context).record_exercise(
+ update.effective_user.id, lesson.id, False
+ )
+ await _reply(
+ update,
+ f"כמעט! ציפיתי לפלט {html.escape(expected)}."
+ f"{run_note}\n\nנסו שוב 💪",
+ )
+ return
+
+ grade = await _grade(context, lesson, code)
+ user = update.effective_user
+ if user:
+ await _storage(context).record_exercise(user.id, lesson.id, grade.passed)
+
+ if grade.passed:
+ context.user_data.pop(AWAITING_KEY, None)
+ await _reply(
+ update,
+ f"✅ יפה מאוד! {grade.feedback}{run_note}",
+ reply_markup=kb.exercise_keyboard(lesson),
+ )
+ else:
+ await _reply(
+ update,
+ f"🔍 {grade.feedback}{run_note}\n\nנסו שוב, או לחצו 🔦 רמז.",
+ reply_markup=kb.exercise_keyboard(lesson),
+ )
+
+
+async def _grade(
+ context: ContextTypes.DEFAULT_TYPE, lesson: Lesson, code: str
+) -> Grade:
+ """בדיקה חכמה עם Claude, ואם אינה זמינה - בדיקה היוריסטית."""
+ tutor = _tutor(context)
+ if tutor.enabled:
+ try:
+ return await tutor.grade(
+ lesson.exercise.prompt, lesson.exercise.solution, code
+ )
+ except AIUnavailable:
+ pass
+ except Exception: # noqa: BLE001
+ logger.exception("בדיקת תרגיל נכשלה, עובר לבדיקה היוריסטית")
+ return heuristic_grade(lesson, code)
+
+
+def heuristic_grade(lesson: Lesson, code: str) -> Grade:
+ """בדיקה פשוטה: תחביר תקין ומילות המפתח הנדרשות."""
+ if not code.strip():
+ return Grade(False, "לא קיבלתי קוד.")
+ try:
+ compile(code, "", "exec")
+ except SyntaxError as exc:
+ return Grade(False, f"יש שגיאת תחביר בשורה {exc.lineno}: {exc.msg}")
+
+ normalized = code.replace(" ", "")
+ missing = [
+ token
+ for token in lesson.exercise.required
+ if token.replace(" ", "") not in normalized
+ ]
+ if missing:
+ return Grade(False, f"חסר בקוד: {', '.join(missing)}")
+ return Grade(True, "הקוד נראה נכון.")
+
+
+async def _run_and_report(update: Update, code: str) -> None:
+ result = await run_code(code)
+ if result.ok:
+ await _reply(
+ update, f"▶️ פלט:\n{_code_block(result.output or '(אין פלט)')}"
+ )
+ else:
+ await _reply(update, f"❌ שגיאה:\n{_code_block(result.combined)}")
+
+
+# ------------------------------------------------------------------- helpers
+def _strip_fences(text: str) -> str:
+ """מסיר עטיפת ```python``` שמשתמשים נוטים להדביק."""
+ stripped = text.strip()
+ if not stripped.startswith("```"):
+ return stripped
+ lines = stripped.splitlines()
+ if lines and lines[0].startswith("```"):
+ lines = lines[1:]
+ if lines and lines[-1].strip() == "```":
+ lines = lines[:-1]
+ return "\n".join(lines).strip()
+
+
+def _looks_like_code(text: str) -> bool:
+ markers = ("print(", "def ", "import ", "for ", "while ", "if ", "=")
+ return text.startswith("```") or (
+ "\n" in text and any(marker in text for marker in markers)
+ )
+
+
+def _markdown_code_to_html(text: str) -> str:
+ """ממיר תשובת מודל (markdown) ל-HTML שטלגרם מקבל."""
+ out: list[str] = []
+ in_code = False
+ for line in text.splitlines():
+ if line.strip().startswith("```"):
+ out.append("" if not in_code else "
")
+ in_code = not in_code
+ continue
+ out.append(html.escape(line) if in_code else _inline_markdown(line))
+ if in_code:
+ out.append("