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:
@@ -89,7 +89,7 @@ OPENAI_BASE_URL=https://api.gapgpt.app/v1
|
|||||||
# تا ساده و بیاشتباه بمونه.
|
# تا ساده و بیاشتباه بمونه.
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
OPENAI_API_KEY_OPENAI_DIRECT=
|
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
|
OPENAI_VISION_MODEL=gpt-4.1-mini
|
||||||
# این رو تغییر نده؛ باید با مدلی که تیمِ دیگه باهاش collectionها رو ساخته یکی باشه.
|
# این رو تغییر نده؛ باید با مدلی که تیمِ دیگه باهاش collectionها رو ساخته یکی باشه.
|
||||||
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|||||||
@@ -775,7 +775,10 @@ if ZARINPAL_REDIRECT_URL == '-':
|
|||||||
# تستهای موجود و اجرای بدونِ کلید دستنخورده کار میکنن).
|
# تستهای موجود و اجرای بدونِ کلید دستنخورده کار میکنن).
|
||||||
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
|
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
|
||||||
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
|
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. انتخابش با `rag_model_benchmark` سنجیده میشود.
|
||||||
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
|
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
|
||||||
|
|||||||
@@ -146,6 +146,22 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده میشوند
|
|||||||
`/dashboard/billing/usage`. `LessonChunk` فقط در دیتابیسِ محلی است و GapGPT فقط
|
`/dashboard/billing/usage`. `LessonChunk` فقط در دیتابیسِ محلی است و GapGPT فقط
|
||||||
از ایران جواب میدهد، پس نمونه با `--export-sample` محلی ساخته و روی سرور با
|
از ایران جواب میدهد، پس نمونه با `--export-sample` محلی ساخته و روی سرور با
|
||||||
`--sample-file` اجرا میشود. `--resume` مدلهای تمامشده را دوباره اجرا نمیکند.
|
`--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` و
|
**تمام شدنِ اعتبار دیده میشود.** هر گذارِ provider بینِ `ok` و
|
||||||
`quota_exhausted` یک ردیف در `statuslog` است (`ai_tutor/provider_status.py`) و
|
`quota_exhausted` یک ردیف در `statuslog` است (`ai_tutor/provider_status.py`) و
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ JUDGE_PROMPT = """یک مسئلهی ریاضیِ مدرسه، جوابِ در
|
|||||||
|
|
||||||
# نشانهی LaTeX/markdown در خروجیِ *خامِ* مدل (پیش از تورِ ایمنی)
|
# نشانهی LaTeX/markdown در خروجیِ *خامِ* مدل (پیش از تورِ ایمنی)
|
||||||
_STEP_LINE = re.compile(r'^\s*([0-9۰-۹]+[)\.)]|مرحله)', re.M)
|
_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'کتاب|اثبات|درست است|صحیح است|مطابق|توضیح')
|
_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:
|
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:
|
def _usage_cents() -> float | None:
|
||||||
@@ -133,6 +138,9 @@ class Command(BaseCommand):
|
|||||||
# فقط از ایران جواب میدهد: نمونه را اینجا بنویس، روی سرور بخوان.
|
# فقط از ایران جواب میدهد: نمونه را اینجا بنویس، روی سرور بخوان.
|
||||||
parser.add_argument('--export-sample', default=None, metavar='PATH')
|
parser.add_argument('--export-sample', default=None, metavar='PATH')
|
||||||
parser.add_argument('--sample-file', 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
|
# ------------------------------------------------------------------ sample
|
||||||
def _sample(self, per_grade, collections, seed):
|
def _sample(self, per_grade, collections, seed):
|
||||||
@@ -160,7 +168,16 @@ class Command(BaseCommand):
|
|||||||
# ----------------------------------------------------------------- context
|
# ----------------------------------------------------------------- context
|
||||||
def _context(self, item):
|
def _context(self, item):
|
||||||
subject = item['collection'].rsplit(' ', 1)[0]
|
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]
|
needle = ' '.join(item['problem'].split())[:60]
|
||||||
# همان تکه (و هر تکهای که عینِ این مسئله را دارد) بیرون: وگرنه مدل
|
# همان تکه (و هر تکهای که عینِ این مسئله را دارد) بیرون: وگرنه مدل
|
||||||
# فقط راهحلِ کتاب را رونویسی میکند و سنجه بیمعنا میشود.
|
# فقط راهحلِ کتاب را رونویسی میکند و سنجه بیمعنا میشود.
|
||||||
@@ -179,13 +196,14 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
data = json.loads(completion.choices[0].message.content or '{}')
|
data = json.loads(completion.choices[0].message.content or '{}')
|
||||||
verdict = data.get('verdict')
|
verdict = data.get('verdict')
|
||||||
|
cost = getattr(getattr(completion, 'usage', None), 'cost', None) or 0
|
||||||
if verdict in ('correct', 'wrong', 'unclear'):
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
if is_account_limit(exc):
|
if is_account_limit(exc):
|
||||||
raise AccountLimitReached(str(exc)[:160]) from exc
|
raise AccountLimitReached(str(exc)[:160]) from exc
|
||||||
time.sleep(2 * (attempt + 1))
|
time.sleep(2 * (attempt + 1))
|
||||||
return 'unclear', 'داوری شکست خورد'
|
return 'unclear', 'داوری شکست خورد', 0
|
||||||
|
|
||||||
# ------------------------------------------------------------------ handle
|
# ------------------------------------------------------------------ handle
|
||||||
def handle(self, *args, **opts):
|
def handle(self, *args, **opts):
|
||||||
@@ -219,16 +237,28 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# context یک بار برای همهی مدلها: مقایسه منصفانه و embedding ارزانتر.
|
# context یک بار برای همهی مدلها: مقایسه منصفانه و embedding ارزانتر.
|
||||||
contexts = {}
|
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 …')
|
self.stdout.write('retrieval …')
|
||||||
try:
|
try:
|
||||||
with ThreadPoolExecutor(opts['workers']) as pool:
|
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
|
contexts[item['id']] = ctx
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
if is_account_limit(exc):
|
if is_account_limit(exc):
|
||||||
raise CommandError(f'⛔ اعتبارِ حساب تمام شده است: {str(exc)[:160]}') from exc
|
raise CommandError(f'⛔ اعتبارِ حساب تمام شده است: {str(exc)[:160]}') from exc
|
||||||
raise
|
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()
|
after = _usage_cents()
|
||||||
wall = time.monotonic() - started
|
wall = time.monotonic() - started
|
||||||
cost = (after - before) if before is not None and after is not None else None
|
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 = []
|
rows = []
|
||||||
for item, answer, meta, seconds, error in answered:
|
for item, answer, meta, seconds, error in answered:
|
||||||
@@ -294,8 +329,16 @@ class Command(BaseCommand):
|
|||||||
'tier': grade_tier(_grade_of(item)), 'problem': item['problem'],
|
'tier': grade_tier(_grade_of(item)), 'problem': item['problem'],
|
||||||
'key': item['key'], 'answer': answer, 'seconds': round(seconds, 1),
|
'key': item['key'], 'answer': answer, 'seconds': round(seconds, 1),
|
||||||
'sanitized': bool(meta.get('sanitized')),
|
'sanitized': bool(meta.get('sanitized')),
|
||||||
|
'rawMarkup': bool(meta.get('rawMarkup')),
|
||||||
|
'promptTokens': meta.get('promptTokens', 0),
|
||||||
'completionTokens': meta.get('completionTokens', 0),
|
'completionTokens': meta.get('completionTokens', 0),
|
||||||
|
'reasoningTokens': meta.get('reasoningTokens', 0),
|
||||||
|
'costUsd': meta.get('costUsd'),
|
||||||
'hasFinal': '✅' in answer, 'steps': len(_STEP_LINE.findall(answer)),
|
'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': '',
|
'error': error, 'verdict': 'error', 'why': '',
|
||||||
})
|
})
|
||||||
errors = sum(1 for r in rows if r['error'])
|
errors = sum(1 for r in rows if r['error'])
|
||||||
@@ -308,10 +351,11 @@ class Command(BaseCommand):
|
|||||||
with ThreadPoolExecutor(opts['workers']) as pool:
|
with ThreadPoolExecutor(opts['workers']) as pool:
|
||||||
verdicts = list(pool.map(
|
verdicts = list(pool.map(
|
||||||
lambda r: self._judge(opts['judge'], chunks_by_id[r['chunkId']], r['answer']), todo))
|
lambda r: self._judge(opts['judge'], chunks_by_id[r['chunkId']], r['answer']), todo))
|
||||||
for r, (verdict, why) in zip(todo, verdicts):
|
for r, (verdict, why, judge_cost) in zip(todo, verdicts):
|
||||||
r['verdict'], r['why'] = verdict, why
|
r['verdict'], r['why'], r['judgeCostUsd'] = verdict, why, judge_cost
|
||||||
results.extend(rows)
|
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):
|
def _report(self, models, results, summary):
|
||||||
tiers = ['primary', 'middle', 'upper']
|
tiers = ['primary', 'middle', 'upper']
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
3. **لحن سه سطح دارد** (دبستان / متوسطهی اول / متوسطهی دوم)، نه فقط نامِ پایه.
|
3. **لحن سه سطح دارد** (دبستان / متوسطهی اول / متوسطهی دوم)، نه فقط نامِ پایه.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
@@ -29,13 +30,14 @@ from .mathtext import StreamSanitizer, to_plain
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DEFAULT_FOLLOW_UPS = ['یک مثال دیگه بزن', 'بیشتر توضیح بده']
|
_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 = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
|
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
|
||||||
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
|
_MIDDLE = {'هفتم', 'هشتم', 'نهم'}
|
||||||
@@ -130,7 +132,8 @@ _METHOD = (
|
|||||||
'- اگر سوال مفهومی است، مفهوم را با یک مثالِ عددیِ کوتاه و حلشده توضیح بده.\n'
|
'- اگر سوال مفهومی است، مفهوم را با یک مثالِ عددیِ کوتاه و حلشده توضیح بده.\n'
|
||||||
'- دقت از همهچیز مهمتر است. پیش از نوشتنِ جوابِ نهایی آن را وارسی کن '
|
'- دقت از همهچیز مهمتر است. پیش از نوشتنِ جوابِ نهایی آن را وارسی کن '
|
||||||
'(جواب را در معادله جایگذاری کن، مشتق یا سادهسازی را دوباره انجام بده، '
|
'(جواب را در معادله جایگذاری کن، مشتق یا سادهسازی را دوباره انجام بده، '
|
||||||
'بزرگیِ عدد را تخمین بزن). اگر در وارسی اشتباهی دیدی، راهحل را اصلاح کن؛ '
|
'بزرگیِ عدد را تخمین بزن؛ توانِ هر متغیر و علامتِ هر جمله را جدا با صورتِ '
|
||||||
|
'سوال مقایسه کن). اگر در وارسی اشتباهی دیدی، راهحل را اصلاح کن؛ '
|
||||||
'هرگز جوابی را که وارسی نکردهای ننویس.\n'
|
'هرگز جوابی را که وارسی نکردهای ننویس.\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}'
|
asked = f'سوالِ دانشآموز: {question}'
|
||||||
|
slash_rule = primary and '/' in question
|
||||||
|
if slash_rule:
|
||||||
|
asked = f'{_SLASH_READING}\n\n{asked}'
|
||||||
if focus:
|
if focus:
|
||||||
asked = f'{focus}\n\n{asked}'
|
asked = f'{focus}\n\n{asked}'
|
||||||
if not contexts:
|
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))
|
excerpts = '\n\n'.join(f'[{i}] {text}' for i, text in enumerate(contexts, 1))
|
||||||
return (
|
return (
|
||||||
'چند بخش از کتابِ درسیِ همین دانشآموز که با جستجو پیدا شدهاند '
|
'چند بخش از کتابِ درسیِ همین دانشآموز که با جستجو پیدا شدهاند '
|
||||||
@@ -173,8 +199,7 @@ def _user_prompt(question: str, contexts: list[str], focus: str = '') -> str:
|
|||||||
'اگر به سوال مربوطاند، از روش و نمادگذاریِ همانها پیروی کن؛ اگر مربوط '
|
'اگر به سوال مربوطاند، از روش و نمادگذاریِ همانها پیروی کن؛ اگر مربوط '
|
||||||
'نیستند نادیدهشان بگیر و از دانشِ خودت در سطحِ همین پایه جواب بده. '
|
'نیستند نادیدهشان بگیر و از دانشِ خودت در سطحِ همین پایه جواب بده. '
|
||||||
'متنِ کتاب از روی تصویر خوانده شده و ممکن است غلطِ تایپی داشته باشد؛ '
|
'متنِ کتاب از روی تصویر خوانده شده و ممکن است غلطِ تایپی داشته باشد؛ '
|
||||||
'حسابِ خودت را ملاک بگذار. در متنِ کتاب «/» بینِ دو رقم ممیزِ اعشار است '
|
f'حسابِ خودت را ملاک بگذار. {slash_note}\n\n'
|
||||||
'(۳/۱۰۴ یعنی 3.104)، نه کسر.\n\n'
|
|
||||||
f'{asked}'
|
f'{asked}'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -220,7 +245,8 @@ def build_messages(question: str, context_chunks: list[dict] | None,
|
|||||||
text = (turn.get('text') or '').strip()
|
text = (turn.get('text') or '').strip()
|
||||||
if text:
|
if text:
|
||||||
messages.append({'role': role, 'content': text[:_HISTORY_CHARS]})
|
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
|
return messages
|
||||||
|
|
||||||
|
|
||||||
@@ -262,6 +288,14 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
|
|||||||
# آیا تورِ ایمنی چیزی را عوض کرد؟ نشانِ این است که مدل قاعدهی «متنِ
|
# آیا تورِ ایمنی چیزی را عوض کرد؟ نشانِ این است که مدل قاعدهی «متنِ
|
||||||
# ساده» را رعایت نکرده — برای مقایسهی مدلها در rag_model_benchmark.
|
# ساده» را رعایت نکرده — برای مقایسهی مدلها در rag_model_benchmark.
|
||||||
'sanitized': answer != raw,
|
'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
|
return answer, list(_DEFAULT_FOLLOW_UPS), meta
|
||||||
|
|
||||||
|
|||||||
@@ -140,14 +140,15 @@ class GenerateAnswerSanitizesTests(TestCase):
|
|||||||
return SimpleNamespace(chat=SimpleNamespace(completions=completions))
|
return SimpleNamespace(chat=SimpleNamespace(completions=completions))
|
||||||
|
|
||||||
def test_latex_from_the_model_never_reaches_the_student(self):
|
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',
|
with patch('rag.services.clients.get_openai_client',
|
||||||
return_value=self._client(r'۱) \(x^2 = 4\)' '\n' r'✅ جواب: \(x = \pm 2\)')):
|
return_value=self._client(r'۱) \(x^2 = 4\)' '\n' r'✅ جواب: \(x = \pm 2\)')):
|
||||||
answer, _f, meta = generate_answer('x² = 4', None, 'نهم')
|
answer, _f, meta = generate_answer('x² = 4', None, 'نهم')
|
||||||
self.assertEqual(answer, '۱) x² = 4\n✅ جواب: x = ±2')
|
self.assertEqual(answer, '۱) x² = 4\n✅ جواب: x = ±2')
|
||||||
self.assertTrue(meta['sanitized'])
|
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):
|
class DisputedSolutionTests(SimpleTestCase):
|
||||||
@@ -300,3 +301,32 @@ class QuestionFocusTests(TestCase):
|
|||||||
response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json')
|
response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json')
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
generate.assert_not_called()
|
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('۲۰ × ۲۰ = ؟', 'سوم'), '۲۰ × ۲۰ = ؟')
|
||||||
|
|||||||
Reference in New Issue
Block a user