Files
MathApp/rag/services/generation.py
Zareian 42783f9ba4 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>
2026-09-25 06:24:13 +03:30

346 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
مرحله‌ی generation: پرسش + context بازیابی‌شده (+ چند پیامِ قبلیِ همان گفتگو)
→ پاسخِ فارسی + چند پیشنهادِ ادامه.
اگه 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
from collections.abc import Iterator
from django.conf import settings
from . import clients
from .mathtext import StreamSanitizer, to_plain
logger = logging.getLogger(__name__)
_DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح بده']
# ⚠️ با هر تغییرِ معنادار در متنِ پرامپت‌های پایین، این را یکی جلو ببرید.
# روی هر پیامِ دستیار ذخیره می‌شود تا وقتی لایک/دیسلایکِ کاربرها جمع شد،
# بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه
# کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود.
PROMPT_VERSION = 'tutor-v3-stepwise-plain'
_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()
tier = grade_tier(grade)
if tier in ('middle', 'upper'):
return f'معلمِ ریاضیِ پایه‌ی {grade}', f'دانش‌آموزِ پایه‌ی {grade}'
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, student = _persona(grade_title)
numbers = _NUMBERS['primary' if grade_tier(grade_title) == 'primary' else 'default']
return (
f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} '
f'با یک {student} حرف می‌زنی. همیشه فارسی جواب بده.\n\n'
f'{_TONE[grade_tier(grade_title)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
)
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}'
)
def chat_model_for(grade_title: str = '') -> str:
"""
مدلِ چت برای این پایه. `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 = '', 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)
`meta` همان چیزی‌ست که روی پیامِ دستیار ذخیره می‌شود: مدل، نسخه‌ی پرامپت،
توکنِ مصرف‌شده و مدتِ تولید. بدونِ اینها نه می‌شود فهمید کدام نسخه‌ی پرامپت
بهتر جواب می‌دهد، نه اینکه چقدر از سهمیه‌ی API خرج شده است.
`model` فقط برای مقایسه‌ی مدل‌ها (`rag_model_benchmark`) است.
"""
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=model, messages=messages, **completion_kwargs(model),
)
latency_ms = int((time.monotonic() - started) * 1000)
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': model,
'promptVersion': PROMPT_VERSION,
'latencyMs': latency_ms,
'promptTokens': getattr(usage, 'prompt_tokens', None) or 0,
'completionTokens': getattr(usage, 'completion_tokens', None) or 0,
'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 = '', history: list[dict] | None = None,
focus: str = '') -> Iterator[str]:
"""
همون 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=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:
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]:
return list(_DEFAULT_FOLLOW_UPS)