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:
2026-09-25 06:24:13 +03:30
parent fbdb9495ee
commit 42783f9ba4
56 changed files with 2792 additions and 326 deletions

View 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}%'
)

View File

@@ -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
# مسئله‌ای که به شکلِ کتاب ارجاع می‌دهد و محتوایش را ننوشته، بدونِ تصویر

View File

@@ -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

View File

@@ -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()

View File

@@ -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
View 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

View File

@@ -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
View 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()