tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed

Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 06:24:13 +03:30
parent fbdb9495ee
commit 42783f9ba4
56 changed files with 2792 additions and 326 deletions

View File

@@ -35,10 +35,24 @@ def make_key(collection, filter_value, question: str, grade_title: str = '') ->
یعنی پاسخی که برای دانش‌آموزِ نهم ساخته شده عیناً به دانش‌آموزِ سوم
برمی‌گشت.
"""
raw = f'{collection}|{filter_value}|{(grade_title or "").strip()}|{_norm(question)}'
from .generation import PROMPT_VERSION
# نسخه‌ی پرامپت هم جزءِ کلید است: بعد از عوض شدنِ پرامپت، جوابِ نسخه‌ی
# قبلی (مثلاً با LaTeX) نباید تا پایانِ TTL دوباره سرو شود.
raw = (f'{PROMPT_VERSION}|{collection}|{filter_value}|'
f'{(grade_title or "").strip()}|{_norm(question)}')
return _PREFIX + hashlib.sha1(raw.encode('utf-8')).hexdigest()
def cacheable(history) -> bool:
"""
فقط پیامِ اولِ هر گفتگو کش می‌شود. «نفهمیدم» یا «یه مثال دیگه بزن» معنایش
به گفتگوی قبلی بسته است؛ پیش‌تر کلید فقط متنِ سوال بود و هر «نفهمیدم» در
همان کتاب، جوابِ کش‌شده‌ی گفتگوی یک نفرِ دیگر را می‌گرفت.
"""
return not history
def get(key: str) -> dict | None:
if not enabled():
return None

View File

@@ -50,6 +50,24 @@ def get_qdrant_client():
)
def is_account_limit(exc) -> bool:
"""
آیا این خطا یعنی «حسابِ سرویس دیگر جواب نمی‌دهد» یا فقط کندیِ لحظه‌ای؟
دو شکلِ واقعی که دیدیم و هر دو باید اجرا را متوقف کنند:
429 initial_usage_limit_exceeded — سقفِ حسابِ تازه، ساعت‌ها
403 insufficient user quota — اعتبار تمام شده
نکته: متنِ دومی «insufficient user quota» است با فاصله، نه زیرخط. محافظِ
اولِ ما دنبالِ `insufficient_quota` می‌گشت و نمی‌گرفتش، و اجرا ۷۷۰ فراخوانیِ
محکوم‌به‌شکست زد به‌جای اینکه سرِ اولی بایستد.
"""
text = str(exc).lower()
if 'usage_limit' in text or 'quota' in text:
return True
return '403' in text and 'insufficient' in text
def reset_clients_cache():
"""برای تست‌ها / بعد از تغییرِ settings در زمانِ اجرا."""
get_openai_client.cache_clear()

View File

@@ -1,10 +1,21 @@
"""
مرحله‌ی generation: پرسش + context بازیابی‌شده → پاسخِ فارسی + چند پیشنهادِ
ادامه.
مرحله‌ی generation: پرسش + context بازیابی‌شده (+ چند پیامِ قبلیِ همان گفتگو)
→ پاسخِ فارسی + چند پیشنهادِ ادامه.
اگه context خالی باشه، مدل به‌عنوانِ معلمِ عمومی جواب می‌ده (بدونِ ادعای
استناد به کتاب) — این حالت وقتی پیش می‌آد که subject/lessonNumber ارسال
نشده یا اون درس هنوز در Qdrant نیست.
اگه context خالی باشه، مدل به‌عنوانِ معلمِ همان پایه جواب می‌ده (بدونِ ادعای
استناد به کتاب) — این حالت وقتی پیش می‌آد که کتاب/فصل معلوم نیست یا اون درس
هنوز در Qdrant نیست.
سه تصمیم که اینجا گرفته شده و دلیلشان:
1. **خروجی متنِ ساده است، نه LaTeX/markdown.** اپ پاسخ را خام نشان می‌دهد؛
`\\frac{1}{2}` روی گوشی عیناً دیده می‌شد. پرامپت متنِ ساده می‌خواهد و
`mathtext.to_plain` پشتش تورِ ایمنی است.
2. **حل کامل و مرحله‌به‌مرحله، با وارسی پیش از جوابِ نهایی.** تصمیمِ صاحبِ
پروژه (۲۰۲۶-۰۹-۲۴): دستیار راه‌حلِ کامل را توضیح می‌دهد، نه فقط راهنمایی.
وارسی (جای‌گذاری، مشتق‌گیریِ دوباره) عمداً در پرامپت است: پاسخی از
gpt-4o-mini مشتقِ x³ − 6x² + 9x را 3x² − 12x نوشته بود.
3. **لحن سه سطح دارد** (دبستان / متوسطه‌ی اول / متوسطه‌ی دوم)، نه فقط نامِ پایه.
"""
import logging
import time
@@ -13,6 +24,7 @@ from collections.abc import Iterator
from django.conf import settings
from . import clients
from .mathtext import StreamSanitizer, to_plain
logger = logging.getLogger(__name__)
@@ -21,68 +33,200 @@ _DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح
# ⚠️ با هر تغییرِ معنادار در متنِ پرامپت‌های پایین، این را یکی جلو ببرید.
# روی هر پیامِ دستیار ذخیره می‌شود تا وقتی لایک/دیسلایکِ کاربرها جمع شد،
# بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست.
PROMPT_VERSION = 'tutor-v2-grade-aware'
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه
# کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود.
PROMPT_VERSION = 'tutor-v3-stepwise-plain'
# پایه‌ها تا ششم دبستان‌اند، هفتم تا نهم متوسطه‌ی اول و دهم به بعد متوسطه‌ی دوم.
_SECONDARY_GRADES = {'هفتم', 'هشتم', 'نهم', 'دهم', 'یازدهم', 'دوازدهم'}
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
_UPPER = {'دهم', 'یازدهم', 'دوازدهم'}
# چند پیامِ آخرِ گفتگو که همراهِ سوالِ تازه فرستاده می‌شود. سه رفت‌وبرگشت
# برای «نفهمیدم» و «یه مثال دیگه» کافی است و هزینه‌ی توکن را محدود نگه می‌دارد.
HISTORY_TURNS = 6
_HISTORY_CHARS = 1500
def grade_tier(grade_title: str) -> str:
"""'primary' | 'middle' | 'upper' | '' (نامعلوم)."""
grade = (grade_title or '').strip()
if grade in _PRIMARY:
return 'primary'
if grade in _MIDDLE:
return 'middle'
if grade in _UPPER:
return 'upper'
return ''
def _persona(grade_title: str) -> tuple[str, str]:
"""(توصیفِ معلم، توصیفِ دانش‌آموز) متناسب با پایه."""
grade = (grade_title or '').strip()
if grade in _SECONDARY_GRADES:
tier = grade_tier(grade)
if tier in ('middle', 'upper'):
return f'معلمِ ریاضیِ پایه‌ی {grade}', f'دانش‌آموزِ پایه‌ی {grade}'
if grade:
return f'معلمِ پایه‌ی {grade} دبستان', f'دانش‌آموزِ پایه‌ی {grade}'
return 'معلمِ فارسی‌زبان', 'دانش‌آموز'
if tier == 'primary':
return f'معلمِ پایه‌ی {grade} دبستان', f'دانش‌آموزِ پایه‌ی {grade} دبستان'
return 'معلمِ ریاضیِ فارسی‌زبان', 'دانش‌آموز'
_TONE = {
'primary': (
'با یک بچه‌ی دبستانی حرف می‌زنی: مهربان، صبور و تشویق‌کننده.\n'
'- جمله‌ها کوتاه و ساده باشند؛ هر مرحله فقط یک کار.\n'
'- با مثال‌های روزمره توضیح بده (سیب، شکلات، پول، خط‌کش، ساعت).\n'
'- اگر اصطلاحِ کتاب را به‌کار می‌بری، اول با زبانِ خودمانی معنی‌اش را بگو.\n'
'- حداکثر یک ایموجیِ تشویقی در کلِ پاسخ.'
),
'middle': (
'با یک نوجوانِ متوسطه‌ی اول حرف می‌زنی: دوستانه و روشن، مثلِ معلمی که '
'بچه‌ها دوستش دارند.\n'
'- اصطلاح‌های کتاب را به‌کار ببر و هرجا لازم است با یک جمله معنی‌اش را بگو.\n'
'- قاعده‌ای را که در هر مرحله استفاده می‌کنی نام ببر (مثلاً «اتحادِ مربعِ دوجمله‌ای»).'
),
'upper': (
'با یک دانش‌آموزِ دبیرستان حرف می‌زنی: دقیق، فشرده و محترمانه.\n'
'- نمادگذاری و اصطلاح‌های همان کتاب را به‌کار ببر.\n'
'- در هر مرحله قاعده یا قضیه‌ای را که استفاده می‌کنی نام ببر '
'(مثلاً «مشتقِ حاصل‌ضرب»، «قضیه‌ی فیثاغورس»، «رفعِ ابهامِ 0/0»).\n'
'- شرط‌ها را صریح چک کن: دامنه، مخرجِ مخالفِ صفر، زیرِ رادیکالِ نامنفی، '
'علامتِ عبارت، واحدِ زاویه.'
),
'': 'دوستانه، روشن و دقیق حرف بزن.',
}
_FORMAT = (
'قالبِ پاسخ (مهم — پاسخ در یک اپِ موبایل به‌صورتِ متنِ ساده نمایش داده می‌شود):\n'
'- فقط متنِ ساده. هیچ LaTeX ننویس (نه \\( \\)، نه \\[ \\]، نه \\frac، نه $) و '
'هیچ markdown ننویس (نه #، نه **، نه جدول).\n'
'- برای ریاضی از نمادهای یونیکد استفاده کن: x²، x³، √2، ∛8، ×، ÷، ±، ≤، ≥، ≠، '
'≈، π، ∞، ∈، ∪، ∩، →، °، f′(x).\n'
'{numbers}\n'
'- هر مرحله در یک خطِ جدا و شماره‌دار باشد: «۱) …»، «۲) …». هر فرمولِ مهم '
'در خطِ خودش.\n'
'- بی‌مقدمه شروع کن؛ سوال را تکرار نکن و حرفِ اضافه نزن.'
)
# در کتاب‌های درسیِ ایران «/» بینِ دو رقم ممیزِ اعشار است (۲/۵ یعنی دو و نیم).
# بچه‌ی دبستانی همین را می‌شناسد؛ پس برای او کسر با کلمه نوشته می‌شود. از
# هفتم به بعد فرمول‌ها طولانی‌اند و (x+1)/(x−2) بی‌جایگزین است، پس ممیز نقطه
# می‌شود تا «/» فقط یک معنا داشته باشد.
_NUMBERS = {
'primary': (
'- مثلِ کتابِ درسیِ خودش بنویس: رقم‌های فارسی، و ممیزِ اعشار با «/» (۲/۵ یعنی '
'دو و نیم). کسر را با کلمه بنویس: «۳ بر ۴» یا «سه‌چهارم» — هرگز با «/».'
),
'default': (
'- اعدادِ داخلِ فرمول را با رقمِ انگلیسی بنویس و ممیزِ اعشار را با نقطه '
'(3.5). «/» فقط یعنی کسر: (x+1)/(x−2).'
),
}
_METHOD = (
'روشِ پاسخ:\n'
'- اگر سوال یک مسئله است، آن را کامل و مرحله‌به‌مرحله حل کن تا دانش‌آموز '
'بتواند مسیر را دنبال کند؛ در هر مرحله بگو چه می‌کنیم و چرا. هیچ مرحله‌ی '
'محاسبه‌ای را جا نینداز.\n'
'- اگر سوال مفهومی است، مفهوم را با یک مثالِ عددیِ کوتاه و حل‌شده توضیح بده.\n'
'- دقت از همه‌چیز مهم‌تر است. پیش از نوشتنِ جوابِ نهایی آن را وارسی کن '
'(جواب را در معادله جای‌گذاری کن، مشتق یا ساده‌سازی را دوباره انجام بده، '
'بزرگیِ عدد را تخمین بزن). اگر در وارسی اشتباهی دیدی، راه‌حل را اصلاح کن؛ '
'هرگز جوابی را که وارسی نکرده‌ای ننویس.\n'
'- اگر مسئله جوابِ مشخصی دارد، خطِ آخرِ حل این باشد: «✅ جواب: …».\n'
'- اگر اطلاعاتِ مسئله ناقص است (مثلاً به شکلی اشاره می‌کند که نمی‌بینی)، '
'بگو چه چیزی لازم است، به‌جای حدس زدن.\n'
'- اگر دانش‌آموز می‌گوید نفهمیده یا توضیحِ بیشتر می‌خواهد، از گفتگوی قبلی '
'بفهم درباره‌ی چیست و همان را ساده‌تر، با راهی دیگر یا مثالی تازه توضیح بده.\n'
'- در پایانِ حلِ یک مسئله، یک تمرینِ کوتاهِ مشابه بده: «حالا خودت امتحان کن: …» '
'(بدونِ جوابش).\n'
'- اگر سوال ربطی به درس و مدرسه ندارد، مؤدبانه و کوتاه بگو که کارت کمک در '
'ریاضی است و بچه را به درس برگردان.'
)
def system_prompt(grade_title: str = '') -> str:
"""
قبلاً «معلمِ دبستانی» ثابت بود. برای پایه‌های نهم/دهم این هم لحن را بچگانه
می‌کرد و هم مدل را وادار می‌کرد مفاهیمی مثل اتحاد، مثلثات و تابع را تا حدِ
دبستان ساده کند — یعنی دقیقاً همان‌جایی که بچه‌ها گیر می‌کنند بی‌فایده
می‌شد. حالا لحن از پایه می‌آید.
قبلاً «معلمِ دبستانی» ثابت بود؛ بعد فقط نامِ پایه در آن آمد. حالا لحن،
روش (حلِ کامل + وارسی) و قالب (متنِ ساده) هر سه صریح‌اند.
"""
teacher, _ = _persona(grade_title)
teacher, student = _persona(grade_title)
numbers = _NUMBERS['primary' if grade_tier(grade_title) == 'primary' else 'default']
return (
f'تو یک {teacher} و فارسی‌زبان هستی. پاسخ‌ها را روشن، دقیق و '
'گام‌به‌گام می‌دهی و از نمادها و اصطلاح‌های ریاضیِ همان پایه استفاده '
'می‌کنی؛ نه ساده‌تر از سطحِ کتاب، نه پیچیده‌تر از آن.'
f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} '
f'با یک {student} حرف می‌زنی. همیشه فارسی جواب بده.\n\n'
f'{_TONE[grade_tier(grade_title)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
)
def _prompt_with_context(question: str, contexts: list[str], grade_title: str = '') -> str:
joined = '\n\n---\n\n'.join(contexts)
teacher, student = _persona(grade_title)
return f"""
تو {teacher} هستی. با تکیه بر «متنِ درس» زیر و با زبانِ خودت، به سوالِ
{student} پاسخِ روشن و گام‌به‌گام بده. لازم نیست پاسخ عیناً در متن باشد؛
می‌توانی از اطلاعاتِ متن نتیجه‌گیری و توضیح بدهی.
اگر مسئله چند مرحله دارد، مرحله‌ها را جدا و شماره‌گذاری‌شده بنویس.
فقط اگر متنِ درس آشکارا درباره‌ی موضوعِ دیگری‌ست و هیچ ربطی به سوال ندارد،
بنویس: «این موضوع در این درس پوشش داده نشده است.»
def _user_prompt(question: str, contexts: list[str], focus: str = '') -> str:
asked = f'سوالِ دانش‌آموز: {question}'
if focus:
asked = f'{focus}\n\n{asked}'
if not contexts:
return asked if focus else question
excerpts = '\n\n'.join(f'[{i}] {text}' for i, text in enumerate(contexts, 1))
return (
'چند بخش از کتابِ درسیِ همین دانش‌آموز که با جستجو پیدا شده‌اند '
'(ممکن است همه مربوط نباشند):\n\n'
f'{excerpts}\n\n'
'— پایانِ بخش‌های کتاب —\n'
'اگر به سوال مربوط‌اند، از روش و نمادگذاریِ همان‌ها پیروی کن؛ اگر مربوط '
'نیستند نادیده‌شان بگیر و از دانشِ خودت در سطحِ همین پایه جواب بده. '
'متنِ کتاب از روی تصویر خوانده شده و ممکن است غلطِ تایپی داشته باشد؛ '
'حسابِ خودت را ملاک بگذار. در متنِ کتاب «/» بینِ دو رقم ممیزِ اعشار است '
'(۳/۱۰۴ یعنی 3.104)، نه کسر.\n\n'
f'{asked}'
)
متنِ درس:
{joined}
سوالِ {student}: {question}
def chat_model_for(grade_title: str = '') -> str:
"""
def _prompt_without_context(question: str, grade_title: str = '') -> str:
teacher, student = _persona(grade_title)
return f"""
تو {teacher} هستی. به این سوالِ یک {student} به فارسی، روشن و گام‌به‌گام
پاسخ بده.
سوال: {question}
مدلِ چت برای این پایه. `OPENAI_CHAT_MODEL_UPPER` (اختیاری) فقط برای
دهم تا دوازدهم است، جایی که حسابان/مثلثات مدلِ قوی‌تری می‌خواهد و
هزینه‌ی اضافه‌اش ارزش دارد؛ خالی یعنی همه‌ی پایه‌ها یک مدل.
"""
upper = getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or ''
if upper and grade_tier(grade_title) == 'upper':
return upper
return settings.OPENAI_CHAT_MODEL
def is_reasoning_model(model: str) -> bool:
"""مدل‌های reasoning (o-series، gpt-5) temperature نمی‌پذیرند."""
name = (model or '').lower().split('/')[-1]
return name.startswith(('o1', 'o3', 'o4', 'gpt-5'))
def completion_kwargs(model: str) -> dict:
if is_reasoning_model(model):
effort = getattr(settings, 'OPENAI_CHAT_REASONING_EFFORT', '') or ''
return {'reasoning_effort': effort} if effort else {}
return {'temperature': 0.2}
def build_messages(question: str, context_chunks: list[dict] | None,
grade_title: str = '', history: list[dict] | None = None,
focus: str = '') -> list[dict]:
"""
history: [{'role': 'user'|'assistant', 'text': ...}, ...] به ترتیبِ زمان.
بدونِ history، «نفهمیدم» یا «یه مثال دیگه بزن» (که خودِ اپ به‌عنوانِ
دکمه‌ی پیشنهادی نشان می‌دهد) برای مدل هیچ معنایی نداشت.
"""
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
messages = [{'role': 'system', 'content': system_prompt(grade_title)}]
for turn in (history or [])[-HISTORY_TURNS:]:
role = 'assistant' if turn.get('role') == 'assistant' else 'user'
text = (turn.get('text') or '').strip()
if text:
messages.append({'role': role, 'content': text[:_HISTORY_CHARS]})
messages.append({'role': 'user', 'content': _user_prompt(question, contexts, focus)})
return messages
def generate_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '') -> tuple[str, list[str], dict]:
grade_title: str = '', history: list[dict] | None = None,
model: str | None = None, focus: str = '') -> tuple[str, list[str], dict]:
"""
context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[].
خروجی: (answer, follow_ups, meta)
@@ -90,70 +234,111 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
`meta` همان چیزی‌ست که روی پیامِ دستیار ذخیره می‌شود: مدل، نسخه‌ی پرامپت،
توکنِ مصرف‌شده و مدتِ تولید. بدونِ اینها نه می‌شود فهمید کدام نسخه‌ی پرامپت
بهتر جواب می‌دهد، نه اینکه چقدر از سهمیه‌ی API خرج شده است.
`model` فقط برای مقایسه‌ی مدل‌ها (`rag_model_benchmark`) است.
"""
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
prompt = (
_prompt_with_context(question, contexts, grade_title) if contexts
else _prompt_without_context(question, grade_title)
)
model = model or chat_model_for(grade_title)
messages = build_messages(question, context_chunks, grade_title, history, focus)
client = clients.get_openai_client()
started = time.monotonic()
completion = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL,
messages=[
{'role': 'system', 'content': system_prompt(grade_title)},
{'role': 'user', 'content': prompt},
],
temperature=0.3,
model=model, messages=messages, **completion_kwargs(model),
)
latency_ms = int((time.monotonic() - started) * 1000)
answer = (completion.choices[0].message.content or '').strip()
raw = (completion.choices[0].message.content or '').strip()
answer = to_plain(raw, fraction_word=grade_tier(grade_title) == 'primary')
if not answer:
answer = 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم. لطفاً دوباره بپرس.'
usage = getattr(completion, 'usage', None)
meta = {
'model': settings.OPENAI_CHAT_MODEL,
'model': model,
'promptVersion': PROMPT_VERSION,
'latencyMs': latency_ms,
'promptTokens': getattr(usage, 'prompt_tokens', None) or 0,
'completionTokens': getattr(usage, 'completion_tokens', None) or 0,
'hadContext': bool(contexts),
'hadContext': any(c.get('summary') for c in (context_chunks or [])),
# آیا تورِ ایمنی چیزی را عوض کرد؟ نشانِ این است که مدل قاعده‌ی «متنِ
# ساده» را رعایت نکرده — برای مقایسه‌ی مدل‌ها در rag_model_benchmark.
'sanitized': answer != raw,
}
return answer, list(_DEFAULT_FOLLOW_UPS), meta
def stream_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '') -> Iterator[str]:
grade_title: str = '', history: list[dict] | None = None,
focus: str = '') -> Iterator[str]:
"""
همون generate_answer ولی توکن‌به‌توکن (برای SSE). فقط قطعه‌های متنِ پاسخ
را yield می‌کند؛ follow_upها ثابت‌اند (_DEFAULT_FOLLOW_UPS) و caller خودش
اضافه‌شان می‌کند.
"""
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
prompt = (
_prompt_with_context(question, contexts, grade_title) if contexts
else _prompt_without_context(question, grade_title)
)
همون generate_answer ولی تکه‌به‌تکه (برای SSE). فقط متنِ پاسخ را yield
می‌کند؛ follow_upها ثابت‌اند و caller خودش اضافه‌شان می‌کند.
متن خط‌به‌خط از StreamSanitizer رد می‌شود، چون یک دستورِ LaTeX ممکن است
بینِ دو تکه‌ی استریم نصف شود.
"""
model = chat_model_for(grade_title)
client = clients.get_openai_client()
stream = client.chat.completions.create(
model=settings.OPENAI_CHAT_MODEL,
messages=[
{'role': 'system', 'content': system_prompt(grade_title)},
{'role': 'user', 'content': prompt},
],
temperature=0.3,
model=model,
messages=build_messages(question, context_chunks, grade_title, history, focus),
stream=True,
**completion_kwargs(model),
)
sanitizer = StreamSanitizer(fraction_word=grade_tier(grade_title) == 'primary')
for event in stream:
if not event.choices:
continue
delta = event.choices[0].delta.content or ''
if delta:
yield delta
clean = sanitizer.feed(delta)
if clean:
yield clean
tail = sanitizer.flush()
if tail:
yield tail
def question_focus(question_id, user_id) -> str:
"""
متنِ سوالی از اپ که دانش‌آموز درباره‌اش می‌پرسد، با گزینه‌ها، آخرین جوابِ
خودش و جوابِ درست — تا دستیار بگوید اشتباه دقیقاً کجاست.
سینیور خواسته بود ثابت شود دستیار «همان‌جایی که بچه‌ها گیر می‌کنند» کمک
می‌کند؛ بدونِ این، دستیار سوالی را که بچه همین الان غلط زده نمی‌دید.
خالی برمی‌گرداند اگر سوال پیدا نشود (هرگز خطا نمی‌دهد).
"""
try:
from content.models import Question, UserAnswer
question = Question.objects.prefetch_related('choices').filter(pk=int(question_id)).first()
except (TypeError, ValueError):
return ''
except Exception: # noqa: BLE001 — اپ content در دسترس نیست
logger.exception('question_focus: خواندنِ سوال %s شکست خورد', question_id)
return ''
if question is None:
return ''
choices = list(question.choices.all())
correct = next((c.value for c in choices if c.is_correct), '')
last = (
UserAnswer.objects.filter(user_id=user_id, question=question)
.select_related('choice').order_by('-id').first()
)
lines = [
'دانش‌آموز درباره‌ی این سوالِ تمرینِ اپ می‌پرسد:',
f'صورتِ سوال: {question.prompt}',
'گزینه‌ها: ' + ' | '.join(c.value for c in choices),
f'جوابِ درست: {correct}',
]
if last is not None:
verdict = 'درست' if last.correct else 'غلط'
lines.append(f'آخرین جوابِ دانش‌آموز: {last.choice.value} ({verdict})')
if not last.correct:
lines.append(
'اول بگو اشتباهِ احتمالیِ او در کدام مرحله بوده (بدونِ سرزنش)، بعد '
'راه‌حلِ درست را مرحله‌به‌مرحله توضیح بده.'
)
return '\n'.join(lines)
def default_follow_ups() -> list[str]:

