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>
346 lines
19 KiB
Python
346 lines
19 KiB
Python
"""
|
||
مرحلهی 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)
|