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:
341
rag/management/commands/rag_model_benchmark.py
Normal file
341
rag/management/commands/rag_model_benchmark.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
مقایسهی مدلهای چت برای دستیار («از پی بپرس») با عدد، نه با حدس.
|
||||
|
||||
چرا جدا از سنجشِ استخراجِ PDF: آنجا مدل صفحهی کتاب را میخواند (بینایی)،
|
||||
اینجا باید مسئله را درست حل کند و مرحلهبهمرحله توضیح دهد. مدلی که در یکی
|
||||
بهترین است لزوماً در دیگری نیست — gpt-4o-mini در استخراج مختصات جعل میکرد و
|
||||
در چت هم مشتقِ x³ − 6x² + 9x را غلط گرفت.
|
||||
|
||||
روش:
|
||||
۱. از هر کتاب چند مسئلهی بیشکل برمیدارد که `rag_verify_solutions` جوابِ
|
||||
کتاب را تأیید کرده (`agrees`) و جوابِ مستقلش (`checked_answer`) ثبت است؛
|
||||
همان جواب کلیدِ تصحیح است.
|
||||
۲. هر مسئله را از همان مسیرِ واقعیِ دستیار میگذراند: retrieval روی کلِ کتابِ
|
||||
آن پایه (مثلِ صفحهی «از پی بپرس») + همان پرامپتِ تولید. خودِ همان تکه از
|
||||
context حذف میشود تا مدل جواب را از کتاب رونویسی نکند.
|
||||
۳. یک مدلِ داورِ ثابت فقط جوابِ نهایی را با کلید مقایسه میکند.
|
||||
۴. هزینهی واقعی از `/dashboard/billing/usage` قبل و بعد از هر مدل خوانده
|
||||
میشود — قیمتِ GapGPT با جدولِ رسمی یکی نیست (docs/extraction-playbook.md).
|
||||
|
||||
# رایگان: فقط نمونه را نشان میدهد
|
||||
python manage.py rag_model_benchmark --dry-run
|
||||
|
||||
python manage.py rag_model_benchmark --models gpt-4o-mini gpt-4.1-mini o4-mini \\
|
||||
--per-grade 6 --json /tmp/bench.json
|
||||
|
||||
⚠️ GapGPT فقط از داخلِ ایران جواب میدهد؛ این را روی سرور اجرا کنید.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from rag.management.commands.rag_verify_solutions import (
|
||||
AccountLimitReached, is_account_limit, needs_figure,
|
||||
)
|
||||
from rag.models import LessonChunk
|
||||
from rag.services import clients
|
||||
from rag.services.config import rag_is_configured
|
||||
from rag.services.generation import completion_kwargs, generate_answer, grade_tier
|
||||
from rag.services.retrieval import retrieve_context
|
||||
|
||||
DEFAULT_MODELS = ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-4.1', 'o4-mini']
|
||||
DEFAULT_JUDGE = 'gpt-4.1'
|
||||
|
||||
JUDGE_PROMPT = """یک مسئلهی ریاضیِ مدرسه، جوابِ درستِ آن (کلید)، و پاسخِ یک معلم به دانشآموز
|
||||
را میبینی. فقط بگو جوابِ نهاییِ معلم با کلید یکی است یا نه.
|
||||
|
||||
قواعد:
|
||||
- شکلِ نوشتن مهم نیست: 0.5 = ۱/۲ = نیم؛ «۳/۵» در کلید ممکن است ممیزِ اعشار باشد (۳٫۵).
|
||||
- ترتیب، واحدِ همارز، و گِرد کردنِ معقول خطا نیست.
|
||||
- اگر کلید چند بخش دارد، همهی بخشها باید درست باشند.
|
||||
- اگر معلم جوابِ نهایی نداده یا چند جوابِ متناقض داده، "wrong".
|
||||
- اگر خودِ کلید با مسئله نمیخواند یا مبهم است، "unclear".
|
||||
|
||||
مسئله:
|
||||
{problem}
|
||||
|
||||
کلید:
|
||||
{key}
|
||||
|
||||
پاسخِ معلم:
|
||||
{answer}
|
||||
|
||||
فقط JSON: {{"verdict": "correct|wrong|unclear", "why": "حداکثر ۸۰ نویسه"}}"""
|
||||
|
||||
# نشانهی LaTeX/markdown در خروجیِ *خامِ* مدل (پیش از تورِ ایمنی)
|
||||
_STEP_LINE = re.compile(r'^\s*([0-9۰-۹]+[)\.)]|مرحله)', re.M)
|
||||
|
||||
|
||||
_KEY_NOT_AN_ANSWER = re.compile(r'کتاب|اثبات|درست است|صحیح است|مطابق|توضیح')
|
||||
|
||||
|
||||
def _gradable(chunk) -> bool:
|
||||
"""
|
||||
فقط مسئلهای که جوابِ مشخص و کوتاه دارد (عدد، عبارت، مجموعه). کلیدهایی
|
||||
مثلِ «راهحلِ کتاب مطابقِ تعریف است» یا اثباتها را نمیشود با کلید سنجید.
|
||||
"""
|
||||
key = (chunk.checked_answer or '').strip()
|
||||
return (
|
||||
0 < len(key) <= 80
|
||||
and bool(re.search(r'[0-9۰-۹]', key))
|
||||
and not _KEY_NOT_AN_ANSWER.search(key)
|
||||
and not chunk.problem.strip().startswith(('اثبات', 'ثابت کنید', 'نشان دهید'))
|
||||
)
|
||||
|
||||
|
||||
def _stable_order(chunk_id: int, seed: str) -> str:
|
||||
return hashlib.sha1(f'{seed}:{chunk_id}'.encode()).hexdigest()
|
||||
|
||||
|
||||
def _grade_of(item) -> str:
|
||||
return (item['grade'] or item['collection'].split()[-1]).strip()
|
||||
|
||||
|
||||
def _usage_cents() -> float | None:
|
||||
"""مصرفِ تجمعیِ حساب به سنت، یا None اگر provider این endpoint را ندارد."""
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
|
||||
try:
|
||||
response = httpx.get(
|
||||
f'{settings.OPENAI_BASE_URL.rstrip("/")}/dashboard/billing/usage',
|
||||
headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, timeout=20,
|
||||
)
|
||||
data = response.json()
|
||||
return float(data['total_usage']) if 'total_usage' in data else None
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'مقایسهی دقت/هزینه/سرعتِ مدلهای چت روی مسئلههای تأییدشدهی کتاب'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--models', nargs='+', default=DEFAULT_MODELS)
|
||||
parser.add_argument('--judge', default=DEFAULT_JUDGE)
|
||||
parser.add_argument('--per-grade', type=int, default=6)
|
||||
parser.add_argument('--collections', nargs='*', default=None,
|
||||
help='پیشفرض: همهی کتابهای ریاضی/حسابان')
|
||||
parser.add_argument('--seed', default='bench-v1')
|
||||
parser.add_argument('--workers', type=int, default=4)
|
||||
parser.add_argument('--no-context', action='store_true',
|
||||
help='بدونِ retrieval — فقط توانِ ریاضیِ خودِ مدل')
|
||||
parser.add_argument('--json', dest='json_path', default=None)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
parser.add_argument('--resume', action='store_true',
|
||||
help='مدلهایی که در فایلِ --json تمام شدهاند دوباره اجرا نشوند')
|
||||
# LessonChunk (و کلیدِ checked_answer) فقط در دیتابیسِ محلی است، ولی GapGPT
|
||||
# فقط از ایران جواب میدهد: نمونه را اینجا بنویس، روی سرور بخوان.
|
||||
parser.add_argument('--export-sample', default=None, metavar='PATH')
|
||||
parser.add_argument('--sample-file', default=None, metavar='PATH')
|
||||
|
||||
# ------------------------------------------------------------------ sample
|
||||
def _sample(self, per_grade, collections, seed):
|
||||
qs = LessonChunk.objects.filter(solution_verdict='agrees').exclude(
|
||||
checked_answer='').exclude(problem='')
|
||||
if collections:
|
||||
qs = qs.filter(collection__in=collections)
|
||||
else:
|
||||
qs = qs.filter(collection__regex=r'^(ریاضی|حسابان) ')
|
||||
by_collection = defaultdict(list)
|
||||
for chunk in qs.only('id', 'problem', 'checked_answer', 'collection', 'grade_title',
|
||||
'subject', 'chapter'):
|
||||
if needs_figure(chunk.problem) or not _gradable(chunk):
|
||||
continue
|
||||
by_collection[chunk.collection].append(chunk)
|
||||
items = []
|
||||
for collection in sorted(by_collection):
|
||||
chunks = sorted(by_collection[collection], key=lambda c: _stable_order(c.id, seed))
|
||||
items.extend({
|
||||
'id': c.id, 'problem': c.problem, 'key': c.checked_answer,
|
||||
'collection': c.collection, 'grade': c.grade_title,
|
||||
} for c in chunks[:per_grade])
|
||||
return items
|
||||
|
||||
# ----------------------------------------------------------------- context
|
||||
def _context(self, item):
|
||||
subject = item['collection'].rsplit(' ', 1)[0]
|
||||
hits, _meta = retrieve_context(item['problem'], subject=subject, grade_title=_grade_of(item))
|
||||
needle = ' '.join(item['problem'].split())[:60]
|
||||
# همان تکه (و هر تکهای که عینِ این مسئله را دارد) بیرون: وگرنه مدل
|
||||
# فقط راهحلِ کتاب را رونویسی میکند و سنجه بیمعنا میشود.
|
||||
return [h for h in hits if needle not in ' '.join(h.get('summary', '').split())]
|
||||
|
||||
# ------------------------------------------------------------------ judge
|
||||
def _judge(self, judge_model, item, answer):
|
||||
client = clients.get_openai_client()
|
||||
prompt = JUDGE_PROMPT.format(problem=item['problem'], key=item['key'],
|
||||
answer=answer[-2500:])
|
||||
for attempt in range(3):
|
||||
try:
|
||||
completion = client.chat.completions.create(
|
||||
model=judge_model, messages=[{'role': 'user', 'content': prompt}],
|
||||
response_format={'type': 'json_object'}, **completion_kwargs(judge_model),
|
||||
)
|
||||
data = json.loads(completion.choices[0].message.content or '{}')
|
||||
verdict = data.get('verdict')
|
||||
if verdict in ('correct', 'wrong', 'unclear'):
|
||||
return verdict, str(data.get('why') or '')[:120]
|
||||
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', 'داوری شکست خورد'
|
||||
|
||||
# ------------------------------------------------------------------ handle
|
||||
def handle(self, *args, **opts):
|
||||
if opts['sample_file']:
|
||||
with open(opts['sample_file'], encoding='utf-8') as fh:
|
||||
items = json.load(fh)
|
||||
else:
|
||||
items = self._sample(opts['per_grade'], opts['collections'], opts['seed'])
|
||||
if opts['export_sample']:
|
||||
with open(opts['export_sample'], 'w', encoding='utf-8') as fh:
|
||||
json.dump(items, fh, ensure_ascii=False, indent=1)
|
||||
self.stdout.write(f'{len(items)} مسئله در {opts["export_sample"]} نوشته شد.')
|
||||
return
|
||||
if not items:
|
||||
raise CommandError('هیچ مسئلهی تأییدشدهای پیدا نشد.')
|
||||
|
||||
per_collection = defaultdict(int)
|
||||
for item in items:
|
||||
per_collection[item['collection']] += 1
|
||||
self.stdout.write(f'{len(items)} مسئله از {len(per_collection)} کتاب:')
|
||||
for collection, n in sorted(per_collection.items()):
|
||||
self.stdout.write(f' {collection:18s} {n}')
|
||||
calls = len(items) * len(opts['models'])
|
||||
self.stdout.write(f'{calls} پاسخ + {calls} داوری با {opts["judge"]}')
|
||||
if opts['dry_run']:
|
||||
for item in items[:3]:
|
||||
self.stdout.write(f'\n— {item["collection"]}: {item["problem"][:160]}\n کلید: {item["key"][:80]}')
|
||||
return
|
||||
if not rag_is_configured():
|
||||
raise CommandError('RAG پیکربندی نشده (OPENAI_API_KEY/QDRANT_API_KEY).')
|
||||
|
||||
# context یک بار برای همهی مدلها: مقایسه منصفانه و embedding ارزانتر.
|
||||
contexts = {}
|
||||
if not opts['no_context']:
|
||||
self.stdout.write('retrieval …')
|
||||
try:
|
||||
with ThreadPoolExecutor(opts['workers']) as pool:
|
||||
for item, ctx in zip(items, pool.map(self._context, items)):
|
||||
contexts[item['id']] = ctx
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if is_account_limit(exc):
|
||||
raise CommandError(f'⛔ اعتبارِ حساب تمام شده است: {str(exc)[:160]}') from exc
|
||||
raise
|
||||
|
||||
# نتیجهی هر مدل همان لحظه روی دیسک میرود. اجرای اول وسطِ کار با تمام
|
||||
# شدنِ اعتبار مرد و چون فقط آخرِ کار ذخیره میکرد، پاسخهای چهار مدلِ
|
||||
# پرداختشده از دست رفت. با --resume مدلهای تمامشده دوباره اجرا نمیشوند.
|
||||
results, summary = [], {}
|
||||
if opts['resume'] and opts['json_path']:
|
||||
try:
|
||||
with open(opts['json_path'], encoding='utf-8') as fh:
|
||||
saved = json.load(fh)
|
||||
summary = saved.get('summary', {})
|
||||
results = [r for r in saved.get('results', []) if r['model'] in summary]
|
||||
self.stdout.write(f'ادامه: {len(summary)} مدل از قبل — {", ".join(summary)}')
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
chunks_by_id = {item['id']: item for item in items}
|
||||
try:
|
||||
for model in opts['models']:
|
||||
if model in summary:
|
||||
continue
|
||||
self._run_model(model, items, contexts, chunks_by_id, opts, results, summary)
|
||||
self._save(opts['json_path'], summary, results)
|
||||
except AccountLimitReached as exc:
|
||||
self._save(opts['json_path'], summary, results)
|
||||
self.stderr.write(self.style.ERROR(
|
||||
f'\n⛔ اعتبارِ حساب تمام شد ({exc}). نتیجهی مدلهای تمامشده ذخیره شد؛ '
|
||||
'بعد از شارژ با همان دستور و --resume ادامه بدهید.'))
|
||||
self._report([m for m in opts['models'] if m in summary], results, summary)
|
||||
|
||||
def _save(self, path, summary, results):
|
||||
if not path:
|
||||
return
|
||||
with open(path, 'w', encoding='utf-8') as fh:
|
||||
json.dump({'summary': summary, 'results': results}, fh, ensure_ascii=False, indent=1)
|
||||
|
||||
def _run_model(self, model, items, contexts, chunks_by_id, opts, results, summary):
|
||||
self.stdout.write(f'\n▶ {model}')
|
||||
before = _usage_cents()
|
||||
started = time.monotonic()
|
||||
|
||||
def run(item):
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
answer, _f, meta = generate_answer(
|
||||
item['problem'], contexts.get(item['id']) or None, _grade_of(item), model=model,
|
||||
)
|
||||
return item, answer, meta, time.monotonic() - t0, None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if is_account_limit(exc):
|
||||
raise AccountLimitReached(str(exc)[:160]) from exc
|
||||
return item, '', {}, time.monotonic() - t0, str(exc)[:200]
|
||||
|
||||
with ThreadPoolExecutor(opts['workers']) as pool:
|
||||
answered = list(pool.map(run, items))
|
||||
after = _usage_cents()
|
||||
wall = time.monotonic() - started
|
||||
cost = (after - before) if before is not None and after is not None else None
|
||||
|
||||
rows = []
|
||||
for item, answer, meta, seconds, error in answered:
|
||||
rows.append({
|
||||
'model': model, 'chunkId': item['id'], 'collection': item['collection'],
|
||||
'tier': grade_tier(_grade_of(item)), 'problem': item['problem'],
|
||||
'key': item['key'], 'answer': answer, 'seconds': round(seconds, 1),
|
||||
'sanitized': bool(meta.get('sanitized')),
|
||||
'completionTokens': meta.get('completionTokens', 0),
|
||||
'hasFinal': '✅' in answer, 'steps': len(_STEP_LINE.findall(answer)),
|
||||
'error': error, 'verdict': 'error', 'why': '',
|
||||
})
|
||||
errors = sum(1 for r in rows if r['error'])
|
||||
self.stdout.write(
|
||||
f' {len(rows) - errors} پاسخ، {errors} خطا، '
|
||||
f'{"هزینه: %.2f سنت" % cost if cost is not None else "هزینه: نامعلوم"}، {wall:.0f}s'
|
||||
)
|
||||
|
||||
todo = [r for r in rows if r['answer']]
|
||||
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
|
||||
results.extend(rows)
|
||||
summary[model] = {'costCents': cost, 'wallSeconds': round(wall, 1)}
|
||||
|
||||
def _report(self, models, results, summary):
|
||||
tiers = ['primary', 'middle', 'upper']
|
||||
header = f'{"مدل":18s} {"درست":>7s} {"دبستان":>7s} {"متوسطه۱":>7s} {"متوسطه۲":>7s} ' \
|
||||
f'{"نامعلوم":>6s} {"سنت/پاسخ":>8s} {"ثانیه":>6s} {"LaTeX":>6s} {"✅":>5s}'
|
||||
self.stdout.write('\n' + header)
|
||||
for model in models:
|
||||
rows = [r for r in results if r['model'] == model]
|
||||
judged = [r for r in rows if r['verdict'] in ('correct', 'wrong')]
|
||||
|
||||
def pct(subset):
|
||||
graded = [r for r in subset if r['verdict'] in ('correct', 'wrong')]
|
||||
if not graded:
|
||||
return '—'
|
||||
return f'{100 * sum(r["verdict"] == "correct" for r in graded) / len(graded):.0f}%'
|
||||
|
||||
cost = summary[model]['costCents']
|
||||
per = f'{cost / len(rows):.3f}' if cost is not None and rows else '?'
|
||||
secs = sum(r['seconds'] for r in rows) / max(len(rows), 1)
|
||||
latex = sum(r['sanitized'] for r in rows) / max(len(rows), 1)
|
||||
final = sum(r['hasFinal'] for r in rows) / max(len(rows), 1)
|
||||
self.stdout.write(
|
||||
f'{model:18s} {pct(judged):>7s} '
|
||||
+ ' '.join(f'{pct([r for r in rows if r["tier"] == t]):>7s}' for t in tiers)
|
||||
+ f' {sum(r["verdict"] == "unclear" for r in rows):>6d} {per:>8s} {secs:>6.1f}'
|
||||
f' {100 * latex:>5.0f}% {100 * final:>4.0f}%'
|
||||
)
|
||||
@@ -64,22 +64,9 @@ class AccountLimitReached(Exception):
|
||||
"""حسابِ سرویس جواب نمیدهد؛ ادامه دادن بیمعناست."""
|
||||
|
||||
|
||||
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
|
||||
# خودِ تشخیص در rag.services.clients است چون دستیارِ زنده هم لازمش دارد
|
||||
# (/api/health باید بگوید «اعتبار تمام شده»، نه فقط «کلید تنظیم شده»).
|
||||
from rag.services.clients import is_account_limit # noqa: E402,F401
|
||||
|
||||
|
||||
# مسئلهای که به شکلِ کتاب ارجاع میدهد و محتوایش را ننوشته، بدونِ تصویر
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
|
||||
|
||||
302
rag/tests_tutor_quality.py
Normal file
302
rag/tests_tutor_quality.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
کیفیتِ پاسخِ دستیار (۲۰۲۶-۰۹-۲۵): متنِ ساده بهجای LaTeX، لحنِ سهسطحی، حلِ
|
||||
مرحلهبهمرحله با وارسی، حافظهی گفتگو، و کنار گذاشتنِ راهحلهای غلطِ کتاب.
|
||||
|
||||
هر تست یکی از ایرادهایی را میپوشاند که روی جوابهای واقعی دیده شد؛
|
||||
توضیحِ هرکدام کنارِ خودِ کد است.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
|
||||
from .services.clients import reset_clients_cache
|
||||
from .services.mathtext import StreamSanitizer, to_plain
|
||||
|
||||
RAG_ON = dict(
|
||||
OPENAI_API_KEY='test-openai', QDRANT_API_KEY='test-qdrant', RAG_FORCE_MOCK=False,
|
||||
)
|
||||
|
||||
|
||||
class PlainMathTextTests(SimpleTestCase):
|
||||
def test_inline_and_display_latex_become_unicode(self):
|
||||
text = r'مشتق \(f(x)=x^{3}-6x^2+9x\) برابر است با:' '\n' r'\[ f\'(x) = 3x^2 - 12x + 9 \]'
|
||||
self.assertEqual(to_plain(text), "مشتق f(x)=x³-6x²+9x برابر است با:\nf'(x) = 3x² - 12x + 9")
|
||||
|
||||
def test_fractions_keep_their_meaning(self):
|
||||
# «1/2h» یعنی h/2 — مخرجِ چندنویسهای باید پرانتز بگیرد.
|
||||
self.assertEqual(to_plain(r'\(\frac{a}{2h}\)'), 'a/(2h)')
|
||||
self.assertEqual(to_plain(r'\(\frac{x+1}{x-2}\)'), '(x+1)/(x-2)')
|
||||
self.assertEqual(to_plain(r'\(\frac{\sqrt{3}}{2}\)'), '√3/2')
|
||||
|
||||
def test_primary_fractions_are_written_in_words(self):
|
||||
# در کتابِ دبستان «۲/۵» یعنی دو و نیم؛ کسر نباید با «/» نوشته شود.
|
||||
self.assertEqual(to_plain(r'\(\frac{3}{4}\)', fraction_word=True), '3 بر 4')
|
||||
|
||||
def test_roots_limits_degrees_and_symbols(self):
|
||||
self.assertEqual(to_plain(r'\(\sqrt[3]{8} = 2\)'), '³√8 = 2')
|
||||
self.assertEqual(to_plain(r'\(\lim_{x \to 0} \frac{\sin x}{x} = 1\)'), 'lim(x → 0) (sin x)/x = 1')
|
||||
self.assertEqual(to_plain(r'\(30^\circ\)'), '30°')
|
||||
self.assertEqual(to_plain(r'\(a \leq b \neq c \times d\)'), 'a ≤ b ≠ c × d')
|
||||
self.assertEqual(to_plain('x^10 + y^2'), 'x¹⁰ + y²')
|
||||
|
||||
def test_set_braces_survive(self):
|
||||
# مجموعهها در ریاضیِ نهم با آکولاد نوشته میشوند.
|
||||
self.assertEqual(to_plain('A = {1, 2, 3}'), 'A = {1, 2, 3}')
|
||||
self.assertEqual(to_plain(r'\(A = \{1, 2\} \cup B\)'), 'A = {1, 2} ∪ B')
|
||||
|
||||
def test_markdown_is_removed(self):
|
||||
text = '### مرحله ۱\n**نکته:** این مهم است\n| x | y |\n|---|---|\n| 1 | 2 |\n- اول'
|
||||
self.assertEqual(to_plain(text), 'مرحله ۱\nنکته: این مهم است\nx | y\n1 | 2\n• اول')
|
||||
|
||||
def test_plain_text_passes_through(self):
|
||||
text = '۱) دو طرف را بر ۲ تقسیم میکنیم.\n۲) x = 3\n✅ جواب: 3'
|
||||
self.assertEqual(to_plain(text), text)
|
||||
|
||||
def test_stream_output_equals_batch_output_however_it_is_split(self):
|
||||
text = (
|
||||
'### حل\nاول \\(x^{2}\\) را حساب میکنیم:\n\\[\n\\frac{a+b}{2}\n\\]\n\n\n'
|
||||
'| a | b |\n|---|---|\n| 1 | 2 |\n**تمام** \\(\\sqrt{16}=4\\)'
|
||||
)
|
||||
for step in (1, 2, 3, 7, 50):
|
||||
sanitizer = StreamSanitizer()
|
||||
out = ''.join(sanitizer.feed(text[i:i + step]) for i in range(0, len(text), step))
|
||||
out += sanitizer.flush()
|
||||
self.assertEqual(out, to_plain(text), f'step={step}')
|
||||
|
||||
|
||||
class TutorPromptTests(SimpleTestCase):
|
||||
def test_three_tiers_have_different_voices(self):
|
||||
from rag.services.generation import system_prompt
|
||||
|
||||
primary, middle, upper = system_prompt('سوم'), system_prompt('هشتم'), system_prompt('یازدهم')
|
||||
self.assertIn('دبستانی', primary)
|
||||
self.assertIn('سیب', primary)
|
||||
self.assertNotIn('دبستان', middle)
|
||||
self.assertIn('دبیرستان', upper)
|
||||
self.assertIn('دامنه', upper)
|
||||
|
||||
def test_every_prompt_demands_full_steps_a_check_and_plain_text(self):
|
||||
from rag.services.generation import system_prompt
|
||||
|
||||
for grade in ('سوم', 'نهم', 'دوازدهم', ''):
|
||||
prompt = system_prompt(grade)
|
||||
self.assertIn('مرحلهبهمرحله', prompt)
|
||||
self.assertIn('وارسی', prompt)
|
||||
self.assertIn('✅ جواب', prompt)
|
||||
self.assertIn('LaTeX', prompt)
|
||||
|
||||
def test_decimal_notation_follows_the_book_for_primary(self):
|
||||
from rag.services.generation import system_prompt
|
||||
|
||||
self.assertIn('۲/۵ یعنی', system_prompt('ششم'))
|
||||
self.assertIn('(3.5)', system_prompt('دهم'))
|
||||
|
||||
def test_history_goes_before_the_new_question(self):
|
||||
from rag.services.generation import HISTORY_TURNS, build_messages
|
||||
|
||||
history = [{'role': 'user', 'text': f'سوال {i}'} if i % 2 == 0
|
||||
else {'role': 'assistant', 'text': f'جواب {i}'} for i in range(10)]
|
||||
messages = build_messages('نفهمیدم', [{'summary': 'متنِ کتاب'}], 'نهم', history)
|
||||
self.assertEqual(messages[0]['role'], 'system')
|
||||
self.assertEqual(len(messages), 1 + HISTORY_TURNS + 1)
|
||||
self.assertEqual(messages[-2], {'role': 'assistant', 'content': 'جواب 9'})
|
||||
self.assertTrue(messages[-1]['content'].endswith('سوالِ دانشآموز: نفهمیدم'))
|
||||
self.assertIn('متنِ کتاب', messages[-1]['content'])
|
||||
|
||||
def test_reasoning_models_get_no_temperature(self):
|
||||
from rag.services.generation import completion_kwargs
|
||||
|
||||
self.assertEqual(completion_kwargs('gpt-4o-mini'), {'temperature': 0.2})
|
||||
with override_settings(OPENAI_CHAT_REASONING_EFFORT='low'):
|
||||
self.assertEqual(completion_kwargs('o4-mini'), {'reasoning_effort': 'low'})
|
||||
self.assertEqual(completion_kwargs('gpt-5-mini'), {'reasoning_effort': 'low'})
|
||||
|
||||
@override_settings(OPENAI_CHAT_MODEL='cheap', OPENAI_CHAT_MODEL_UPPER='strong')
|
||||
def test_upper_grades_can_have_their_own_model(self):
|
||||
from rag.services.generation import chat_model_for
|
||||
|
||||
self.assertEqual(chat_model_for('دوازدهم'), 'strong')
|
||||
self.assertEqual(chat_model_for('نهم'), 'cheap')
|
||||
self.assertEqual(chat_model_for(''), 'cheap')
|
||||
|
||||
|
||||
@override_settings(**RAG_ON)
|
||||
class GenerateAnswerSanitizesTests(TestCase):
|
||||
def setUp(self):
|
||||
reset_clients_cache()
|
||||
|
||||
def tearDown(self):
|
||||
reset_clients_cache()
|
||||
|
||||
def _client(self, reply):
|
||||
completions = SimpleNamespace(create=lambda **kw: SimpleNamespace(
|
||||
choices=[SimpleNamespace(message=SimpleNamespace(content=reply))], usage=None))
|
||||
return SimpleNamespace(chat=SimpleNamespace(completions=completions))
|
||||
|
||||
def test_latex_from_the_model_never_reaches_the_student(self):
|
||||
from rag.services.generation import 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')
|
||||
|
||||
|
||||
class DisputedSolutionTests(SimpleTestCase):
|
||||
def _payload(self, verdict):
|
||||
return {'generalSolution': 'روش', 'problem': 'مسئله', 'solution': 'راهحلِ کتاب',
|
||||
'solution_verdict': verdict}
|
||||
|
||||
def test_a_solution_the_checker_disputes_is_not_shown_to_the_tutor(self):
|
||||
from rag.services.retrieval import _payload_text
|
||||
|
||||
text = _payload_text(self._payload('disagrees'))
|
||||
self.assertIn('مسئله', text)
|
||||
self.assertNotIn('راهحلِ کتاب:', text)
|
||||
|
||||
def test_agreed_and_unchecked_solutions_still_go(self):
|
||||
from rag.services.retrieval import _payload_text
|
||||
|
||||
for verdict in ('agrees', 'unjudgeable', None):
|
||||
self.assertIn('راهحلِ کتاب: راهحلِ کتاب', _payload_text(self._payload(verdict)))
|
||||
|
||||
|
||||
@override_settings(**RAG_ON, RAG_ANSWER_CACHE_TTL=300)
|
||||
class ConversationMemoryTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
reset_clients_cache()
|
||||
self.client = APIClient()
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def _user(self, phone):
|
||||
user = User.objects.create(phone=phone, is_guest=False)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
return client
|
||||
|
||||
def _ask(self, client, text, conversation_id=None):
|
||||
body = {'userQuestion': text, 'subject': 'ریاضی', 'grade': 'نهم', 'lessonNumber': 4}
|
||||
if conversation_id:
|
||||
body['conversationId'] = conversation_id
|
||||
return client.post('/api/ai/ask', body, format='json')
|
||||
|
||||
def test_follow_up_sees_the_conversation_and_searches_with_the_topic(self):
|
||||
client = self._user('09120000901')
|
||||
retrieve = patch('rag.services.retrieval.retrieve_context',
|
||||
return_value=([], {'gradeTitle': 'نهم'}))
|
||||
generate = patch('rag.services.generation.generate_answer',
|
||||
return_value=('جوابِ توان', [], {}))
|
||||
with retrieve as retrieve_mock, generate as generate_mock:
|
||||
first = self._ask(client, '۲ به توان منفی ۳ چند است؟')
|
||||
self._ask(client, 'نفهمیدم', first.json()['conversationId'])
|
||||
|
||||
history = generate_mock.call_args.kwargs['history']
|
||||
self.assertEqual([t['text'] for t in history], ['۲ به توان منفی ۳ چند است؟', 'جوابِ توان'])
|
||||
self.assertIn('توان منفی', retrieve_mock.call_args.args[0])
|
||||
|
||||
def test_a_follow_up_is_never_served_from_someone_elses_cache(self):
|
||||
replies = iter(['جوابِ اولِ الف', 'جوابِ «نفهمیدم»ِ الف', 'جوابِ اولِ ب', 'جوابِ «نفهمیدم»ِ ب'])
|
||||
retrieve = patch('rag.services.retrieval.retrieve_context',
|
||||
return_value=([], {'gradeTitle': 'نهم'}))
|
||||
generate = patch('rag.services.generation.generate_answer',
|
||||
side_effect=lambda *a, **k: (next(replies), [], {}))
|
||||
with retrieve, generate:
|
||||
a, b = self._user('09120000902'), self._user('09120000903')
|
||||
conv_a = self._ask(a, 'اتحاد چیست؟').json()['conversationId']
|
||||
self._ask(a, 'نفهمیدم', conv_a)
|
||||
conv_b = self._ask(b, 'تجزیه چیست؟').json()['conversationId']
|
||||
second_b = self._ask(b, 'نفهمیدم', conv_b)
|
||||
|
||||
self.assertEqual(second_b.json()['answer'], 'جوابِ «نفهمیدم»ِ ب')
|
||||
|
||||
|
||||
@override_settings(**RAG_ON)
|
||||
class ProviderStateTests(TestCase):
|
||||
"""اعتبارِ تمامشده باید در /api/health دیده شود، نه فقط در لاگ."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
reset_clients_cache()
|
||||
user = User.objects.create(phone='09120000904', is_guest=False)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def _ask(self, generate):
|
||||
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
|
||||
patch('rag.services.generation.generate_answer', **generate):
|
||||
return self.client.post('/api/ai/ask', {'userQuestion': 'سلام'}, format='json')
|
||||
|
||||
def test_exhausted_credit_shows_up_in_health_and_clears_on_recovery(self):
|
||||
quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001")
|
||||
self.assertEqual(self._ask({'side_effect': quota}).status_code, 503)
|
||||
|
||||
health = self.client.get('/api/health').json()
|
||||
self.assertEqual(health['integrations']['aiProvider']['state'], 'quota_exhausted')
|
||||
self.assertTrue(any('GapGPT' in w for w in health['warnings']))
|
||||
|
||||
self.assertEqual(self._ask({'return_value': ('جواب', [], {})}).status_code, 200)
|
||||
health = self.client.get('/api/health').json()
|
||||
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
|
||||
|
||||
def test_an_ordinary_network_error_does_not_claim_the_credit_is_gone(self):
|
||||
self.assertEqual(self._ask({'side_effect': ConnectionError('timeout')}).status_code, 503)
|
||||
health = self.client.get('/api/health').json()
|
||||
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
|
||||
|
||||
|
||||
@override_settings(**RAG_ON)
|
||||
class QuestionFocusTests(TestCase):
|
||||
"""«چرا جوابم غلط بود؟» — دستیار باید همان سوال و جوابِ دانشآموز را ببیند."""
|
||||
|
||||
def setUp(self):
|
||||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||||
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='ف', order=1),
|
||||
title='ن', order=1)
|
||||
self.question = Question.objects.create(lesson=Lesson.objects.create(node=node, title='د'),
|
||||
type='multiple_choice', prompt='۲ به توان منفی ۳؟')
|
||||
self.right = Choice.objects.create(question=self.question, value='1/8', is_correct=True)
|
||||
self.wrong = Choice.objects.create(question=self.question, value='-8', is_correct=False)
|
||||
user = User.objects.create(phone='09120000905', is_guest=False, grade_id=grade.id)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def test_the_tutor_sees_the_question_and_the_wrong_answer(self):
|
||||
self.client.post(f'/api/questions/{self.question.id}/answer',
|
||||
{'selectedOptionId': self.wrong.id}, format='json')
|
||||
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
|
||||
patch('rag.services.generation.generate_answer',
|
||||
return_value=('جواب', [], {})) as generate:
|
||||
response = self.client.post('/api/ai/ask', {
|
||||
'userQuestion': 'چرا غلط شد؟', 'questionId': str(self.question.id),
|
||||
}, format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
focus = generate.call_args.kwargs['focus']
|
||||
self.assertIn('۲ به توان منفی ۳؟', focus)
|
||||
self.assertIn('-8 (غلط)', focus)
|
||||
self.assertIn('جوابِ درست: 1/8', focus)
|
||||
|
||||
def test_an_overlong_question_is_refused_before_any_paid_call(self):
|
||||
with patch('rag.services.generation.generate_answer') as generate:
|
||||
response = self.client.post('/api/ai/ask', {'userQuestion': 'x' * 2001}, format='json')
|
||||
self.assertEqual(response.status_code, 400)
|
||||
generate.assert_not_called()
|
||||
Reference in New Issue
Block a user