308
rag/services/mathtext.py Normal file
View File

@@ -0,0 +1,308 @@
"""
تبدیلِ LaTeX و markdown در پاسخِ دستیار به متنِ ساده با نمادهای یونیکد.
چرا لازم است: صفحه‌ی «از پی بپرس» پاسخ را همان‌طور که هست، به‌صورتِ متنِ خام
نشان می‌دهد. مدل‌ها برای ریاضی عادت دارند `\\( \\frac{1}{2} \\)` و `### مرحله ۱`
بنویسند؛ روی گوشی این‌ها عیناً دیده می‌شوند و فرمولِ حسابان به رشته‌ای از
بک‌اسلش و آکولاد تبدیل می‌شود. پرامپت از مدل می‌خواهد متنِ ساده بنویسد، ولی
مدل همیشه گوش نمی‌دهد — این ماژول تورِ ایمنیِ قطعی پشتِ آن است.
قاعده‌ی مهم: آکولادی که جزوِ LaTeX نیست دست نمی‌خورد. مجموعه‌ها در ریاضیِ نهم
با `{1, 2, 3}` نوشته می‌شوند و نباید پاک شوند.
"""
import re
from contextvars import ContextVar
# برای دبستان «/» ممیزِ اعشار است (مثلِ کتابشان)، پس کسر «۳ بر ۴» نوشته می‌شود.
_FRACTION_WORD: ContextVar[bool] = ContextVar('fraction_word', default=False)
_SUPERSCRIPT = str.maketrans('0123456789+-=()nixyk', '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱˣʸᵏ')
_SUBSCRIPT = str.maketrans('0123456789+-=()', '₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎')
_SUP_CHARS = set('0123456789+-=()nixyk')
_SUB_CHARS = set('0123456789+-=()')
_SYMBOLS = {
'times': '×', 'cdot': '·', 'div': '÷', 'pm': '±', 'mp': '∓',
'leq': '≤', 'le': '≤', 'geq': '≥', 'ge': '≥', 'neq': '≠', 'ne': '≠',
'approx': '≈', 'simeq': '≃', 'equiv': '≡', 'infty': '∞',
'pi': 'π', 'alpha': 'α', 'beta': 'β', 'gamma': 'γ', 'theta': 'θ',
'Delta': 'Δ', 'delta': 'δ', 'lambda': 'λ', 'mu': 'μ', 'sigma': 'σ',
'Sigma': 'Σ', 'phi': 'φ', 'varphi': 'φ', 'omega': 'ω', 'epsilon': 'ε',
'varepsilon': 'ε', 'rho': 'ρ', 'tau': 'τ',
'rightarrow': '→', 'to': '→', 'leftarrow': '←', 'Rightarrow': '⇒',
'implies': '⇒', 'Leftrightarrow': '⇔', 'iff': '⇔', 'mapsto': '↦',
'in': '∈', 'notin': '∉', 'subset': '⊂', 'subseteq': '⊆', 'supset': '⊃',
'cup': '∪', 'cap': '∩', 'emptyset': '∅', 'varnothing': '∅',
'forall': '∀', 'exists': '∃', 'angle': '∠', 'perp': '⊥', 'parallel': '∥',
'triangle': '△', 'circ': '∘', 'degree': '°', 'prime': '′',
'ldots': '…', 'dots': '…', 'cdots': '⋯', 'int': '∫', 'sum': 'Σ',
'prod': '∏', 'therefore': '∴', 'propto': '∝', 'neg': '¬',
'quad': ' ', 'qquad': ' ', 'lbrace': '{', 'rbrace': '}',
'setminus': '−', 'mid': '|', 'vert': '|',
}
_PREFIX_SIGNS = {'pm', 'mp', 'neg', 'angle', 'triangle', 'forall', 'exists'}
_BLACKBOARD = {'R': 'ℝ', 'N': 'ℕ', 'Z': 'ℤ', 'Q': 'ℚ', 'C': 'ℂ'}
# \sin و \log و ... فقط بک‌اسلششان حذف می‌شود.
_FUNCTIONS = {
'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'log', 'ln', 'exp', 'lim',
'max', 'min', 'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'det', 'gcd',
}
# دستورهایی که فقط محتوای آرگومانشان می‌ماند.
_UNWRAP = {'text', 'textbf', 'textit', 'mathrm', 'mathbf', 'mathit', 'operatorname',
'boxed', 'displaystyle', 'mbox', 'underline', 'bm', 'textrm'}
_DROP = {'left', 'right', 'big', 'Big', 'bigg', 'Bigg', 'displaystyle', 'limits',
'nonumber', 'hline', 'noindent'}
def _read_group(s: str, i: int) -> tuple[str, int]:
"""از s[i] یک آرگومانِ LaTeX می‌خواند: {…} با آکولادِ تودرتو، یا یک نویسه."""
while i < len(s) and s[i] == ' ':
i += 1
if i >= len(s):
return '', i
if s[i] != '{':
if s[i] == '\\':
m = re.match(r'\\[A-Za-z]+', s[i:])
if m:
return m.group(0), i + len(m.group(0))
return s[i], i + 1
depth, j = 0, i
while j < len(s):
if s[j] == '\\' and j + 1 < len(s):
j += 2
continue
if s[j] == '{':
depth += 1
elif s[j] == '}':
depth -= 1
if depth == 0:
return s[i + 1:j], j + 1
j += 1
return s[i + 1:], len(s)
_ATOM = re.compile(r'√?[\d.]+|√?\w|\(.*\)')
def _needs_parens(expr: str) -> bool:
"""«2h» در مخرج باید (2h) شود، وگرنه 1/2h یعنی h/2."""
return not _ATOM.fullmatch(expr.strip())
def _script(body: str, table, allowed: set, marker: str) -> str:
if body and all(ch in allowed for ch in body):
return body.translate(table)
return f'{marker}({body})' if len(body) > 1 else f'{marker}{body}'
def _convert(s: str) -> str:
"""یک عبارتِ LaTeX را (بازگشتی) به یونیکد تبدیل می‌کند."""
out: list[str] = []
i = 0
while i < len(s):
ch = s[i]
if ch == '\\':
if i + 1 < len(s) and not s[i + 1].isalpha():
nxt = s[i + 1]
if nxt == '\\':
out.append('\n')
elif nxt in '{}%$&#_':
out.append(nxt)
elif nxt in ',;:! ':
out.append(' ' if nxt != '!' else '')
else:
out.append(nxt)
i += 2
continue
m = re.match(r'\\([A-Za-z]+)', s[i:])
name = m.group(1)
i += len(m.group(0))
if name == 'frac' or name in ('dfrac', 'tfrac'):
num, i = _read_group(s, i)
den, i = _read_group(s, i)
num, den = _convert(num), _convert(den)
num = f'({num})' if _needs_parens(num) else num
den = f'({den})' if _needs_parens(den) else den
out.append(f'{num} بر {den}' if _FRACTION_WORD.get() else f'{num}/{den}')
elif name == 'sqrt':
index = ''
if i < len(s) and s[i] == '[':
close = s.find(']', i)
if close != -1:
index, i = s[i + 1:close], close + 1
body, i = _read_group(s, i)
body = _convert(body)
root = (index.translate(_SUPERSCRIPT) if index else '') + '√'
out.append(root + (f'({body})' if len(body) > 1 else body))
elif name == 'mathbb':
body, i = _read_group(s, i)
out.append(_BLACKBOARD.get(body, body))
elif name in ('vec', 'overrightarrow'):
body, i = _read_group(s, i)
out.append(_convert(body) + '⃗')
elif name in ('overline', 'bar', 'widehat', 'hat'):
body, i = _read_group(s, i)
out.append(_convert(body))
elif name in ('begin', 'end'):
_env, i = _read_group(s, i)
elif name in _UNWRAP:
if i < len(s) and s[i] == '{':
body, i = _read_group(s, i)
out.append(_convert(body))
elif name in _DROP:
# \left( → ( — خودِ پرانتز در دورِ بعدِ حلقه نوشته می‌شود.
if name in ('left', 'right') and i < len(s) and s[i] == '.':
i += 1
elif name in _SYMBOLS:
out.append(_SYMBOLS[name])
# «\pm 2» → «±2»: نمادِ یک‌طرفه به عددش می‌چسبد.
if name in _PREFIX_SIGNS:
while i < len(s) and s[i] == ' ':
i += 1
elif name in _FUNCTIONS:
out.append(name)
else:
out.append(name)
elif ch in '^_':
# مدل «x^10» را به معنیِ ده می‌نویسد، نه «x¹0» — رقم‌ها با هم خوانده می‌شوند.
digits = re.match(r'\d+', s[i + 1:])
if digits:
body, i = digits.group(0), i + 1 + len(digits.group(0))
else:
body, i = _read_group(s, i + 1)
if ch == '^' and body in ('\\circ', 'circ', '°'):
out.append('°')
continue
if ch == '^' and body in ('\\prime', "'"):
out.append('′')
continue
body = _convert(body)
if ch == '_' and ''.join(out).endswith('lim'):
out.append(f'({body})')
continue
if ch == '^':
out.append(_script(body, _SUPERSCRIPT, _SUP_CHARS, '^'))
else:
out.append(_script(body, _SUBSCRIPT, _SUB_CHARS, '_'))
elif ch == '{':
# آکولادِ گروه‌بندیِ LaTeX (بعد از پاک شدنِ دستورِ صاحبش)
body, i = _read_group(s, i)
out.append(_convert(body))
elif ch == '&':
i += 1
else:
out.append(ch)
i += 1
return ''.join(out)
# \( … \) و \[ … \] و $$ … $$ و $ … $ — محتوا تبدیل و جداکننده حذف می‌شود.
_MATH_SPANS = re.compile(
r'\\\[(.+?)\\\]|\\\((.+?)\\\)|\$\$(.+?)\$\$|(?<![\w$])\$([^$\n]+?)\$(?!\w)',
re.DOTALL,
)
# LaTeXِ بی‌جداکننده که مدل گاهی وسطِ متن می‌نویسد: \frac{..}{..}، x^{2}، \sqrt{..}
_BARE_LATEX = re.compile(r'\\[A-Za-z{}]|[\^_]\{|(?<=[\w)])\^\d')
def _math_span(match: re.Match) -> str:
body = next(g for g in match.groups() if g is not None)
# بلوکِ نمایشی را مدل خودش در خطِ جدا می‌نویسد؛ خطِ خالیِ اضافه نمی‌سازیم.
if '\\\\' in body: # چندخطی (\\ یا cases/aligned)
return '\n'.join(' '.join(line.split()) for line in _convert(body).strip().split('\n'))
return ' '.join(_convert(body).split())
def _strip_markdown(text: str) -> str:
lines = []
for line in text.split('\n'):
stripped = line.strip()
if re.fullmatch(r'\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?', stripped):
continue # خطِ جداکننده‌ی جدولِ markdown
if stripped.startswith('|') and stripped.endswith('|') and stripped.count('|') >= 2:
cells = [c.strip() for c in stripped.strip('|').split('|')]
line = ' | '.join(cells)
line = re.sub(r'^\s{0,3}#{1,6}\s+', '', line)
line = re.sub(r'^(\s*)[-*]\s+', r'\1• ', line)
line = re.sub(r'^\s*(-{3,}|\*{3,}|_{3,})\s*$', '', line)
line = re.sub(r'\*\*(.+?)\*\*', r'\1', line)
line = re.sub(r'__(.+?)__', r'\1', line)
line = line.replace('`', '')
lines.append(line.strip()) # تورفتگی در حبابِ راست‌به‌چپ معنایی ندارد
return '\n'.join(lines)
def to_plain(text: str, *, fraction_word: bool = False) -> str:
"""
پاسخِ مدل → متنِ ساده‌ی قابلِ خواندن روی گوشی.
fraction_word: کسرِ LaTeX به‌صورتِ «a بر b» (برای دبستان) به‌جای a/b.
"""
if not text:
return text
token = _FRACTION_WORD.set(fraction_word)
try:
return _to_plain(text)
finally:
_FRACTION_WORD.reset(token)
def _to_plain(text: str) -> str:
text = _MATH_SPANS.sub(_math_span, text)
if _BARE_LATEX.search(text):
text = '\n'.join(_convert(line) if _BARE_LATEX.search(line) else line
for line in text.split('\n'))
text = _strip_markdown(text)
text = re.sub(r'[ \t]+\n', '\n', text)
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
class StreamSanitizer:
"""
همان to_plain برای پاسخِ استریمی. یک دستورِ LaTeX ممکن است بینِ دو تکه‌ی
استریم نصف شود (`\\fr` + `ac{1}{2}`)، پس متن تا پایانِ هر خط نگه داشته و
خط‌به‌خط تبدیل می‌شود؛ یک بلوکِ `\\[ … \\]` چندخطی هم تا بسته شدنش نگه
داشته می‌شود.
"""
def __init__(self, *, fraction_word: bool = False):
self._fraction_word = fraction_word
self._buffer = ''
self._emitted_any = False
self._newlines = 0 # خط‌های خالیِ معلق؛ مثلِ to_plain حداکثر دوتا
def _ready_cut(self) -> int:
cut = self._buffer.rfind('\n')
if cut == -1:
return -1
head = self._buffer[:cut + 1]
# بلوکِ نمایشیِ باز را نصفه نفرست.
if head.count('\\[') > head.count('\\]') or head.count('$$') % 2:
return -1
return cut + 1
def feed(self, delta: str) -> str:
self._buffer += delta
cut = self._ready_cut()
if cut == -1:
return ''
head, self._buffer = self._buffer[:cut], self._buffer[cut:]
return self._emit(head, final=False)
def flush(self) -> str:
head, self._buffer = self._buffer, ''
return self._emit(head, final=True)
def _emit(self, chunk: str, *, final: bool) -> str:
plain = to_plain(chunk, fraction_word=self._fraction_word)
if not plain:
# خطِ واقعاً خالی خطِ خالی است؛ خطی که محتوایش حذف شد (جداکننده‌ی
# جدول) مثلِ to_plain هیچ ردی نمی‌گذارد.
if not chunk.strip():
self._newlines += chunk.count('\n')
return ''
leading = len(chunk) - len(chunk.lstrip('\n'))
prefix = '\n' * min(self._newlines + leading, 2) if self._emitted_any else ''
self._emitted_any = True
self._newlines = 0 if final else len(chunk) - len(chunk.rstrip('\n'))
return prefix + plain

