tutor runs on gpt-4.1-mini, and primary-school "/" is read as decimal or fraction by rule

Two benchmark rounds (14 models on 30 problems, then gpt-4o-mini vs gpt-4.1-mini
on 102 problems under three prompt versions, hand-graded): gpt-4.1-mini scored
95-96/102 against 85-90 for gpt-4o-mini, at 0.12 vs 0.04 cents per answer.
The default now lives in settings so the deploy needs no .env edit.

The context note "in the book / is a decimal point, not a fraction" was written
for the upper-grade books but went to every grade, so gpt-4.1-mini computed
1/7 + 1/8 as 3.5. For primary grades the note now says / can be either, and a
reading rule sits next to the question itself (the same rule at the end of
the system prompt had no effect). Slash problems went from 11 to 14 of 17;
three are still wrong.

generate_answer now reports the provider's exact per-call cost, reasoning
tokens and whether the raw output broke the plain-text rule. The benchmark
fixes primary grades being sent with no book context, caches retrieval so
runs compare on identical context, and retries Qdrant timeouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 02:36:54 +03:30
parent e0176ca0a6
commit 11bbe1c248
6 changed files with 147 additions and 20 deletions

View File

@@ -18,6 +18,7 @@
3. **لحن سه سطح دارد** (دبستان / متوسطه‌ی اول / متوسطه‌ی دوم)، نه فقط نامِ پایه.
"""
import logging
import re
import time
from collections.abc import Iterator
@@ -29,13 +30,14 @@ 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-v3-stepwise-plain'
PROMPT_VERSION = 'tutor-v5-slash-in-question'
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
@@ -130,7 +132,8 @@ _METHOD = (
'- اگر سوال مفهومی است، مفهوم را با یک مثالِ عددیِ کوتاه و حل‌شده توضیح بده.\n'
'- دقت از همه‌چیز مهم‌تر است. پیش از نوشتنِ جوابِ نهایی آن را وارسی کن '
'(جواب را در معادله جای‌گذاری کن، مشتق یا ساده‌سازی را دوباره انجام بده، '
'بزرگیِ عدد را تخمین بزن). اگر در وارسی اشتباهی دیدی، راه‌حل را اصلاح کن؛ '
'بزرگیِ عدد را تخمین بزن؛ توانِ هر متغیر و علامتِ هر جمله را جدا با صورتِ '
'سوال مقایسه کن). اگر در وارسی اشتباهی دیدی، راه‌حل را اصلاح کن؛ '
'هرگز جوابی را که وارسی نکرده‌ای ننویس.\n'
'- اگر مسئله جوابِ مشخصی دارد، خطِ آخرِ حل این باشد: «✅ جواب: …».\n'
'- اگر اطلاعاتِ مسئله ناقص است (مثلاً به شکلی اشاره می‌کند که نمی‌بینی)، '
@@ -158,12 +161,35 @@ def system_prompt(grade_title: str = '') -> str:
)
def _user_prompt(question: str, contexts: list[str], focus: str = '') -> str:
# «/» در سوالِ دبستانی هم ممیز است هم کسر: کتاب کسرِ دوطبقه را چاپ می‌کند و
# استخراج/تایپ آن را صاف می‌کند. سنجشِ ۲۰۲۶-۰۹-۲۶: gpt-4.1-mini همه را ممیز
# می‌خواند (۱/۷ + ۱/۸ = ۳/۵) و gpt-4o-mini بیشتر کسر. همین قاعده در انتهای
# پرامپتِ سیستم اثری نداشت؛ اینجا کنارِ خودِ سوال است.
_SLASH_READING = (
'پیش از حل، معنای هر «/» در این سوال را تعیین کن و در خطِ اولِ پاسخ بنویس '
'(مثلاً «اینجا ۲/۹ کسرِ دو‌نهم است»): اگر سوال عددی با صفر پیش از «/» دارد '
'(مثلِ ۰/۴) یا عدد همراهِ واحد آمده (۳/۶ متر)، «/» ممیزِ اعشار است. در غیرِ '
'این صورت، و همیشه وقتی سوال کلمه‌ی «کسر» دارد، «/» خطِ کسر است.'
)
def _user_prompt(question: str, contexts: list[str], focus: str = '',
primary: bool = False) -> str:
asked = f'سوالِ دانش‌آموز: {question}'
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 else question
return asked if (focus or slash_rule) 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 (
'چند بخش از کتابِ درسیِ همین دانش‌آموز که با جستجو پیدا شده‌اند '
@@ -173,8 +199,7 @@ def _user_prompt(question: str, contexts: list[str], focus: str = '') -> str:
'اگر به سوال مربوط‌اند، از روش و نمادگذاریِ همان‌ها پیروی کن؛ اگر مربوط '
'نیستند نادیده‌شان بگیر و از دانشِ خودت در سطحِ همین پایه جواب بده. '
'متنِ کتاب از روی تصویر خوانده شده و ممکن است غلطِ تایپی داشته باشد؛ '
'حسابِ خودت را ملاک بگذار. در متنِ کتاب «/» بینِ دو رقم ممیزِ اعشار است '
'(۳/۱۰۴ یعنی 3.104)، نه کسر.\n\n'
f'حسابِ خودت را ملاک بگذار. {slash_note}\n\n'
f'{asked}'
)
@@ -220,7 +245,8 @@ def build_messages(question: str, context_chunks: list[dict] | None,
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)})
messages.append({'role': 'user', 'content': _user_prompt(
question, contexts, focus, primary=grade_tier(grade_title) == 'primary')})
return messages
@@ -262,6 +288,14 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
# آیا تورِ ایمنی چیزی را عوض کرد؟ نشانِ این است که مدل قاعده‌ی «متنِ
# ساده» را رعایت نکرده — برای مقایسه‌ی مدل‌ها در 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),
}
return answer, list(_DEFAULT_FOLLOW_UPS), meta