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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
308
rag/services/mathtext.py
Normal 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
|
||||
@@ -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', '')
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user