From 11bbe1c2480021a8216536aace08c34604dc2fa6 Mon Sep 17 00:00:00 2001 From: Zareian Date: Sat, 26 Sep 2026 02:36:54 +0330 Subject: [PATCH] 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 --- .env.example | 2 +- config/settings.py | 5 +- docs/architecture.md | 16 +++++ .../commands/rag_model_benchmark.py | 62 ++++++++++++++++--- rag/services/generation.py | 48 +++++++++++--- rag/tests_tutor_quality.py | 34 +++++++++- 6 files changed, 147 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index de1ad20..5891d45 100644 --- a/.env.example +++ b/.env.example @@ -89,7 +89,7 @@ OPENAI_BASE_URL=https://api.gapgpt.app/v1 # تا ساده و بی‌اشتباه بمونه. # OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_API_KEY_OPENAI_DIRECT= -OPENAI_CHAT_MODEL=gpt-4o-mini +OPENAI_CHAT_MODEL=gpt-4.1-mini OPENAI_VISION_MODEL=gpt-4.1-mini # این رو تغییر نده؛ باید با مدلی که تیمِ دیگه باهاش collectionها رو ساخته یکی باشه. OPENAI_EMBEDDING_MODEL=text-embedding-3-small diff --git a/config/settings.py b/config/settings.py index 08c884a..5a57ae8 100644 --- a/config/settings.py +++ b/config/settings.py @@ -775,7 +775,10 @@ if ZARINPAL_REDIRECT_URL == '-': # تست‌های موجود و اجرای بدونِ کلید دست‌نخورده کار می‌کنن). OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '') OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1') -OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini') +# gpt-4.1-mini از ۲۰۲۶-۰۹-۲۶ (تصمیمِ صاحبِ پروژه): در سنجشِ ۱۰۲ مسئله ۹۶ درست در برابرِ +# ۸۵–۹۰ِ gpt-4o-mini، با ۲٫۹ برابر هزینه (۰٫۱۲ سنت برای هر پاسخ). شرح: گزارش ۲۳ و +# docs/architecture.md. پیش‌فرض همین‌جاست تا دیپلوی به ویرایشِ دستیِ .env نیاز نداشته باشد. +OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4.1-mini') # اختیاری: مدلِ جدا فقط برای پایه‌های دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی # همه‌ی پایه‌ها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده می‌شود. OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '') diff --git a/docs/architecture.md b/docs/architecture.md index 65627ec..38140f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,6 +146,22 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند `/dashboard/billing/usage`. `LessonChunk` فقط در دیتابیسِ محلی است و GapGPT فقط از ایران جواب می‌دهد، پس نمونه با `--export-sample` محلی ساخته و روی سرور با `--sample-file` اجرا می‌شود. `--resume` مدل‌های تمام‌شده را دوباره اجرا نمی‌کند. +هزینه‌ی هر پاسخ از `usage.cost`ِ خودِ GapGPT خوانده می‌شود، و `--context-cache` بخش‌های +بازیابی‌شده را ذخیره می‌کند تا چند اجرا (چند مدل یا چند نسخه‌ی پرامپت) دقیقاً یک context ببینند. + +**انتخابِ فعلی: `gpt-4.1-mini` (۲۰۲۶-۰۹-۲۶).** دورِ اول ۱۴ مدل روی ۳۰ مسئله: پنج مدل +بی‌خطا بودند و 4.1-mini ارزان‌ترینشان بود. دورِ دوم 4o-mini در برابرِ 4.1-mini روی ۱۰۲ مسئله +با سه نسخه‌ی پرامپت: ۹۵–۹۶ در برابرِ ۸۵–۹۰. نوسانِ 4o-mini با پرامپتِ یکسان تا ۵ مسئله بود، +پس این فاصله واقعی است. خطاهای 4o-mini بیشتر حسابِ ساده بود (۸۴۵۱ − ۷۲۳۴ = ۱۳۱۷). هر پاسخ +۰٫۱۲ سنت است، ۲٫۹ برابرِ 4o-mini. داورِ خودکار (gpt-4.1) حدودِ ۷٪ اشتباه داوری کرد (مثلاً +3¹⁴ و (−3)¹⁴ را متفاوت دانست)، پس نتیجه‌ها دستی تصحیح شد. + +**«/» در دبستان (پرامپتِ `tutor-v5-slash-in-question`).** در کتاب‌های دبستان «/» هم ممیز +است هم کسر، چون کسرِ دوطبقه در استخراج صاف می‌شود. جمله‌ی «در کتاب / ممیز است، نه کسر» که +برای دبیرستان نوشته شده بود به همه می‌رفت، و 4.1-mini با آن ۱/۷ + ۱/۸ را ۳/۵ حساب می‌کرد. +حالا برای دبستان آن جمله عوض شده و قاعده‌ی تشخیص (صفر پیش از «/» یا واحد = ممیز؛ کلمه‌ی +«کسر» یا بی‌نشانه = کسر) کنارِ خودِ سوال می‌آید. همین قاعده در انتهای پرامپتِ سیستم اثری +نداشت. نتیجه: مسئله‌های «/»ِ 4.1-mini از ۱۱ به ۱۴ از ۱۷. سه‌تا هنوز غلط است. **تمام شدنِ اعتبار دیده می‌شود.** هر گذارِ provider بینِ `ok` و `quota_exhausted` یک ردیف در `statuslog` است (`ai_tutor/provider_status.py`) و diff --git a/rag/management/commands/rag_model_benchmark.py b/rag/management/commands/rag_model_benchmark.py index 5b92109..9bcc2de 100644 --- a/rag/management/commands/rag_model_benchmark.py +++ b/rag/management/commands/rag_model_benchmark.py @@ -69,6 +69,9 @@ JUDGE_PROMPT = """یک مسئله‌ی ریاضیِ مدرسه، جوابِ در # نشانه‌ی LaTeX/markdown در خروجیِ *خامِ* مدل (پیش از تورِ ایمنی) _STEP_LINE = re.compile(r'^\s*([0-9۰-۹]+[)\.)]|مرحله)', re.M) +# جمله‌ی انگلیسی/چینی وسطِ پاسخِ فارسی (نامِ تابع‌ها مثلِ sin/lim شمرده نمی‌شوند) +_FOREIGN_WORD = re.compile(r'\b(?!(?:sin|cos|tan|cot|log|lim|ln|cm|mm|km|kg|erg|Erg)\b)[A-Za-z]{4,}\b') +_CJK = re.compile(r'[\u3040-\u30ff\u4e00-\u9fff]') _KEY_NOT_AN_ANSWER = re.compile(r'کتاب|اثبات|درست است|صحیح است|مطابق|توضیح') @@ -93,7 +96,9 @@ def _stable_order(chunk_id: int, seed: str) -> str: def _grade_of(item) -> str: - return (item['grade'] or item['collection'].split()[-1]).strip() + # از نامِ collection، مثلِ خودِ اپ. `grade_title`ِ کتاب‌های دبستان «سوم دبستان» + # است؛ با آن نه collection پیدا می‌شد (retrieval خالی) نه لحنِ دبستان. + return item['collection'].split()[-1].strip() def _usage_cents() -> float | None: @@ -133,6 +138,9 @@ class Command(BaseCommand): # فقط از ایران جواب می‌دهد: نمونه را اینجا بنویس، روی سرور بخوان. parser.add_argument('--export-sample', default=None, metavar='PATH') parser.add_argument('--sample-file', default=None, metavar='PATH') + # context یک بار ساخته و ذخیره می‌شود تا چند اجرا (مثلاً دو نسخه‌ی پرامپت) + # دقیقاً همان بخش‌های کتاب را ببینند و Qdrant هم فقط یک بار صدا زده شود. + parser.add_argument('--context-cache', default=None, metavar='PATH') # ------------------------------------------------------------------ sample def _sample(self, per_grade, collections, seed): @@ -160,7 +168,16 @@ class Command(BaseCommand): # ----------------------------------------------------------------- context def _context(self, item): subject = item['collection'].rsplit(' ', 1)[0] - hits, _meta = retrieve_context(item['problem'], subject=subject, grade_title=_grade_of(item)) + for attempt in range(3): + try: + hits, _meta = retrieve_context(item['problem'], subject=subject, + grade_title=_grade_of(item)) + break + except Exception as exc: # noqa: BLE001 + # timeoutِ Qdrant زیرِ بارِ هم‌زمان یک بار کلِ اجرا را کشت. + if is_account_limit(exc) or attempt == 2: + raise + time.sleep(3 * (attempt + 1)) needle = ' '.join(item['problem'].split())[:60] # همان تکه (و هر تکه‌ای که عینِ این مسئله را دارد) بیرون: وگرنه مدل # فقط راه‌حلِ کتاب را رونویسی می‌کند و سنجه بی‌معنا می‌شود. @@ -179,13 +196,14 @@ class Command(BaseCommand): ) data = json.loads(completion.choices[0].message.content or '{}') verdict = data.get('verdict') + cost = getattr(getattr(completion, 'usage', None), 'cost', None) or 0 if verdict in ('correct', 'wrong', 'unclear'): - return verdict, str(data.get('why') or '')[:120] + return verdict, str(data.get('why') or '')[:120], cost except Exception as exc: # noqa: BLE001 if is_account_limit(exc): raise AccountLimitReached(str(exc)[:160]) from exc time.sleep(2 * (attempt + 1)) - return 'unclear', 'داوری شکست خورد' + return 'unclear', 'داوری شکست خورد', 0 # ------------------------------------------------------------------ handle def handle(self, *args, **opts): @@ -219,16 +237,28 @@ class Command(BaseCommand): # context یک بار برای همه‌ی مدل‌ها: مقایسه منصفانه و embedding ارزان‌تر. contexts = {} - if not opts['no_context']: + cache = opts['context_cache'] + if cache and not opts['no_context']: + try: + with open(cache, encoding='utf-8') as fh: + contexts = {int(k): v for k, v in json.load(fh).items()} + self.stdout.write(f'context از {cache} ({len(contexts)} مسئله)') + except FileNotFoundError: + pass + if not opts['no_context'] and any(item['id'] not in contexts for item in items): self.stdout.write('retrieval …') try: with ThreadPoolExecutor(opts['workers']) as pool: - for item, ctx in zip(items, pool.map(self._context, items)): + todo = [item for item in items if item['id'] not in contexts] + for item, ctx in zip(todo, pool.map(self._context, todo)): contexts[item['id']] = ctx except Exception as exc: # noqa: BLE001 if is_account_limit(exc): raise CommandError(f'⛔ اعتبارِ حساب تمام شده است: {str(exc)[:160]}') from exc raise + if cache: + with open(cache, 'w', encoding='utf-8') as fh: + json.dump(contexts, fh, ensure_ascii=False) # نتیجه‌ی هر مدل همان لحظه روی دیسک می‌رود. اجرای اول وسطِ کار با تمام # شدنِ اعتبار مرد و چون فقط آخرِ کار ذخیره می‌کرد، پاسخ‌های چهار مدلِ @@ -286,6 +316,11 @@ class Command(BaseCommand): after = _usage_cents() wall = time.monotonic() - started cost = (after - before) if before is not None and after is not None else None + # هزینه‌ی دقیقِ هر پاسخ از usage.cost، اگر provider بفرستد. دقیق‌تر از + # تفاضلِ billing است و اجرای هم‌زمانِ چند مدل را هم خراب نمی‌کند. + per_call = [a[2].get('costUsd') for a in answered if not a[4]] + if per_call and all(c is not None for c in per_call): + cost = 100 * sum(per_call) rows = [] for item, answer, meta, seconds, error in answered: @@ -294,8 +329,16 @@ class Command(BaseCommand): 'tier': grade_tier(_grade_of(item)), 'problem': item['problem'], 'key': item['key'], 'answer': answer, 'seconds': round(seconds, 1), 'sanitized': bool(meta.get('sanitized')), + 'rawMarkup': bool(meta.get('rawMarkup')), + 'promptTokens': meta.get('promptTokens', 0), 'completionTokens': meta.get('completionTokens', 0), + 'reasoningTokens': meta.get('reasoningTokens', 0), + 'costUsd': meta.get('costUsd'), 'hasFinal': '✅' in answer, 'steps': len(_STEP_LINE.findall(answer)), + 'chars': len(answer), 'hasCheck': 'وارسی' in answer, + 'hasPractice': 'امتحان کن' in answer, + 'foreignWords': len(_FOREIGN_WORD.findall(answer)), + 'cjk': len(_CJK.findall(answer)), 'error': error, 'verdict': 'error', 'why': '', }) errors = sum(1 for r in rows if r['error']) @@ -308,10 +351,11 @@ class Command(BaseCommand): with ThreadPoolExecutor(opts['workers']) as pool: verdicts = list(pool.map( lambda r: self._judge(opts['judge'], chunks_by_id[r['chunkId']], r['answer']), todo)) - for r, (verdict, why) in zip(todo, verdicts): - r['verdict'], r['why'] = verdict, why + for r, (verdict, why, judge_cost) in zip(todo, verdicts): + r['verdict'], r['why'], r['judgeCostUsd'] = verdict, why, judge_cost results.extend(rows) - summary[model] = {'costCents': cost, 'wallSeconds': round(wall, 1)} + summary[model] = {'costCents': cost, 'wallSeconds': round(wall, 1), + 'judgeCostCents': round(100 * sum(v[2] for v in verdicts), 3)} def _report(self, models, results, summary): tiers = ['primary', 'middle', 'upper'] diff --git a/rag/services/generation.py b/rag/services/generation.py index d257ea5..76ebbaa 100644 --- a/rag/services/generation.py +++ b/rag/services/generation.py @@ -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 diff --git a/rag/tests_tutor_quality.py b/rag/tests_tutor_quality.py index b3026b2..e307a88 100644 --- a/rag/tests_tutor_quality.py +++ b/rag/tests_tutor_quality.py @@ -140,14 +140,15 @@ class GenerateAnswerSanitizesTests(TestCase): return SimpleNamespace(chat=SimpleNamespace(completions=completions)) def test_latex_from_the_model_never_reaches_the_student(self): - from rag.services.generation import generate_answer + from rag.services.generation import PROMPT_VERSION, generate_answer with patch('rag.services.clients.get_openai_client', return_value=self._client(r'۱) \(x^2 = 4\)' '\n' r'✅ جواب: \(x = \pm 2\)')): answer, _f, meta = generate_answer('x² = 4', None, 'نهم') self.assertEqual(answer, '۱) x² = 4\n✅ جواب: x = ±2') self.assertTrue(meta['sanitized']) - self.assertEqual(meta['promptVersion'], 'tutor-v3-stepwise-plain') + self.assertTrue(meta['rawMarkup']) + self.assertEqual(meta['promptVersion'], PROMPT_VERSION) class DisputedSolutionTests(SimpleTestCase): @@ -300,3 +301,32 @@ class QuestionFocusTests(TestCase): response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json') self.assertEqual(response.status_code, 400) generate.assert_not_called() + + +class SlashReadingPromptTests(SimpleTestCase): + """ + «/» در سوالِ دبستانی هم ممیز است هم کسر. سنجشِ ۲۰۲۶-۰۹-۲۶ نشان داد جمله‌ی + «در کتاب / ممیز است» که برای دبیرستان نوشته شده بود، gpt-4.1-mini را وادار + می‌کرد ۱/۷ + ۱/۸ را ۳/۵ حساب کند. این تست‌ها فقط متنِ پرامپت را می‌پایند، + نه رفتارِ مدل را. + """ + + def _user(self, question, grade, contexts=None): + from rag.services.generation import build_messages + return build_messages(question, contexts, grade)[-1]['content'] + + def test_primary_question_with_slash_carries_the_reading_rule(self): + text = self._user('۱/۷ + ۱/۸ را حساب کن', 'ششم', [{'summary': 'بخشی از کتاب'}]) + self.assertIn('معنای هر «/»', text) + self.assertNotIn('نه کسر', text) + + def test_rule_also_sent_without_book_context(self): + self.assertIn('معنای هر «/»', self._user('۳/۴ ÷ ۲/۵', 'ششم')) + + def test_upper_grades_keep_the_decimal_note_and_skip_the_rule(self): + text = self._user('حد (x+1)/(x-2)', 'دهم', [{'summary': 'بخشی از کتاب'}]) + self.assertIn('۳/۱۰۴ یعنی 3.104', text) + self.assertNotIn('معنای هر «/»', text) + + def test_primary_question_without_slash_is_unchanged(self): + self.assertEqual(self._user('۲۰ × ۲۰ = ؟', 'سوم'), '۲۰ × ۲۰ = ؟')