From f889d84dbb9ba2fb05707b967f8771dffd2e0bee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:04:22 +0000 Subject: [PATCH] =?UTF-8?q?=D7=91=D7=95=D7=98=20=D7=98=D7=9C=D7=92=D7=A8?= =?UTF-8?q?=D7=9D=20=D7=9C=D7=9C=D7=99=D7=9E=D7=95=D7=93=20Python,=20?= =?UTF-8?q?=D7=9E=D7=95=D7=9B=D7=9F=20=D7=9C=D7=A4=D7=A8=D7=99=D7=A1=D7=94?= =?UTF-8?q?=20=D7=91=D7=A8=D7=A0=D7=93=D7=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit בוט בעברית שמלמד Python מאפס: 12 שיעורים עם הסבר, דוגמת קוד, תרגיל וחידון, כולל מעקב התקדמות לכל משתמש. - main.py בוחר webhook או polling לפי RENDER_EXTERNAL_URL, כך שאותו קוד רץ ברנדר ובפיתוח מקומי בלי שינוי - render.yaml מגדיר את השירות כ-Blueprint לפריסה בלחיצה אחת - התקדמות, ציוני חידונים ורצף יומי נשמרים ב-SQLite, מחוץ ללולאת האירועים - מורה חכם מבוסס Claude ל-/ask ולבדיקת תרגילים, אופציונלי; בלי מפתח API הבוט נופל בעדינות לבדיקה היוריסטית וכל החומר נשאר זמין - הרצת קוד תרגילים בתת-תהליך עם מגבלות CPU, זיכרון וכתיבה, כבויה כברירת מחדל - 127 בדיקות: קומפילציה של כל דוגמאות הקוד, התאמת פלט הפתרונות, תקינות ה-HTML של כל הודעה שנשלחת, וכיסוי לכל כפתור במקלדות Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Bf46SdpBT3AF138X9i5Uo --- .env.example | 17 + .gitignore | 9 + Procfile | 1 + README.md | 126 ++++++ app/__init__.py | 3 + app/ai.py | 189 +++++++++ app/config.py | 72 ++++ app/content/__init__.py | 23 ++ app/content/lessons.py | 857 ++++++++++++++++++++++++++++++++++++++++ app/handlers.py | 731 ++++++++++++++++++++++++++++++++++ app/keyboards.py | 130 ++++++ app/runner.py | 135 +++++++ app/storage.py | 251 ++++++++++++ main.py | 100 +++++ pytest.ini | 5 + render.yaml | 30 ++ requirements.txt | 3 + tests/__init__.py | 0 tests/test_content.py | 83 ++++ tests/test_flow.py | 274 +++++++++++++ tests/test_helpers.py | 102 +++++ tests/test_runner.py | 49 +++ tests/test_storage.py | 69 ++++ tests/test_wiring.py | 123 ++++++ 24 files changed, 3382 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Procfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/ai.py create mode 100644 app/config.py create mode 100644 app/content/__init__.py create mode 100644 app/content/lessons.py create mode 100644 app/handlers.py create mode 100644 app/keyboards.py create mode 100644 app/runner.py create mode 100644 app/storage.py create mode 100644 main.py create mode 100644 pytest.ini create mode 100644 render.yaml create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_content.py create mode 100644 tests/test_flow.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_runner.py create mode 100644 tests/test_storage.py create mode 100644 tests/test_wiring.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f52227f --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# חובה - הטוקן מ-@BotFather בטלגרם +TELEGRAM_BOT_TOKEN=123456:ABC-DEF_your_token_here + +# אופציונלי - מפעיל את /ask (שאלות חופשיות) ובדיקת תרגילים חכמה +ANTHROPIC_API_KEY=sk-ant-... +CLAUDE_MODEL=claude-opus-5 + +# אופציונלי - ברנדר מוגדר אוטומטית מ-RENDER_EXTERNAL_URL +# WEBHOOK_URL=https://my-bot.onrender.com +WEBHOOK_SECRET=change-me-to-a-random-string +PORT=10000 + +# היכן נשמר מסד הנתונים (ברנדר: /var/data אם חיברת Disk) +DATA_DIR=./data + +# הרצת קוד של משתמשים (/run) - כבוי כברירת מחדל, ראה README +ENABLE_CODE_RUNNER=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20a02cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +*.db +data/ +.pytest_cache/ +.DS_Store diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..629b83a --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: python main.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..c820d5b --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# 🐍 בוט טלגרם שמלמד Python + +בוט טלגרם בעברית שמלמד Python מאפס: 12 שיעורים קצרים, ובכל אחד הסבר, דוגמת קוד, +תרגיל מעשי וחידון. הבוט זוכר את ההתקדמות של כל תלמיד, ומוכן לפריסה ב-Render. + +## מה יש בבוט + +| יכולת | תיאור | +|---|---| +| 📚 12 שיעורים | מ-`print` ועד פונקציות וטיפול בשגיאות, עם ניווט בכפתורים | +| ✏️ תרגילים | התלמיד שולח קוד בהודעה, והבוט בודק ומחזיר משוב ממוקד | +| 🧠 חידונים | שאלות רב-ברירה עם הסבר לכל תשובה, גם כשטועים | +| 📊 מעקב התקדמות | שיעורים שהושלמו, אחוזי הצלחה בחידונים ורצף למידה יומי | +| 🤖 מורה חכם (אופציונלי) | `/ask` לשאלות חופשיות ובדיקת תרגילים חכמה, מבוסס Claude | +| ▶️ הרצת קוד (אופציונלי) | הרצת קוד התלמיד בתת-תהליך מוגבל והשוואת הפלט | + +הבוט עובד במלואו גם בלי מפתח Claude - כל החומר, התרגילים והחידונים מקומיים. +המפתח רק מוסיף את היכולת לענות על שאלות חופשיות. + +## הפקודות + +``` +/start התחלה ותפריט ראשי +/lessons תפריט כל השיעורים +/lesson 5 מעבר ישיר לשיעור מספר 5 +/next השיעור הבא שטרם הושלם +/practice התרגיל של השיעור הנוכחי +/quiz החידון של השיעור הנוכחי +/ask ... שאלה חופשית על Python +/run ... הרצת קוד (אם הופעלה) +/progress ההתקדמות שלי +/cancel ביטול תרגיל פתוח +/reset איפוס ההתקדמות +``` + +## פריסה ב-Render + +### 1. יוצרים בוט בטלגרם + +פותחים צ'אט עם [@BotFather](https://t.me/BotFather), שולחים `/newbot`, בוחרים שם +ושם משתמש, ומעתיקים את הטוקן שמתקבל. + +### 2. פורסים את הקוד + +הדרך הקצרה - **Blueprint**: ב-Render בוחרים `New` → `Blueprint`, מצביעים על +הריפו הזה, ו-`render.yaml` מגדיר את השירות אוטומטית. נותר רק למלא את +`TELEGRAM_BOT_TOKEN`. + +הדרך הידנית - **Web Service**: + +| הגדרה | ערך | +|---|---| +| Environment | Python 3 | +| Build Command | `pip install -r requirements.txt` | +| Start Command | `python main.py` | + +### 3. מגדירים משתני סביבה + +| משתנה | חובה | תיאור | +|---|---|---| +| `TELEGRAM_BOT_TOKEN` | ✅ | הטוקן מ-BotFather | +| `ANTHROPIC_API_KEY` | ❌ | מפעיל את `/ask` ובדיקת תרגילים חכמה | +| `CLAUDE_MODEL` | ❌ | ברירת מחדל: `claude-opus-5` | +| `WEBHOOK_SECRET` | ❌ | מחרוזת אקראית שמאמתת שהעדכון הגיע מטלגרם | +| `DATA_DIR` | ❌ | תיקיית מסד הנתונים, ברירת מחדל `./data` | +| `ENABLE_CODE_RUNNER` | ❌ | `true` מפעיל את `/run` ובדיקת פלט | +| `WEBHOOK_URL` | ❌ | רק אם אתם לא ב-Render; שם זה נגזר מ-`RENDER_EXTERNAL_URL` | + +אין צורך לרשום את ה-webhook ידנית: הבוט מזהה את כתובת השירות שרנדר מזריקה +ב-`RENDER_EXTERNAL_URL`, נרשם מול טלגרם בעלייה ומאזין על `PORT`. בלי +`WEBHOOK_URL` הוא עובר אוטומטית ל-polling, מה שנוח לפיתוח מקומי. + +### שתי מגבלות של התוכנית החינמית + +1. **השירות נרדם** אחרי כ-15 דקות חוסר פעילות. ההודעה הראשונה אחרי שינה + תתקבל באיחור של עשרות שניות, כי היא זו שמעירה את השירות. +2. **אין דיסק קבוע**, ולכן מסד הנתונים נמחק בכל דיפלוי. לשמירת התקדמות לאורך זמן: + מוסיפים Disk בתוכנית בתשלום, ממפים אותו ל-`/var/data` ומגדירים + `DATA_DIR=/var/data`. + +## הרצה מקומית + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt + +cp .env.example .env # ואז ממלאים את TELEGRAM_BOT_TOKEN +python main.py # עולה במצב polling +``` + +## בדיקות + +```bash +pip install pytest pytest-asyncio +python -m pytest +``` + +הבדיקות מאמתות שכל דוגמאות הקוד והפתרונות מתקמפלים, שהפתרונות מייצרים את הפלט +המצופה, שכל הודעה שהבוט שולח היא HTML תקין שטלגרם מקבל, ושכל כפתור מוביל לפעולה +קיימת. + +## מבנה הפרויקט + +``` +main.py נקודת כניסה: בונה את האפליקציה ובוחר webhook או polling +app/config.py קריאת משתני סביבה +app/content/lessons.py תוכנית הלימודים: שיעורים, תרגילים וחידונים +app/handlers.py פקודות, כפתורים וזרימת השיחה +app/keyboards.py מקלדות inline +app/storage.py התקדמות בשמירת SQLite +app/ai.py מורה חכם מבוסס Claude (אופציונלי) +app/runner.py הרצת קוד בתת-תהליך מוגבל (אופציונלי) +render.yaml Blueprint לפריסה ברנדר +``` + +## להוסיף שיעור משלכם + +מוסיפים `Lesson` לרשימת `LESSONS` בקובץ `app/content/lessons.py`. התפריט, הניווט, +החידון ומעקב ההתקדמות מתעדכנים מעצמם. `python -m pytest` יאמת שהשיעור החדש תקין. + +## הערה על `/run` + +הפעלת `ENABLE_CODE_RUNNER=true` מריצה קוד שנשלח מטלגרם על השרת שלכם. הבוט מגביל +זמן CPU, זיכרון, גודל פלט וכתיבה לקבצים, וחוסם ייבוא מודולים רגישים - אבל אלו +שכבות הגנה, לא בידוד מלא. להפעלה בסביבה ציבורית מומלץ להריץ בקונטיינר ייעודי +ללא גישת רשת. כשהתכונה כבויה, תרגילים נבדקים בלי להריץ קוד כלל. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..78c08ba --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +"""בוט טלגרם שמלמד Python.""" + +__all__ = ["config", "storage", "ai", "handlers", "keyboards", "runner"] diff --git a/app/ai.py b/app/ai.py new file mode 100644 index 0000000..5a8dcd7 --- /dev/null +++ b/app/ai.py @@ -0,0 +1,189 @@ +"""שכבת AI אופציונלית מעל Claude. + +אם ANTHROPIC_API_KEY מוגדר, הבוט יודע לענות על שאלות חופשיות (/ask) +ולבדוק תרגילים חופשיים. בלי מפתח הבוט ממשיך לעבוד עם בדיקות היוריסטיות. +""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +TUTOR_SYSTEM = ( + "אתה מורה סבלני ל-Python שמלמד מתחילים גמורים דרך בוט טלגרם. " + "ענה תמיד בעברית, בגובה העיניים, בלי ז'רגון מיותר.\n" + "כללים:\n" + "- תשובה קצרה: עד 120 מילים, ואם צריך דוגמה - עד 12 שורות קוד.\n" + "- קוד תמיד בתוך בלוק ```python ... ``` ובלי הסברים בתוך הקוד.\n" + "- אם השאלה אינה קשורה לתכנות, לפייתון או ללמידה שלהם, אמור זאת " + "במשפט אחד והצע לשאול שאלה על Python.\n" + "- אל תיתן קוד מסוכן (מחיקת קבצים, גישה לרשת, סיסמאות) גם אם מבקשים.\n" + "- עדיף להסביר את העיקרון מאשר לפתור עבור התלמיד את כל שיעורי הבית." +) + +GRADER_SYSTEM = ( + "אתה בודק תרגילי Python של מתחילים. אתה מקבל את משימת התרגיל, פתרון " + "לדוגמה ואת הקוד של התלמיד.\n" + "החזר אך ורק JSON תקין במבנה:\n" + '{"passed": true/false, "feedback": "משפט או שניים בעברית"}\n' + "כללי שיפוט:\n" + "- קבל כל פתרון נכון, גם אם הוא שונה מהפתרון לדוגמה.\n" + "- אל תוריד נקודות על שמות משתנים, רווחים או סדר שורות שאינו משנה.\n" + "- אם יש שגיאה - הסבר במשפט אחד מה חסר, בלי לתת את הפתרון המלא.\n" + "- אם הקוד אינו קשור למשימה, passed=false." +) + +_CODE_FENCE = re.compile(r"```[a-zA-Z]*\n?(.*?)```", re.DOTALL) + + +@dataclass(frozen=True) +class Grade: + passed: bool + feedback: str + + +class AIUnavailable(RuntimeError): + """נזרק כשאין מפתח API או כשהספרייה לא מותקנת.""" + + +class Tutor: + """עטיפה דקה מעל Anthropic SDK, עם ניוון עדין כשאין מפתח.""" + + def __init__(self, api_key: str | None, model: str = "claude-opus-5") -> None: + self._model = model + self._client = None + # נסיגה חד-פעמית: אם החשבון לא תומך ב-beta fallbacks, עוברים לנתיב הרגיל + self._server_fallbacks = True + + if not api_key: + return + try: + from anthropic import AsyncAnthropic + except ImportError: # pragma: no cover + logger.warning("חבילת anthropic אינה מותקנת - תכונות ה-AI כבויות") + return + self._client = AsyncAnthropic(api_key=api_key, max_retries=2, timeout=60.0) + + @property + def enabled(self) -> bool: + return self._client is not None + + # ------------------------------------------------------------------ core + async def _create( + self, *, system: str, user_content: str, max_tokens: int, effort: str + ): + if self._client is None: + raise AIUnavailable("תכונות ה-AI אינן פעילות") + + messages = [{"role": "user", "content": user_content}] + + if self._server_fallbacks: + try: + return await self._client.beta.messages.create( + model=self._model, + max_tokens=max_tokens, + system=system, + messages=messages, + output_config={"effort": effort}, + betas=["server-side-fallback-2026-07-01"], + fallbacks="default", + ) + except Exception as exc: # noqa: BLE001 - נסיגה מכוונת + if not _is_unsupported_parameter(exc): + raise + logger.info("server-side fallbacks לא זמינים, עובר לנתיב הרגיל: %s", exc) + self._server_fallbacks = False + + return await self._client.messages.create( + model=self._model, + max_tokens=max_tokens, + system=system, + messages=messages, + output_config={"effort": effort}, + ) + + @staticmethod + def _text_of(response) -> str: + parts = [ + block.text + for block in response.content + if getattr(block, "type", None) == "text" + ] + return "\n".join(part for part in parts if part).strip() + + # ------------------------------------------------------------------- ask + async def answer(self, question: str, lesson_title: str | None = None) -> str: + """עונה על שאלה חופשית של תלמיד.""" + context = f"התלמיד נמצא כרגע בשיעור: {lesson_title}\n\n" if lesson_title else "" + response = await self._create( + system=TUTOR_SYSTEM, + user_content=f"{context}שאלת התלמיד:\n{question}", + max_tokens=1500, + effort="low", + ) + + if response.stop_reason == "refusal": + return "לא אוכל לענות על השאלה הזו. אפשר לשאול אותי משהו על Python 🙂" + + text = self._text_of(response) + return text or "לא הצלחתי לנסח תשובה. נסו לשאול בניסוח אחר." + + # ----------------------------------------------------------------- grade + async def grade(self, task: str, solution: str, submission: str) -> Grade: + """בודק פתרון של תלמיד מול משימת התרגיל.""" + user_content = ( + f"\n{task}\n\n\n" + f"\n{solution}\n\n\n" + f"\n{submission}\n" + ) + response = await self._create( + system=GRADER_SYSTEM, + user_content=user_content, + max_tokens=800, + effort="low", + ) + + if response.stop_reason == "refusal": + raise AIUnavailable("הבקשה נדחתה") + + return _parse_grade(self._text_of(response)) + + +def _is_unsupported_parameter(exc: Exception) -> bool: + """מזהה שגיאות 'הפרמטר לא נתמך' כדי לנסות שוב בלי beta.""" + if isinstance(exc, TypeError): + return True + status = getattr(exc, "status_code", None) + if status != 400: + return False + message = str(exc).lower() + return any( + token in message + for token in ("fallback", "beta", "unexpected", "not supported", "unknown") + ) + + +def _parse_grade(text: str) -> Grade: + """מחלץ JSON מתשובת המודל, גם אם הוא עטוף בבלוק קוד.""" + candidate = text.strip() + fence = _CODE_FENCE.search(candidate) + if fence: + candidate = fence.group(1).strip() + else: + start, end = candidate.find("{"), candidate.rfind("}") + if start != -1 and end > start: + candidate = candidate[start : end + 1] + + try: + data = json.loads(candidate) + return Grade( + passed=bool(data["passed"]), + feedback=str(data.get("feedback", "")).strip() or "נבדק.", + ) + except (json.JSONDecodeError, KeyError, TypeError) as exc: + logger.warning("תשובת בדיקה לא תקינה: %s", exc) + raise AIUnavailable("תשובת הבדיקה לא הייתה בפורמט צפוי") from exc diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..7eb5c8d --- /dev/null +++ b/app/config.py @@ -0,0 +1,72 @@ +"""טעינת הגדרות מסביבת ההרצה (משתני סביבה / קובץ .env).""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +try: # python-dotenv הוא נוחות לפיתוח מקומי בלבד + from dotenv import load_dotenv + + load_dotenv() +except ImportError: # pragma: no cover - בסביבת הרצה אמיתית המשתנים כבר מוגדרים + pass + + +def _as_bool(value: str | None, default: bool = False) -> bool: + if value is None or value == "": + return default + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + + +@dataclass(frozen=True) +class Config: + telegram_token: str + anthropic_api_key: str | None + claude_model: str + webhook_url: str | None + webhook_secret: str | None + port: int + data_dir: Path + enable_code_runner: bool + + @property + def db_path(self) -> Path: + return self.data_dir / "pythonbot.sqlite3" + + @property + def use_webhook(self) -> bool: + """ברנדר מריצים webhook; מקומית בלי WEBHOOK_URL עוברים ל-polling.""" + return bool(self.webhook_url) + + @property + def ai_enabled(self) -> bool: + return bool(self.anthropic_api_key) + + +def load_config() -> Config: + token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + if not token: + raise RuntimeError( + "חסר TELEGRAM_BOT_TOKEN. צרו בוט אצל @BotFather והגדירו את הטוקן " + "כמשתנה סביבה (ראו .env.example)." + ) + + # ברנדר מזריק את RENDER_EXTERNAL_URL אוטומטית לשירותי Web. + webhook_url = ( + os.environ.get("WEBHOOK_URL") or os.environ.get("RENDER_EXTERNAL_URL") or "" + ).strip().rstrip("/") + + data_dir = Path(os.environ.get("DATA_DIR", "./data")).expanduser() + + return Config( + telegram_token=token, + anthropic_api_key=(os.environ.get("ANTHROPIC_API_KEY") or "").strip() or None, + claude_model=os.environ.get("CLAUDE_MODEL", "claude-opus-5").strip(), + webhook_url=webhook_url or None, + webhook_secret=(os.environ.get("WEBHOOK_SECRET") or "").strip() or None, + port=int(os.environ.get("PORT", "10000")), + data_dir=data_dir, + enable_code_runner=_as_bool(os.environ.get("ENABLE_CODE_RUNNER"), False), + ) diff --git a/app/content/__init__.py b/app/content/__init__.py new file mode 100644 index 0000000..31e085c --- /dev/null +++ b/app/content/__init__.py @@ -0,0 +1,23 @@ +"""תוכן הקורס: שיעורים, תרגילים וחידונים.""" + +from app.content.lessons import ( + Exercise, + Lesson, + Quiz, + LESSONS, + LESSONS_BY_ID, + find_lesson, + lesson_index, + next_lesson, +) + +__all__ = [ + "Exercise", + "Lesson", + "Quiz", + "LESSONS", + "LESSONS_BY_ID", + "find_lesson", + "lesson_index", + "next_lesson", +] diff --git a/app/content/lessons.py b/app/content/lessons.py new file mode 100644 index 0000000..7e0c497 --- /dev/null +++ b/app/content/lessons.py @@ -0,0 +1,857 @@ +"""תוכנית הלימודים של הבוט. + +כל שיעור מורכב מהסבר, דוגמת קוד, תרגיל אחד וכמה שאלות חידון. +הטקסט מיועד ל-Telegram במצב parse_mode=HTML, ולכן מותר בו רק +, , , ,
, .
+"""
+
+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=(
+                    "מה יודפס?\n
x = 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=( + "מה הבעיה בקוד?\n
i = 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=( + "מה יודפס?\n
items = [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=( + "מה יודפס?\n
s = \"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=( + "מה יודפס?\n
def 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("
") + return "\n".join(out) + + +def _inline_markdown(line: str) -> str: + """בורח מ-HTML ומתרגם `code`, **bold** ו-*italic*.""" + escaped = html.escape(line) + escaped = re.sub(r"`([^`]+)`", r"\1", escaped) + escaped = re.sub(r"\*\*([^*]+)\*\*", r"\1", escaped) + escaped = re.sub(r"(?\1
", escaped) + return escaped + + +async def on_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + logger.exception("שגיאה בטיפול בעדכון", exc_info=context.error) + if isinstance(update, Update) and update.effective_message: + try: + await update.effective_message.reply_text( + "משהו השתבש אצלי 😕 נסו שוב, או /start כדי להתחיל מחדש." + ) + except Exception: # noqa: BLE001 + logger.debug("לא הצלחתי לשלוח הודעת שגיאה למשתמש") + + +def register(application: Application) -> None: + """רושם את כל ההנדלרים על האפליקציה.""" + application.add_handler(CommandHandler("start", start)) + application.add_handler(CommandHandler("help", help_command)) + application.add_handler(CommandHandler(["lessons", "menu"], lessons_menu)) + application.add_handler(CommandHandler("lesson", lesson_command)) + application.add_handler(CommandHandler("next", next_command)) + application.add_handler(CommandHandler("quiz", quiz_command)) + application.add_handler(CommandHandler(["practice", "exercise"], practice_command)) + application.add_handler(CommandHandler("progress", progress_command)) + application.add_handler(CommandHandler("ask", ask_command)) + application.add_handler(CommandHandler("run", run_command)) + application.add_handler(CommandHandler("cancel", cancel_command)) + application.add_handler(CommandHandler("reset", reset_command)) + application.add_handler(CallbackQueryHandler(on_callback)) + application.add_handler( + MessageHandler(filters.TEXT & ~filters.COMMAND, on_text) + ) + application.add_error_handler(on_error) diff --git a/app/keyboards.py b/app/keyboards.py new file mode 100644 index 0000000..29a51f3 --- /dev/null +++ b/app/keyboards.py @@ -0,0 +1,130 @@ +"""מקלדות inline של הבוט. + +מבנה ה-callback_data: "<פעולה>:<מזהה שיעור>[:פרמטר]". +""" + +from __future__ import annotations + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + +from app.content import LESSONS, Lesson, lesson_index + +CB_LESSON = "lesson" +CB_EXAMPLE = "example" +CB_EXERCISE = "exercise" +CB_SOLUTION = "solution" +CB_HINT = "hint" +CB_QUIZ = "quiz" +CB_ANSWER = "answer" +CB_NEXT = "next" +CB_MENU = "menu" +CB_NOOP = "noop" + + +def menu_keyboard(completed: set[str]) -> InlineKeyboardMarkup: + """תפריט כל השיעורים, עם וי ליד מה שהושלם.""" + rows = [] + for index, lesson in enumerate(LESSONS, start=1): + mark = "✅" if lesson.id in completed else "▫️" + rows.append( + [ + InlineKeyboardButton( + f"{mark} {index}. {lesson.display_title}", + callback_data=f"{CB_LESSON}:{lesson.id}", + ) + ] + ) + return InlineKeyboardMarkup(rows) + + +def lesson_keyboard(lesson: Lesson) -> InlineKeyboardMarkup: + rows = [ + [ + InlineKeyboardButton( + "💡 דוגמת קוד", callback_data=f"{CB_EXAMPLE}:{lesson.id}" + ), + InlineKeyboardButton( + "✏️ תרגיל", callback_data=f"{CB_EXERCISE}:{lesson.id}" + ), + ], + [ + InlineKeyboardButton("🧠 חידון", callback_data=f"{CB_QUIZ}:{lesson.id}:0"), + ], + ] + rows.append(_nav_row(lesson)) + return InlineKeyboardMarkup(rows) + + +def example_keyboard(lesson: Lesson) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "✏️ לתרגיל", callback_data=f"{CB_EXERCISE}:{lesson.id}" + ), + InlineKeyboardButton( + "📖 חזרה לשיעור", callback_data=f"{CB_LESSON}:{lesson.id}" + ), + ], + _nav_row(lesson), + ] + ) + + +def exercise_keyboard(lesson: Lesson) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("🔦 רמז", callback_data=f"{CB_HINT}:{lesson.id}"), + InlineKeyboardButton( + "🔑 פתרון", callback_data=f"{CB_SOLUTION}:{lesson.id}" + ), + ], + [ + InlineKeyboardButton( + "🧠 לחידון", callback_data=f"{CB_QUIZ}:{lesson.id}:0" + ), + InlineKeyboardButton( + "📖 חזרה לשיעור", callback_data=f"{CB_LESSON}:{lesson.id}" + ), + ], + ] + ) + + +def quiz_keyboard(lesson: Lesson, quiz_index: int) -> InlineKeyboardMarkup: + quiz = lesson.quizzes[quiz_index] + rows = [ + [ + InlineKeyboardButton( + f"{chr(ord('א') + option_index)}. {option}", + callback_data=f"{CB_ANSWER}:{lesson.id}:{quiz_index}:{option_index}", + ) + ] + for option_index, option in enumerate(quiz.options) + ] + return InlineKeyboardMarkup(rows) + + +def after_quiz_keyboard(lesson: Lesson, quiz_index: int) -> InlineKeyboardMarkup: + rows = [] + if quiz_index + 1 < len(lesson.quizzes): + rows.append( + [ + InlineKeyboardButton( + "➡️ שאלה הבאה", + callback_data=f"{CB_QUIZ}:{lesson.id}:{quiz_index + 1}", + ) + ] + ) + rows.append(_nav_row(lesson)) + return InlineKeyboardMarkup(rows) + + +def _nav_row(lesson: Lesson) -> list[InlineKeyboardButton]: + row = [InlineKeyboardButton("📚 כל השיעורים", callback_data=f"{CB_MENU}:-")] + if lesson_index(lesson.id) + 1 < len(LESSONS): + row.append( + InlineKeyboardButton("⏭ השיעור הבא", callback_data=f"{CB_NEXT}:{lesson.id}") + ) + return row diff --git a/app/runner.py b/app/runner.py new file mode 100644 index 0000000..b9cc307 --- /dev/null +++ b/app/runner.py @@ -0,0 +1,135 @@ +"""הרצת קוד תרגילים בתת-תהליך מוגבל. + +התכונה כבויה כברירת מחדל (ENABLE_CODE_RUNNER=false). הפעלה מריצה קוד שנשלח +מטלגרם על השרת שלכם, ולכן הגבלנו זמן CPU, זיכרון, גודל פלט וייבוא מודולים +רגישים. זו הגנה בשכבות, לא בידוד מלא - ראו את הפרק המתאים ב-README. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import subprocess +import sys +import tempfile +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +CPU_SECONDS = 2 +MEMORY_BYTES = 128 * 1024 * 1024 +WALL_TIMEOUT = 6.0 +MAX_OUTPUT_CHARS = 2000 +MAX_CODE_CHARS = 4000 + +BLOCKED_MODULES = { + "os", "sys", "subprocess", "shutil", "socket", "http", "urllib", "requests", + "pathlib", "ctypes", "multiprocessing", "threading", "importlib", "pickle", + "signal", "resource", "glob", "tempfile", "webbrowser", "ftplib", "smtplib", +} +BLOCKED_CALLS = {"open", "eval", "exec", "compile", "__import__", "input", "breakpoint"} + +_IMPORT_RE = re.compile(r"^\s*(?:import|from)\s+([A-Za-z_][\w.]*)", re.MULTILINE) + + +@dataclass(frozen=True) +class RunResult: + ok: bool + output: str + error: str = "" + + @property + def combined(self) -> str: + return "\n".join(part for part in (self.output, self.error) if part).strip() + + +def check_code(code: str) -> str | None: + """בדיקה סטטית מהירה. מחזיר הודעת שגיאה בעברית, או None אם הקוד עבר.""" + if not code.strip(): + return "לא קיבלתי קוד להרצה." + if len(code) > MAX_CODE_CHARS: + return f"הקוד ארוך מדי (מעל {MAX_CODE_CHARS} תווים)." + + for match in _IMPORT_RE.finditer(code): + root = match.group(1).split(".")[0] + if root in BLOCKED_MODULES: + return f"ייבוא המודול {root} חסום בסביבת התרגול." + + for name in BLOCKED_CALLS: + if re.search(rf"\b{re.escape(name)}\s*\(", code): + return f"השימוש ב-{name}() חסום בסביבת התרגול." + + try: + compile(code, "", "exec") + except SyntaxError as exc: + return f"שגיאת תחביר בשורה {exc.lineno}: {exc.msg}" + return None + + +def _apply_limits() -> None: # pragma: no cover - רץ רק בתת-תהליך + import resource + + resource.setrlimit(resource.RLIMIT_CPU, (CPU_SECONDS, CPU_SECONDS)) + resource.setrlimit(resource.RLIMIT_AS, (MEMORY_BYTES, MEMORY_BYTES)) + resource.setrlimit(resource.RLIMIT_NPROC, (64, 64)) + resource.setrlimit(resource.RLIMIT_FSIZE, (0, 0)) + os.setsid() + + +def _run_sync(code: str) -> RunResult: + env = { + "PATH": "/usr/bin:/bin", + "HOME": "/tmp", + "PYTHONIOENCODING": "utf-8", + "PYTHONDONTWRITEBYTECODE": "1", + "LANG": "C.UTF-8", + } + with tempfile.TemporaryDirectory(prefix="pythonbot-") as workdir: + try: + completed = subprocess.run( + [sys.executable, "-I", "-B", "-c", code], + capture_output=True, + text=True, + timeout=WALL_TIMEOUT, + cwd=workdir, + env=env, + stdin=subprocess.DEVNULL, + preexec_fn=_apply_limits if hasattr(os, "setsid") else None, + check=False, + ) + except subprocess.TimeoutExpired: + return RunResult(False, "", "הקוד רץ יותר מדי זמן - אולי יש לולאה אינסופית?") + except OSError as exc: # pragma: no cover + logger.exception("הרצת הקוד נכשלה") + return RunResult(False, "", f"לא הצלחתי להריץ את הקוד: {exc}") + + stdout = completed.stdout[:MAX_OUTPUT_CHARS] + stderr = completed.stderr[-MAX_OUTPUT_CHARS:] + if completed.returncode < 0: + # התהליך נהרג על ידי סיגנל - בדרך כלל חריגה ממגבלת CPU או זיכרון + return RunResult( + False, + stdout.strip(), + "הקוד חרג ממגבלות הזמן או הזיכרון. בדקו אם יש לולאה שלא נגמרת.", + ) + if completed.returncode != 0: + return RunResult(False, stdout.strip(), _short_error(stderr)) + return RunResult(True, stdout.strip()) + + +def _short_error(stderr: str) -> str: + """מציג לתלמיד את שורת השגיאה המשמעותית בלבד.""" + lines = [line for line in stderr.strip().splitlines() if line.strip()] + if not lines: + return "הקוד נעצר עם שגיאה." + return lines[-1].strip() + + +async def run_code(code: str) -> RunResult: + """מריץ קוד ומחזיר את הפלט. אינו חוסם את לולאת האירועים.""" + problem = check_code(code) + if problem: + return RunResult(False, "", problem) + return await asyncio.to_thread(_run_sync, code) diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..f31e87c --- /dev/null +++ b/app/storage.py @@ -0,0 +1,251 @@ +"""שמירת התקדמות משתמשים ב-SQLite. + +כל הגישות ל-DB רצות ב-thread נפרד (``asyncio.to_thread``) כדי לא לחסום את +לולאת האירועים של הבוט. +""" + +from __future__ import annotations + +import asyncio +import sqlite3 +from dataclasses import dataclass +from datetime import date, datetime, timezone +from pathlib import Path + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + first_name TEXT, + username TEXT, + created_at TEXT NOT NULL, + last_seen TEXT NOT NULL, + streak INTEGER NOT NULL DEFAULT 0, + last_active_day TEXT +); + +CREATE TABLE IF NOT EXISTS lesson_progress ( + user_id INTEGER NOT NULL, + lesson_id TEXT NOT NULL, + completed_at TEXT NOT NULL, + PRIMARY KEY (user_id, lesson_id) +); + +CREATE TABLE IF NOT EXISTS quiz_answers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + lesson_id TEXT NOT NULL, + quiz_index INTEGER NOT NULL, + correct INTEGER NOT NULL, + answered_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_quiz_answers_user ON quiz_answers(user_id); + +CREATE TABLE IF NOT EXISTS exercise_results ( + user_id INTEGER NOT NULL, + lesson_id TEXT NOT NULL, + passed INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, lesson_id) +); +""" + + +@dataclass(frozen=True) +class Stats: + lessons_done: int + quiz_total: int + quiz_correct: int + exercises_passed: int + streak: int + + @property + def quiz_accuracy(self) -> int: + if self.quiz_total == 0: + return 0 + return round(100 * self.quiz_correct / self.quiz_total) + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +class Storage: + """עטיפה דקה מעל SQLite עם API אסינכרוני.""" + + def __init__(self, db_path: Path) -> None: + self._db_path = db_path + self._lock = asyncio.Lock() + + # ------------------------------------------------------------------ setup + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self._db_path, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + def _init_sync(self) -> None: + self._db_path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as conn: + conn.executescript(_SCHEMA) + + async def init(self) -> None: + await asyncio.to_thread(self._init_sync) + + # ------------------------------------------------------------------ users + def _touch_user_sync( + self, user_id: int, first_name: str | None, username: str | None + ) -> int: + now = _utcnow() + today = date.today().isoformat() + with self._connect() as conn: + row = conn.execute( + "SELECT streak, last_active_day FROM users WHERE user_id = ?", + (user_id,), + ).fetchone() + + if row is None: + conn.execute( + "INSERT INTO users (user_id, first_name, username, created_at," + " last_seen, streak, last_active_day) VALUES (?,?,?,?,?,?,?)", + (user_id, first_name, username, now, now, 1, today), + ) + return 1 + + streak = int(row["streak"] or 0) + last_day = row["last_active_day"] + if last_day != today: + if last_day: + gap = (date.today() - date.fromisoformat(last_day)).days + streak = streak + 1 if gap == 1 else 1 + else: + streak = 1 + streak = max(streak, 1) + + conn.execute( + "UPDATE users SET first_name = ?, username = ?, last_seen = ?," + " streak = ?, last_active_day = ? WHERE user_id = ?", + (first_name, username, now, streak, today, user_id), + ) + return streak + + async def touch_user( + self, user_id: int, first_name: str | None = None, username: str | None = None + ) -> int: + """מעדכן פעילות אחרונה ומחזיר את אורך הרצף היומי.""" + async with self._lock: + return await asyncio.to_thread( + self._touch_user_sync, user_id, first_name, username + ) + + # --------------------------------------------------------------- progress + def _complete_lesson_sync(self, user_id: int, lesson_id: str) -> None: + with self._connect() as conn: + conn.execute( + "INSERT OR IGNORE INTO lesson_progress (user_id, lesson_id," + " completed_at) VALUES (?,?,?)", + (user_id, lesson_id, _utcnow()), + ) + + async def complete_lesson(self, user_id: int, lesson_id: str) -> None: + async with self._lock: + await asyncio.to_thread(self._complete_lesson_sync, user_id, lesson_id) + + def _completed_lessons_sync(self, user_id: int) -> set[str]: + with self._connect() as conn: + rows = conn.execute( + "SELECT lesson_id FROM lesson_progress WHERE user_id = ?", (user_id,) + ).fetchall() + return {row["lesson_id"] for row in rows} + + async def completed_lessons(self, user_id: int) -> set[str]: + async with self._lock: + return await asyncio.to_thread(self._completed_lessons_sync, user_id) + + # ------------------------------------------------------------------- quiz + def _record_quiz_sync( + self, user_id: int, lesson_id: str, quiz_index: int, correct: bool + ) -> None: + with self._connect() as conn: + conn.execute( + "INSERT INTO quiz_answers (user_id, lesson_id, quiz_index, correct," + " answered_at) VALUES (?,?,?,?,?)", + (user_id, lesson_id, quiz_index, int(correct), _utcnow()), + ) + + async def record_quiz_answer( + self, user_id: int, lesson_id: str, quiz_index: int, correct: bool + ) -> None: + async with self._lock: + await asyncio.to_thread( + self._record_quiz_sync, user_id, lesson_id, quiz_index, correct + ) + + # --------------------------------------------------------------- exercise + def _record_exercise_sync(self, user_id: int, lesson_id: str, passed: bool) -> None: + with self._connect() as conn: + conn.execute( + "INSERT INTO exercise_results (user_id, lesson_id, passed, attempts," + " updated_at) VALUES (?,?,?,1,?)" + " ON CONFLICT(user_id, lesson_id) DO UPDATE SET" + " passed = MAX(passed, excluded.passed)," + " attempts = attempts + 1," + " updated_at = excluded.updated_at", + (user_id, lesson_id, int(passed), _utcnow()), + ) + + async def record_exercise(self, user_id: int, lesson_id: str, passed: bool) -> None: + async with self._lock: + await asyncio.to_thread( + self._record_exercise_sync, user_id, lesson_id, passed + ) + + # ------------------------------------------------------------------ stats + def _stats_sync(self, user_id: int) -> Stats: + with self._connect() as conn: + lessons = conn.execute( + "SELECT COUNT(*) AS c FROM lesson_progress WHERE user_id = ?", + (user_id,), + ).fetchone()["c"] + quiz = conn.execute( + "SELECT COUNT(*) AS total, COALESCE(SUM(correct), 0) AS correct" + " FROM quiz_answers WHERE user_id = ?", + (user_id,), + ).fetchone() + exercises = conn.execute( + "SELECT COUNT(*) AS c FROM exercise_results" + " WHERE user_id = ? AND passed = 1", + (user_id,), + ).fetchone()["c"] + user = conn.execute( + "SELECT streak FROM users WHERE user_id = ?", (user_id,) + ).fetchone() + + return Stats( + lessons_done=int(lessons), + quiz_total=int(quiz["total"]), + quiz_correct=int(quiz["correct"]), + exercises_passed=int(exercises), + streak=int(user["streak"]) if user else 0, + ) + + async def stats(self, user_id: int) -> Stats: + async with self._lock: + return await asyncio.to_thread(self._stats_sync, user_id) + + # ------------------------------------------------------------------ reset + def _reset_sync(self, user_id: int) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM lesson_progress WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM quiz_answers WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM exercise_results WHERE user_id = ?", (user_id,)) + conn.execute( + "UPDATE users SET streak = 1, last_active_day = ? WHERE user_id = ?", + (date.today().isoformat(), user_id), + ) + + async def reset(self, user_id: int) -> None: + async with self._lock: + await asyncio.to_thread(self._reset_sync, user_id) diff --git a/main.py b/main.py new file mode 100644 index 0000000..5ace948 --- /dev/null +++ b/main.py @@ -0,0 +1,100 @@ +"""נקודת הכניסה של הבוט. + +ברנדר (Web Service) מוגדר RENDER_EXTERNAL_URL, ולכן הבוט עולה במצב webhook +ומאזין על PORT. מקומית, בלי WEBHOOK_URL, הוא עובר אוטומטית ל-polling. +""" + +from __future__ import annotations + +import logging +import sys + +from telegram import BotCommand +from telegram.ext import Application, ApplicationBuilder + +from app import handlers +from app.ai import Tutor +from app.config import Config, load_config +from app.storage import Storage + +logging.basicConfig( + format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + level=logging.INFO, +) +logging.getLogger("httpx").setLevel(logging.WARNING) +logger = logging.getLogger("pythonbot") + +COMMANDS = [ + BotCommand("start", "התחלה"), + BotCommand("lessons", "תפריט השיעורים"), + BotCommand("next", "השיעור הבא"), + BotCommand("practice", "תרגיל"), + BotCommand("quiz", "חידון"), + BotCommand("ask", "שאלה חופשית על Python"), + BotCommand("progress", "ההתקדמות שלי"), + BotCommand("help", "עזרה"), +] + +WEBHOOK_PATH = "telegram" + + +def build_application(config: Config) -> Application: + storage = Storage(config.db_path) + tutor = Tutor(config.anthropic_api_key, config.claude_model) + + async def on_startup(app: Application) -> None: + await storage.init() + await app.bot.set_my_commands(COMMANDS) + logger.info( + "הבוט מוכן | AI: %s | הרצת קוד: %s | מצב: %s", + "פעיל" if tutor.enabled else "כבוי", + "פעילה" if config.enable_code_runner else "כבויה", + "webhook" if config.use_webhook else "polling", + ) + + application = ( + ApplicationBuilder() + .token(config.telegram_token) + .post_init(on_startup) + .build() + ) + application.bot_data["storage"] = storage + application.bot_data["tutor"] = tutor + application.bot_data["enable_code_runner"] = config.enable_code_runner + + handlers.register(application) + return application + + +def main() -> int: + try: + config = load_config() + except RuntimeError as exc: + logger.error("%s", exc) + return 1 + + application = build_application(config) + + if config.use_webhook: + url = f"{config.webhook_url}/{WEBHOOK_PATH}" + logger.info("מאזין ב-webhook על פורט %s", config.port) + application.run_webhook( + listen="0.0.0.0", + port=config.port, + url_path=WEBHOOK_PATH, + webhook_url=url, + secret_token=config.webhook_secret, + drop_pending_updates=True, + allowed_updates=["message", "callback_query"], + ) + else: + logger.info("מצב polling (פיתוח מקומי)") + application.run_polling( + drop_pending_updates=True, + allowed_updates=["message", "callback_query"], + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4dc8d52 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +asyncio_mode = auto +filterwarnings = + ignore::DeprecationWarning diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..6d6fab3 --- /dev/null +++ b/render.yaml @@ -0,0 +1,30 @@ +# Render Blueprint - פריסה בלחיצה אחת. +# ב-Render: New > Blueprint, בוחרים את הריפו הזה, ומגדירים את TELEGRAM_BOT_TOKEN. +services: + - type: web + name: python-teacher-bot + runtime: python + plan: free + region: frankfurt + buildCommand: pip install -r requirements.txt + startCommand: python main.py + envVars: + - key: PYTHON_VERSION + value: "3.11.9" + # הטוקן מ-@BotFather. sync: false = מזינים אותו ידנית בדשבורד. + - key: TELEGRAM_BOT_TOKEN + sync: false + # אופציונלי: מפעיל /ask ובדיקת תרגילים חכמה. + - key: ANTHROPIC_API_KEY + sync: false + - key: CLAUDE_MODEL + value: claude-opus-5 + # נוצר אוטומטית - מאמת שהעדכונים באמת הגיעו מטלגרם. + - key: WEBHOOK_SECRET + generateValue: true + - key: ENABLE_CODE_RUNNER + value: "false" + # בתוכנית החינמית אין דיסק קבוע; הנתונים נמחקים בכל דיפלוי. + # להתקדמות מתמשכת: שדרגו תוכנית, הוסיפו Disk ב-/var/data והציבו DATA_DIR=/var/data + - key: DATA_DIR + value: /tmp/pythonbot-data diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1e44371 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-telegram-bot[webhooks]==22.8 +anthropic==1.7.0 +python-dotenv==1.1.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_content.py b/tests/test_content.py new file mode 100644 index 0000000..6730358 --- /dev/null +++ b/tests/test_content.py @@ -0,0 +1,83 @@ +"""בדיקות תקינות לתוכן הקורס.""" + +from __future__ import annotations + +import re + +import pytest + +from app.content import LESSONS, LESSONS_BY_ID, find_lesson, lesson_index, next_lesson + +ALLOWED_TAGS = {"b", "i", "u", "s", "code", "pre", "a"} +TAG_RE = re.compile(r" set[str]: + return {match.lower() for match in TAG_RE.findall(text)} + + +def test_lessons_exist_and_ids_are_unique(): + assert len(LESSONS) >= 10 + ids = [lesson.id for lesson in LESSONS] + assert len(ids) == len(set(ids)) + assert len(LESSONS_BY_ID) == len(LESSONS) + + +@pytest.mark.parametrize("lesson", LESSONS, ids=lambda lesson: lesson.id) +def test_lesson_fields_are_filled(lesson): + assert lesson.title and lesson.goal and lesson.body + assert lesson.example.strip() + assert lesson.exercise.prompt.strip() + assert lesson.exercise.hint.strip() + assert lesson.exercise.solution.strip() + + +@pytest.mark.parametrize("lesson", LESSONS, ids=lambda lesson: lesson.id) +def test_only_telegram_safe_html(lesson): + """טלגרם תומך בתת-קבוצה קטנה של תגיות HTML בלבד.""" + for field in (lesson.body, lesson.goal, lesson.exercise.prompt): + assert _tags(field) <= ALLOWED_TAGS, field + for quiz in lesson.quizzes: + assert _tags(quiz.question) <= ALLOWED_TAGS + assert _tags(quiz.explanation) <= ALLOWED_TAGS + + +@pytest.mark.parametrize("lesson", LESSONS, ids=lambda lesson: lesson.id) +def test_examples_and_solutions_compile(lesson): + compile(lesson.example, f"{lesson.id}-example", "exec") + compile(lesson.exercise.solution, f"{lesson.id}-solution", "exec") + + +@pytest.mark.parametrize("lesson", LESSONS, ids=lambda lesson: lesson.id) +def test_quizzes_are_valid(lesson): + assert lesson.quizzes, "לכל שיעור חייב להיות לפחות חידון אחד" + for quiz in lesson.quizzes: + assert len(quiz.options) >= 2 + assert 0 <= quiz.answer < len(quiz.options) + assert quiz.explanation.strip() + assert len(set(quiz.options)) == len(quiz.options) + + +@pytest.mark.parametrize("lesson", LESSONS, ids=lambda lesson: lesson.id) +def test_required_tokens_appear_in_reference_solution(lesson): + normalized = lesson.exercise.solution.replace(" ", "") + for token in lesson.exercise.required: + assert token.replace(" ", "") in normalized, (lesson.id, token) + + +@pytest.mark.parametrize("lesson", LESSONS, ids=lambda lesson: lesson.id) +def test_callback_data_fits_telegram_limit(lesson): + """callback_data מוגבל ל-64 בתים.""" + for quiz_index, quiz in enumerate(lesson.quizzes): + for option_index in range(len(quiz.options)): + data = f"answer:{lesson.id}:{quiz_index}:{option_index}" + assert len(data.encode("utf-8")) <= 64, data + + +def test_navigation_helpers(): + assert lesson_index(LESSONS[0].id) == 0 + assert lesson_index("nope") == -1 + assert next_lesson(LESSONS[0].id) is LESSONS[1] + assert next_lesson(LESSONS[-1].id) is None + assert find_lesson(LESSONS[3].id) is LESSONS[3] + assert find_lesson("nope") is None diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 0000000..f49c19b --- /dev/null +++ b/tests/test_flow.py @@ -0,0 +1,274 @@ +"""בדיקות זרימה: מריצות את ההנדלרים האמיתיים מול טלגרם מדומה.""" + +from __future__ import annotations + +from html.parser import HTMLParser +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app import handlers +from app.ai import Tutor +from app.content import LESSONS +from app.storage import Storage + +ALLOWED_TAGS = {"b", "i", "u", "s", "code", "pre", "a", "tg-spoiler", "blockquote"} +USER_ID = 42 + + +class _TelegramHTMLValidator(HTMLParser): + """מוודא שהתגיות מאוזנות ושכולן נתמכות בטלגרם.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.stack: list[str] = [] + self.problems: list[str] = [] + + def handle_starttag(self, tag, attrs): + if tag not in ALLOWED_TAGS: + self.problems.append(f"תגית לא נתמכת: {tag}") + self.stack.append(tag) + + def handle_endtag(self, tag): + if not self.stack or self.stack.pop() != tag: + self.problems.append(f"סגירה לא תואמת: {tag}") + + +def assert_valid_telegram_html(text: str) -> None: + validator = _TelegramHTMLValidator() + validator.feed(text) + assert not validator.problems, (validator.problems, text[:200]) + assert not validator.stack, (validator.stack, text[:200]) + assert len(text) <= handlers.TELEGRAM_LIMIT + + +class FakeBot: + def __init__(self) -> None: + self.send_chat_action = AsyncMock() + + +class Recorder: + """אוסף כל טקסט שהבוט שלח או ערך, ומאמת אותו.""" + + def __init__(self) -> None: + self.sent: list[str] = [] + + async def reply_html(self, text, **kwargs): + assert_valid_telegram_html(text) + self.sent.append(text) + + async def edit_message_text(self, text, **kwargs): + assert_valid_telegram_html(text) + self.sent.append(text) + + @property + def last(self) -> str: + assert self.sent, "הבוט לא שלח שום הודעה" + return self.sent[-1] + + +def make_update(recorder: Recorder, *, text: str = "", callback: str | None = None): + message = SimpleNamespace(text=text, reply_html=recorder.reply_html) + message.reply_text = AsyncMock() + query = None + if callback is not None: + query = SimpleNamespace( + data=callback, + answer=AsyncMock(), + edit_message_text=recorder.edit_message_text, + ) + return SimpleNamespace( + effective_message=message, + effective_user=SimpleNamespace(id=USER_ID, first_name="דנה", username="dana"), + effective_chat=SimpleNamespace(id=USER_ID), + callback_query=query, + ) + + +@pytest.fixture +async def context(tmp_path): + storage = Storage(tmp_path / "flow.sqlite3") + await storage.init() + application = SimpleNamespace( + bot_data={ + "storage": storage, + "tutor": Tutor(None), # בלי מפתח - נתיב הבדיקה ההיוריסטית + "enable_code_runner": True, + } + ) + return SimpleNamespace( + application=application, + bot_data=application.bot_data, + user_data={}, + bot=FakeBot(), + args=[], + ) + + +async def test_start_greets_and_lists_lessons(context): + recorder = Recorder() + await handlers.start(make_update(recorder), context) + assert "דנה" in recorder.last + assert "רצף למידה" in recorder.last + + +async def test_lesson_command_opens_the_requested_lesson(context): + recorder = Recorder() + context.args = ["3"] + await handlers.lesson_command(make_update(recorder), context) + assert LESSONS[2].title in recorder.last + assert context.user_data[handlers.CURRENT_KEY] == LESSONS[2].id + + +async def test_lesson_command_rejects_out_of_range(context): + recorder = Recorder() + context.args = ["99"] + await handlers.lesson_command(make_update(recorder), context) + assert "יש שיעורים" in recorder.last + + +async def test_every_lesson_renders_through_the_callback_router(context): + """המסלול המלא של כל שיעור: הסבר, דוגמה, תרגיל, רמז, פתרון וחידון.""" + recorder = Recorder() + for lesson in LESSONS: + for action in ("lesson", "example", "exercise", "hint", "solution"): + await handlers.on_callback( + make_update(recorder, callback=f"{action}:{lesson.id}"), context + ) + for index in range(len(lesson.quizzes)): + await handlers.on_callback( + make_update(recorder, callback=f"quiz:{lesson.id}:{index}"), context + ) + assert len(recorder.sent) > len(LESSONS) * 5 + + +async def test_correct_quiz_answers_complete_the_lesson(context): + recorder = Recorder() + lesson = LESSONS[0] + for index, quiz in enumerate(lesson.quizzes): + await handlers.on_callback( + make_update(recorder, callback=f"answer:{lesson.id}:{index}:{quiz.answer}"), + context, + ) + assert "נכון" in recorder.last + storage = context.bot_data["storage"] + assert lesson.id in await storage.completed_lessons(USER_ID) + assert (await storage.stats(USER_ID)).quiz_correct == len(lesson.quizzes) + + +async def test_wrong_quiz_answer_explains_without_completing(context): + recorder = Recorder() + lesson = LESSONS[1] + quiz = lesson.quizzes[0] + wrong = (quiz.answer + 1) % len(quiz.options) + await handlers.on_callback( + make_update(recorder, callback=f"answer:{lesson.id}:0:{wrong}"), context + ) + assert "לא מדויק" in recorder.last + assert quiz.explanation.split("<")[0][:20] in recorder.last + assert lesson.id not in await context.bot_data["storage"].completed_lessons(USER_ID) + + +async def test_exercise_submission_runs_and_passes(context): + recorder = Recorder() + lesson = next(l for l in LESSONS if l.exercise.expected_output == "75") + await handlers.on_callback( + make_update(recorder, callback=f"exercise:{lesson.id}"), context + ) + assert context.user_data[handlers.AWAITING_KEY] == lesson.id + + await handlers.on_text( + make_update(recorder, text=lesson.exercise.solution), context + ) + assert "יפה מאוד" in recorder.last + assert handlers.AWAITING_KEY not in context.user_data + assert (await context.bot_data["storage"].stats(USER_ID)).exercises_passed == 1 + + +async def test_wrong_output_keeps_the_exercise_open(context): + recorder = Recorder() + lesson = next(l for l in LESSONS if l.exercise.expected_output == "75") + context.user_data[handlers.AWAITING_KEY] = lesson.id + + await handlers.on_text(make_update(recorder, text="print(1)"), context) + assert "ציפיתי לפלט" in recorder.last + assert context.user_data[handlers.AWAITING_KEY] == lesson.id + + +async def test_broken_code_is_reported_not_crashed(context): + recorder = Recorder() + lesson = LESSONS[1] + context.user_data[handlers.AWAITING_KEY] = lesson.id + + await handlers.on_text(make_update(recorder, text="print(1/0)"), context) + assert "לא רץ בהצלחה" in recorder.last + assert "ZeroDivisionError" in recorder.last + + +async def test_markdown_fences_in_submissions_are_stripped(context): + recorder = Recorder() + lesson = next(l for l in LESSONS if l.exercise.expected_output == "75") + context.user_data[handlers.AWAITING_KEY] = lesson.id + + fenced = f"```python\n{lesson.exercise.solution}```" + await handlers.on_text(make_update(recorder, text=fenced), context) + assert "יפה מאוד" in recorder.last + + +async def test_question_without_ai_explains_the_limitation(context): + recorder = Recorder() + await handlers.on_text(make_update(recorder, text="מה זה לולאה?"), context) + assert "ANTHROPIC_API_KEY" in recorder.last + + +async def test_progress_reports_real_numbers(context): + recorder = Recorder() + storage = context.bot_data["storage"] + await storage.touch_user(USER_ID) + await storage.complete_lesson(USER_ID, LESSONS[0].id) + await storage.record_quiz_answer(USER_ID, LESSONS[0].id, 0, True) + + await handlers.progress_command(make_update(recorder), context) + assert f"1/{len(LESSONS)}" in recorder.last + assert "1/1 נכון (100%)" in recorder.last + + +async def test_next_command_skips_completed_lessons(context): + recorder = Recorder() + await context.bot_data["storage"].complete_lesson(USER_ID, LESSONS[0].id) + await handlers.next_command(make_update(recorder), context) + assert LESSONS[1].title in recorder.last + + +async def test_reset_flow_clears_progress(context): + recorder = Recorder() + storage = context.bot_data["storage"] + await storage.complete_lesson(USER_ID, LESSONS[0].id) + + await handlers.reset_command(make_update(recorder), context) + assert "לאפס" in recorder.last + + await handlers.on_callback(make_update(recorder, callback="reset:yes"), context) + assert "אופסה" in recorder.last + assert await storage.completed_lessons(USER_ID) == set() + + +async def test_unknown_lesson_in_callback_is_handled(context): + recorder = Recorder() + await handlers.on_callback(make_update(recorder, callback="lesson:nope"), context) + assert "לא מצאתי" in recorder.last + + +async def test_run_command_reports_output(context): + recorder = Recorder() + update = make_update(recorder, text="/run print(6 * 7)") + await handlers.run_command(update, context) + assert "42" in recorder.last + + +async def test_run_command_blocks_dangerous_code(context): + recorder = Recorder() + update = make_update(recorder, text="/run import os\nos.listdir('/')") + await handlers.run_command(update, context) + assert "חסום" in recorder.last diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..dfdb090 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,102 @@ +"""בדיקות לעיבוד טקסט, לבדיקה ההיוריסטית ולפענוח תשובות המודל.""" + +from __future__ import annotations + +import pytest + +from app.ai import AIUnavailable, _parse_grade +from app.content import LESSONS_BY_ID +from app.handlers import ( + TELEGRAM_LIMIT, + _chunks, + _looks_like_code, + _markdown_code_to_html, + _strip_fences, + heuristic_grade, +) + + +def test_short_text_is_not_split(): + assert _chunks("שלום") == ["שלום"] + + +def test_long_text_is_split_under_the_limit(): + text = "\n".join(f"שורה מספר {i}" for i in range(2000)) + parts = _chunks(text) + assert len(parts) > 1 + assert all(len(part) <= TELEGRAM_LIMIT for part in parts) + assert "".join(part.replace("\n", "") for part in parts) == text.replace("\n", "") + + +def test_text_without_newlines_is_still_split(): + parts = _chunks("א" * (TELEGRAM_LIMIT * 2 + 5)) + assert len(parts) == 3 + assert all(len(part) <= TELEGRAM_LIMIT for part in parts) + + +def test_strip_fences_removes_markdown_wrapper(): + assert _strip_fences("```python\nprint(1)\n```") == "print(1)" + assert _strip_fences("```\nprint(1)\n```") == "print(1)" + assert _strip_fences("print(1)") == "print(1)" + + +def test_looks_like_code_detects_multiline_snippets(): + assert _looks_like_code("for i in range(3):\n print(i)") + assert _looks_like_code("```print(1)```") + assert not _looks_like_code("מה זה לולאה?") + assert not _looks_like_code("איך עושים if בפייתון") + + +def test_markdown_is_converted_to_telegram_html(): + converted = _markdown_code_to_html( + "הנה **דוגמה**:\n```python\nif 1 < 2:\n print('כן')\n```\nוזה `int`" + ) + assert "דוגמה" in converted + assert "
" in converted and "
" in converted + assert "1 < 2" in converted # סימני < בתוך קוד חייבים לברוח + assert "int" in converted + + +def test_unclosed_code_fence_is_closed(): + assert _markdown_code_to_html("```\nprint(1)").endswith("") + + +def test_raw_html_from_the_model_is_escaped(): + assert "") + + +def test_heuristic_grade_accepts_the_reference_solution(): + lesson = LESSONS_BY_ID["02-variables"] + assert heuristic_grade(lesson, lesson.exercise.solution).passed + + +def test_heuristic_grade_rejects_syntax_errors(): + lesson = LESSONS_BY_ID["02-variables"] + grade = heuristic_grade(lesson, "price = 100\nprint(price") + assert not grade.passed and "תחביר" in grade.feedback + + +def test_heuristic_grade_reports_missing_pieces(): + lesson = LESSONS_BY_ID["02-variables"] + grade = heuristic_grade(lesson, "price = 100") + assert not grade.passed and "discount" in grade.feedback + + +def test_parse_grade_reads_plain_json(): + grade = _parse_grade('{"passed": true, "feedback": "יפה"}') + assert grade.passed and grade.feedback == "יפה" + + +def test_parse_grade_reads_json_inside_a_code_fence(): + grade = _parse_grade('```json\n{"passed": false, "feedback": "חסר return"}\n```') + assert not grade.passed and grade.feedback == "חסר return" + + +def test_parse_grade_reads_json_with_surrounding_prose(): + grade = _parse_grade('בדקתי:\n{"passed": true, "feedback": "נכון"}\nבהצלחה') + assert grade.passed + + +def test_parse_grade_raises_on_garbage(): + with pytest.raises(AIUnavailable): + _parse_grade("אין לי מושג") diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..399d159 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,49 @@ +"""בדיקות למריץ הקוד המוגבל ולבדיקת הפתרונות לדוגמה.""" + +from __future__ import annotations + +import pytest + +from app.content import LESSONS +from app.runner import check_code, run_code + +SOLUTIONS_WITH_OUTPUT = [ + lesson + for lesson in LESSONS + if lesson.exercise.expected_output is not None + and "input(" not in lesson.exercise.solution +] + + +@pytest.mark.parametrize( + "lesson", SOLUTIONS_WITH_OUTPUT, ids=lambda lesson: lesson.id +) +async def test_reference_solution_produces_expected_output(lesson): + result = await run_code(lesson.exercise.solution) + assert result.ok, result.combined + assert result.output.strip() == lesson.exercise.expected_output.strip() + + +def test_blocks_dangerous_imports(): + assert check_code("import os") is not None + assert check_code("from subprocess import run") is not None + assert check_code("open('/etc/passwd')") is not None + assert check_code("print('hi')") is None + + +def test_reports_syntax_errors_before_running(): + problem = check_code("print('hi'") + assert problem is not None and "תחביר" in problem + + +async def test_runaway_loop_is_stopped(): + result = await run_code("while True:\n pass") + assert not result.ok + assert "מגבלות" in result.error or "זמן" in result.error + + +async def test_runtime_error_is_reported_briefly(): + result = await run_code("print(1 / 0)") + assert not result.ok + assert "ZeroDivisionError" in result.error + assert result.error.count("\n") == 0 diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..f71f2f1 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,69 @@ +"""בדיקות לשכבת האחסון.""" + +from __future__ import annotations + +import pytest + +from app.storage import Storage + + +@pytest.fixture +async def storage(tmp_path): + store = Storage(tmp_path / "test.sqlite3") + await store.init() + return store + + +async def test_new_user_starts_with_streak_one(storage): + assert await storage.touch_user(1, "דנה", "dana") == 1 + assert await storage.touch_user(1, "דנה", "dana") == 1 + + +async def test_lesson_completion_is_idempotent(storage): + await storage.touch_user(2) + await storage.complete_lesson(2, "01-hello") + await storage.complete_lesson(2, "01-hello") + await storage.complete_lesson(2, "02-variables") + + assert await storage.completed_lessons(2) == {"01-hello", "02-variables"} + assert (await storage.stats(2)).lessons_done == 2 + + +async def test_quiz_stats_are_accumulated(storage): + await storage.touch_user(3) + await storage.record_quiz_answer(3, "01-hello", 0, True) + await storage.record_quiz_answer(3, "01-hello", 1, False) + await storage.record_quiz_answer(3, "02-variables", 0, True) + + stats = await storage.stats(3) + assert stats.quiz_total == 3 + assert stats.quiz_correct == 2 + assert stats.quiz_accuracy == 67 + + +async def test_exercise_pass_is_sticky(storage): + await storage.touch_user(4) + await storage.record_exercise(4, "05-conditions", False) + await storage.record_exercise(4, "05-conditions", True) + await storage.record_exercise(4, "05-conditions", False) + + assert (await storage.stats(4)).exercises_passed == 1 + + +async def test_reset_clears_progress_only_for_that_user(storage): + await storage.touch_user(5) + await storage.touch_user(6) + await storage.complete_lesson(5, "01-hello") + await storage.complete_lesson(6, "01-hello") + + await storage.reset(5) + + assert await storage.completed_lessons(5) == set() + assert await storage.completed_lessons(6) == {"01-hello"} + + +async def test_stats_for_unknown_user_are_empty(storage): + stats = await storage.stats(999) + assert stats.lessons_done == 0 + assert stats.quiz_accuracy == 0 + assert stats.streak == 0 diff --git a/tests/test_wiring.py b/tests/test_wiring.py new file mode 100644 index 0000000..b1e97e3 --- /dev/null +++ b/tests/test_wiring.py @@ -0,0 +1,123 @@ +"""בדיקות חיווט: שהאפליקציה נבנית ושכל כפתור מוביל לפעולה מוכרת.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from telegram.ext import CallbackQueryHandler, CommandHandler, MessageHandler + +import main +from app.config import Config +from app.content import LESSONS +from app import keyboards as kb + +FAKE_TOKEN = "123456789:AAExampleTokenForTestsOnly_0000000000000" + +KNOWN_ACTIONS = { + kb.CB_LESSON, + kb.CB_EXAMPLE, + kb.CB_EXERCISE, + kb.CB_SOLUTION, + kb.CB_HINT, + kb.CB_QUIZ, + kb.CB_ANSWER, + kb.CB_NEXT, + kb.CB_MENU, + "reset", +} + + +@pytest.fixture +def config(tmp_path: Path) -> Config: + return Config( + telegram_token=FAKE_TOKEN, + anthropic_api_key=None, + claude_model="claude-opus-5", + webhook_url=None, + webhook_secret=None, + port=10000, + data_dir=tmp_path, + enable_code_runner=False, + ) + + +def _all_callback_data() -> list[str]: + data: list[str] = ["reset:yes", "reset:no"] + for lesson in LESSONS: + markups = [ + kb.lesson_keyboard(lesson), + kb.example_keyboard(lesson), + kb.exercise_keyboard(lesson), + kb.menu_keyboard({lesson.id}), + ] + for index in range(len(lesson.quizzes)): + markups.append(kb.quiz_keyboard(lesson, index)) + markups.append(kb.after_quiz_keyboard(lesson, index)) + for markup in markups: + for row in markup.inline_keyboard: + for button in row: + if button.callback_data: + data.append(button.callback_data) + return data + + +def test_application_builds_with_all_handlers(config): + application = main.build_application(config) + handlers = application.handlers[0] + + assert any(isinstance(handler, CallbackQueryHandler) for handler in handlers) + assert any(isinstance(handler, MessageHandler) for handler in handlers) + + commands = { + name + for handler in handlers + if isinstance(handler, CommandHandler) + for name in handler.commands + } + assert { + "start", "help", "lessons", "lesson", "next", "quiz", + "practice", "progress", "ask", "run", "cancel", "reset", + } <= commands + + assert application.bot_data["enable_code_runner"] is False + assert application.bot_data["tutor"].enabled is False + + +def test_menu_commands_are_registered_handlers(config): + application = main.build_application(config) + registered = { + name + for handler in application.handlers[0] + if isinstance(handler, CommandHandler) + for name in handler.commands + } + for command in main.COMMANDS: + assert command.command in registered + + +def test_every_button_maps_to_a_known_action(): + for data in _all_callback_data(): + action = data.split(":")[0] + assert action in KNOWN_ACTIONS, data + + +def test_callback_data_stays_within_telegram_limit(): + for data in _all_callback_data(): + assert len(data.encode("utf-8")) <= 64, data + + +def test_quiz_buttons_cover_every_option(): + lesson = LESSONS[0] + markup = kb.quiz_keyboard(lesson, 0) + assert len(markup.inline_keyboard) == len(lesson.quizzes[0].options) + + +def test_last_lesson_has_no_next_button(): + markup = kb.lesson_keyboard(LESSONS[-1]) + data = [ + button.callback_data + for row in markup.inline_keyboard + for button in row + ] + assert not any(item.startswith(f"{kb.CB_NEXT}:") for item in data)