""" مرحله‌ی 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)