View File

@@ -107,15 +107,23 @@ def _payload_text(payload: dict) -> str:
دو شکلِ payload در دادهٔ تیمِ دیگه هست:
- درس‌های متنی (علوم/اجتماعی): کلیدِ `summary`
- ریاضی: کلیدِ `generalSolution` (+ problem/solution) و بدونِ `summary`
⚠️ راه‌حلی که `rag_verify_solutions` با آن مخالفت کرده (`disagrees`) به مدل
نمی‌رسد؛ فقط روش و صورتِ مسئله می‌رود. پیش‌تر همان راه‌حلِ غلط (مثلاً «در
[−π, π] دو نوسانِ کامل») به‌عنوانِ «متنِ درس» به دستیار داده می‌شد و دستیار
تکرارش می‌کرد — حدودِ ۱۳٪ تکه‌های کتاب‌های دهم تا دوازدهم این‌طورند.
"""
if payload.get('summary'):
return payload['summary']
solution = payload.get('solution', '')
if payload.get('solution_verdict') == 'disagrees':
solution = ''
parts = [
payload.get('generalSolution', ''),
payload.get('problem', ''),
payload.get('solution', ''),
('روش', payload.get('generalSolution', '')),
('مسئله', payload.get('problem', '')),
('راه‌حلِ کتاب', solution),
]
joined = '\n'.join(p for p in parts if p).strip()
joined = '\n'.join(f'{label}: {text}' for label, text in parts if text).strip()
return joined or payload.get('text', '')