Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.venv/
venv/
.env
*.db
data/
.pytest_cache/
.DS_Store
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: python main.py
126 changes: 126 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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, זיכרון, גודל פלט וכתיבה לקבצים, וחוסם ייבוא מודולים רגישים - אבל אלו
שכבות הגנה, לא בידוד מלא. להפעלה בסביבה ציבורית מומלץ להריץ בקונטיינר ייעודי
ללא גישת רשת. כשהתכונה כבויה, תרגילים נבדקים בלי להריץ קוד כלל.
3 changes: 3 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""בוט טלגרם שמלמד Python."""

__all__ = ["config", "storage", "ai", "handlers", "keyboards", "runner"]
189 changes: 189 additions & 0 deletions app/ai.py
Original file line number Diff line number Diff line change
@@ -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"<task>\n{task}\n</task>\n\n"
f"<reference_solution>\n{solution}\n</reference_solution>\n\n"
f"<student_code>\n{submission}\n</student_code>"
)
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
Loading