Files
MathApp/rag/services/generation.py
Zareian a973c41db2 Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
  curriculum import need add/delete; content browser needs view_question; guest device ids are
  shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
  Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
  (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
  orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
  refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
  KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
  INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
  pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
  ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:52:25 +03:30

492 lines
29 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 re
import time
from collections.abc import Iterator
from django.conf import settings
from . import clients
from . import diagram as diagram_mod
from . import guardrails
from .mathtext import StreamSanitizer, to_plain
logger = logging.getLogger(__name__)
_DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح بده']
_RAW_MARKUP = re.compile(r'\\[A-Za-z(\[]|\$|\*\*|^\s*#{1,6}\s|^\s*\|\s*:?-{3,}', re.M)
# ⚠️ با هر تغییرِ معنادار در متنِ پرامپت‌های پایین، این را یکی جلو ببرید.
# روی هر پیامِ دستیار ذخیره می‌شود تا وقتی لایک/دیسلایکِ کاربرها جمع شد،
# بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه
# کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود.
PROMPT_VERSION = 'tutor-v12-age-tone'
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
_UPPER = {'دهم', 'یازدهم', 'دوازدهم'}
# چند پیامِ آخرِ گفتگو که همراهِ سوالِ تازه فرستاده می‌شود. سه رفت‌وبرگشت
# برای «نفهمیدم» و «یه مثال دیگه» کافی است و هزینه‌ی توکن را محدود نگه می‌دارد.
HISTORY_TURNS = 6
# stream_answer پیش از فرستادنِ اولین تکه این‌قدر حرف جمع می‌کند (برای تشخیصِ علامتِ رد).
_HOLD_CHARS = 40
_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 ''
# از این سن به بعد لحن بزرگسالانه است، هر پایه‌ای که انتخاب شده باشد (سینیور، ۲۰۲۶-۱۰-۰۲:
# «ممکن است یک بی‌سواد بخواهد کلاسِ ابتدایی بردارد»). پایه فقط محتوا و نمادگذاری را تعیین می‌کند.
ADULT_AGE = 18
def tone_for(grade_title: str, age: int | None) -> str:
"""کلیدِ _TONE: 'adult' برای بزرگسال، وگرنه سطحِ پایه."""
if age is not None and age >= ADULT_AGE:
return 'adult'
return grade_tier(grade_title)
def _persona(grade_title: str, age: int | None = None) -> tuple[str, str]:
"""(توصیفِ معلم، توصیفِ یادگیرنده) متناسب با پایه و سن."""
grade = (grade_title or '').strip()
if tone_for(grade, age) == 'adult':
level = f'ریاضیِ پایه‌ی {grade}' if grade else 'ریاضی'
return 'معلمِ ریاضیِ بزرگسالان', f'بزرگسالی که {level} را یاد می‌گیرد'
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'
'- شرط‌ها را صریح چک کن: دامنه، مخرجِ مخالفِ صفر، زیرِ رادیکالِ نامنفی، '
'علامتِ عبارت، واحدِ زاویه.'
),
'adult': (
'با یک بزرگسال حرف می‌زنی؛ شاید سال‌ها از درس دور بوده، شاید تازه سواد آموخته، شاید '
'فقط می‌خواهد یک مبحث را یاد بگیرد. محترمانه، صبور و بی‌قضاوت.\n'
'- هرگز لحنِ کودکانه نداشته باش: نه «عزیزم» و «آفرین کوچولو»، نه شکلک، نه مثالِ '
'شکلات و اسباب‌بازی.\n'
'- مثال‌ها از زندگیِ بزرگسال: خرید و تخفیف، قبض، اجاره، دستمزد، آشپزی، مسافت و زمان.\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'
'- در پایانِ حلِ یک مسئله، یک تمرینِ کوتاهِ مشابه بده: «حالا خودت امتحان کن: …» '
'(بدونِ جوابش).'
)
def system_prompt(grade_title: str = '', age: int | None = None) -> str:
"""
قبلاً «معلمِ دبستانی» ثابت بود؛ بعد فقط نامِ پایه در آن آمد. حالا لحن،
روش (حلِ کامل + وارسی) و قالب (متنِ ساده) هر سه صریح‌اند.
"""
teacher, student = _persona(grade_title, age)
# نمادگذاریِ عدد از کتابِ همان پایه است، نه سنِ خواننده.
numbers = _NUMBERS['primary' if grade_tier(grade_title) == 'primary' else 'default']
return (
f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} '
f'با یک {student} حرف می‌زنی. همیشه فارسی جواب بده.\n\n'
f'{guardrails.SCOPE_RULE}\n\n'
f'{_TONE[tone_for(grade_title, age)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
f'\n\n{diagram_mod.DIAGRAM_RULE}'
)
# «/» در سوالِ دبستانی هم ممیز است هم کسر: کتاب کسرِ دوطبقه را چاپ می‌کند و
# استخراج/تایپ آن را صاف می‌کند. سنجشِ ۲۰۲۶-۰۹-۲۶: gpt-4.1-mini همه را ممیز
# می‌خواند (۱/۷ + ۱/۸ = ۳/۵) و gpt-4o-mini بیشتر کسر. همین قاعده در انتهای
# پرامپتِ سیستم اثری نداشت؛ اینجا کنارِ خودِ سوال است.
_SLASH_READING = (
'پیش از حل، معنای هر «/» در این سوال را تعیین کن و در خطِ اولِ پاسخ بنویس '
'(مثلاً «اینجا ۲/۹ کسرِ دو‌نهم است»): اگر سوال عددی با صفر پیش از «/» دارد '
'(مثلِ ۰/۴) یا عدد همراهِ واحد آمده (۳/۶ متر)، «/» ممیزِ اعشار است. در غیرِ '
'این صورت، و همیشه وقتی سوال کلمه‌ی «کسر» دارد، «/» خطِ کسر است.'
)
def _user_prompt(question: str, contexts: list[str], focus: str = '',
primary: bool = False, previous: str = '') -> str:
asked = f'سوالِ دانش‌آموز: {question}'
# سوالِ کوتاهِ ادامه‌ای («اگه به‌جای ۶ بذاریم ۴؟») بدونِ لنگر، مدل را سراغِ تکه‌ی کتابِ
# نامربوط می‌برد (چتِ واقعی، ۲۰۲۶-۰۹-۲۹: جوابِ یک «نیم‌دایره» به‌جای همان سهمی).
if previous and len(question) <= 80:
asked = (f'{asked}\n(این ادامه‌ی گفتگوست؛ سوالِ قبلیِ دانش‌آموز: «{previous[:300]}». '
'اگر به آن مربوط است، همان مسئله را با تغییرِ خواسته‌شده حل کن.)')
shape_hint = (diagram_mod.hint_for(f'{question}\n{focus}')
or diagram_mod.follow_up_hint_for(question, previous)
or diagram_mod.asked_hint_for(question))
if shape_hint:
asked = f'{asked}\n\n{shape_hint}'
slash_rule = primary and '/' in question
if slash_rule:
asked = f'{_SLASH_READING}\n\n{asked}'
if focus:
asked = f'{focus}\n\n{asked}'
if not contexts:
return asked if (focus or slash_rule or shape_hint or previous) else question
# این جمله برای کتاب‌های دبیرستان نوشته شده بود (۳/۱۰۴ = 3.104) ولی به همه
# می‌رفت، و gpt-4.1-mini با آن هر کسرِ دبستانی را ممیز می‌خواند.
slash_note = (
'در متنِ کتاب «/» بینِ دو رقم گاهی ممیزِ اعشار است و گاهی خطِ کسر.'
if primary else
'در متنِ کتاب «/» بینِ دو رقم ممیزِ اعشار است (۳/۱۰۴ یعنی 3.104)، نه کسر.'
)
excerpts = '\n\n'.join(f'[{i}] {text}' for i, text in enumerate(contexts, 1))
return (
'چند بخش از کتابِ درسیِ همین دانش‌آموز که با جستجو پیدا شده‌اند '
'(ممکن است همه مربوط نباشند):\n\n'
f'{excerpts}\n\n'
'— پایانِ بخش‌های کتاب —\n'
'اگر به سوال مربوط‌اند، از روش و نمادگذاریِ همان‌ها پیروی کن؛ اگر مربوط '
'نیستند نادیده‌شان بگیر و از دانشِ خودت در سطحِ همین پایه جواب بده. '
'متنِ کتاب از روی تصویر خوانده شده و ممکن است غلطِ تایپی داشته باشد؛ '
f'حسابِ خودت را ملاک بگذار. {slash_note}\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 = '', age: int | None = None) -> 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, age)}]
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]})
# سوالِ قبلیِ کاربر: «اگه به‌جای ۶ بذاریم ۴ چی؟» کلمه‌ی شکل‌دار ندارد، ولی ادامه‌ی
# همان مسئله‌ی شکل‌دار است و شکلش باید با عددِ تازه دوباره کشیده شود.
previous = _previous_question(history)
messages.append({'role': 'user', 'content': _user_prompt(
question, contexts, focus, primary=grade_tier(grade_title) == 'primary', previous=previous)})
return messages
def _previous_question(history) -> str:
return next((t.get('text') or '' for t in reversed(history or []) if t.get('role') == 'user'), '')
def generate_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '', history: list[dict] | None = None,
model: str | None = None, focus: str = '',
age: int | None = None) -> 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, age)
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()
# بلوکِ شکل پیش از to_plain جدا می‌شود؛ to_plain کسرها و «*» را در JSON عوض می‌کرد.
raw, diagram = diagram_mod.extract(raw)
# سوالِ ادامه‌ای («حالا ۱۰ دقیقه بعد؟») با سوالِ قبلی سنجیده می‌شود
diagram = diagram_mod.fits(f'{question}\n{focus}\n{_previous_question(history)}', diagram)
answer = to_plain(raw, fraction_word=grade_tier(grade_title) == 'primary')
refused = guardrails.OFF_TOPIC_MARKER in raw
if refused:
answer, diagram = guardrails.REFUSAL, None
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,
# LaTeX/markdownِ واقعی در خروجیِ خام. `sanitized` برای دبستان همیشه
# روشن است (کسر به «۳ بر ۴» تبدیل می‌شود)؛ این یکی فقط نقضِ قاعده را می‌شمارد.
'rawMarkup': bool(_RAW_MARKUP.search(raw)),
'reasoningTokens': getattr(getattr(usage, 'completion_tokens_details', None),
'reasoning_tokens', None) or 0,
# GapGPT هزینه‌ی دقیقِ هر فراخوانی را (به دلار) در usage می‌فرستد؛
# providerی که نفرستد None می‌دهد.
'costUsd': getattr(usage, 'cost', None),
'diagram': diagram,
'refused': refused,
}
return answer, ([] if refused else 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 = '', result: dict | None = None, age: int | None = None) -> Iterator[str]:
"""
`result` (اختیاری) بعد از پایانِ استریم کلیدِ 'diagram' را می‌گیرد: شکلِ
اعتبارسنجی‌شده یا None. بلوکِ شکل هیچ‌وقت به‌صورتِ متن yield نمی‌شود.
همون 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, age),
stream=True,
# آخرین تکه‌ی استریم usage را می‌آورد؛ بدونِ این، هزینه‌ی جواب‌های اپ ثبت نمی‌شد.
stream_options={'include_usage': True},
**completion_kwargs(model),
)
sanitizer = StreamSanitizer(fraction_word=grade_tier(grade_title) == 'primary')
splitter = diagram_mod.StreamSplitter()
# چند حرفِ اول نگه داشته می‌شود تا اگر مدل با علامتِ «خارج از ریاضی» جواب داد،
# حتی یک تکه از آن به کاربر نرسد و جایش متنِ ثابتِ رد برود.
held = ''
released = False
refused = False
for event in stream:
usage = getattr(event, 'usage', None)
if usage is not None and result is not None:
result['usage'] = {
'promptTokens': getattr(usage, 'prompt_tokens', 0) or 0,
'completionTokens': getattr(usage, 'completion_tokens', 0) or 0,
}
if not event.choices or refused:
continue
delta = event.choices[0].delta.content or ''
if not delta:
continue
clean = sanitizer.feed(splitter.feed(delta))
if not clean:
continue
if released:
if guardrails.OFF_TOPIC_MARKER in clean:
# علامت دیر آمد: بخشِ پیش از آن رفته است، ولی بقیه نمی‌رود و جواب «ردشده» حساب می‌شود
# تا نه ذخیره شود نه در کش برود (پیش‌تر کاربرِ بعدی همین متن را از کش می‌گرفت).
head = clean.split(guardrails.OFF_TOPIC_MARKER, 1)[0]
if head.strip():
yield head
refused = True
continue
yield clean
continue
held += clean
if guardrails.OFF_TOPIC_MARKER in held:
refused = True
elif len(held) >= _HOLD_CHARS:
released = True
yield held
tail = sanitizer.feed(splitter.flush()) + sanitizer.flush()
if not released and not refused:
held += tail
tail = ''
refused = guardrails.OFF_TOPIC_MARKER in held
if result is not None:
result['refused'] = refused
result['diagram'] = None if refused else diagram_mod.fits(
f'{question}\n{focus}\n{_previous_question(history)}', splitter.diagram)
if refused:
yield ('\n\n' if released else '') + guardrails.REFUSAL
return
if not released and held.strip():
yield held
tail = tail.replace(guardrails.OFF_TOPIC_MARKER, '')
if tail.strip():
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),
]
# جوابِ درست فقط بعد از این‌که خودش جواب داده. تا ۲۰۲۶-۱۰-۰۳ همیشه می‌رفت: کسی که API را
# مستقیم صدا می‌زد پیش از جواب دادن، جواب را از دستیار می‌گرفت و امتیاز می‌برد (فرانت دکمه را
# فقط بعد از جوابِ غلط نشان می‌دهد، ولی سرور شرطی نداشت). این‌که مدل در آن حالت جواب را
# تکرار می‌کرد با مدلِ زنده آزموده نشد؛ فقط رسیدنش به پرامپت.
if last is None:
lines.append('دانش‌آموز هنوز جواب نداده؛ جوابِ نهایی یا گزینه‌ی درست را نگو، فقط راهِ فکر کردن را نشان بده.')
else:
lines.append(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)