Files
MathApp/rag/management/commands/rag_verify_solutions.py
Zareian 42783f9ba4 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>
2026-09-25 06:24:13 +03:30

391 lines
21 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
راستی‌آزماییِ راه‌حل‌هایی که vision از کتابِ درسی درآورده.
چرا لازم است: استخراج گاهی راه‌حلِ غلط می‌سازد و این خطا در بازیابی دیده
نمی‌شود — فقط وقتی کسی به `solution` اعتماد کند (ساختِ سؤال، یا خودِ دستیار
هنگامِ نقلِ جواب) خودش را نشان می‌دهد. نمونه‌های واقعی از همین کتاب‌ها:
BMI = ۱۰۰/(۲٫۷۴)² کتاب: ۳۳٫۲ درست: ۱۳٫۳
۱۶ آبی + ۲۴ صورتی کتاب: «کل ۵۰» درست: ۴۰
بزرگ‌ترین عددِ ۴رقمیِ بی‌تکرار کتاب: ۸۵۳۲ درست: ۹۸۷۶
g(x) = x³ − ۲ کتاب: «نمایی» درست: چندجمله‌ایِ درجه ۳
`solution` هرگز بازنویسی نمی‌شود. کتاب باید کتاب بماند: اگر جوابِ مدل را
جایش می‌گذاشتیم و مدل اشتباه می‌کرد، منبعِ حقیقت بی‌صدا خراب می‌شد.
──────────────────────────────────────────────────────────────────────────
درس‌هایی که از اجرای اولِ این دستور گرفته شد و در طراحیِ فعلی نشسته‌اند:
۱. نتیجه باید سه‌حالته باشد. با بله/خیر، «نمی‌دانم» ناچار به «غلط است»
ترجمه می‌شد: ۳۵٪ از گزارش‌ها مسئله‌هایی بودند که به شکل یا جدولِ کتاب
ارجاع می‌دادند و اصلاً قابلِ قضاوت نبودند.
۲. «۳/۱۰۴» در این کتاب‌ها عددِ اعشاریِ ۳٫۱۰۴ است، نه کسرِ ۳÷۱۰۴. بدونِ گفتنِ
این به مدل، ۲۴٪ از گزارش‌ها هشدارِ کاذب بود — بیشترشان در پایه ششم که
پر از اعشار است.
۳. مسئله‌های وابسته به شکل اصلاً فرستاده نمی‌شوند. هزینه دارند و جوابشان
بی‌معناست؛ همان‌جا برچسبِ unjudgeable می‌خورند.
۴. دو مدل، دو نقش. deepseek-reasoner ارزان است و تشخیصش خوب، ولی جوابِ
خودش گاهی غلط است (مسئله‌ی سرعتِ نور را ۱۰^۱۷ داد به‌جای ۱۰^۱۴). پس
او تور می‌اندازد و o4-mini فقط روی صیدها داوری می‌کند.
──────────────────────────────────────────────────────────────────────────
python manage.py rag_verify_solutions --all
python manage.py rag_verify_solutions --all --adjudicate
python manage.py rag_verify_solutions --collection "ریاضی ششم" --limit 20
python manage.py rag_verify_solutions --report
"""
import json
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Count, Q
from django.utils import timezone
from rag.models import LessonChunk
from rag.services import clients
from rag.services.config import rag_is_configured
SCOUT_MODEL = 'deepseek-reasoner'
JUDGE_MODEL = 'o4-mini'
# دو خطای کاملاً متفاوت که هر دو ۴۲۹‌اند و نباید یکسان با آن‌ها رفتار کرد:
#
# • فشارِ لحظه‌ایِ موازی‌کاری — گذراست، عقب‌نشینی حلش می‌کند.
# • سقفِ مصرفِ حساب — ساعت‌ها باز نمی‌شود. تلاشِ دوباره نه‌تنها بی‌فایده
# است، پنجره‌ی محدودیت را هم تمدید می‌کند: یک اجرا ۱۱۹ بار دوباره تلاش
# کرد، ۶۵ دقیقه طول کشید، هیچ کاری نکرد، و مهلت را از ۱۴ ساعت جلوتر برد.
class AccountLimitReached(Exception):
"""حسابِ سرویس جواب نمی‌دهد؛ ادامه دادن بی‌معناست."""
# خودِ تشخیص در rag.services.clients است چون دستیارِ زنده هم لازمش دارد
# (/api/health باید بگوید «اعتبار تمام شده»، نه فقط «کلید تنظیم شده»).
from rag.services.clients import is_account_limit # noqa: E402,F401
# مسئله‌ای که به شکلِ کتاب ارجاع می‌دهد و محتوایش را ننوشته، بدونِ تصویر
# قابلِ حل نیست؛ فرستادنش هم پول می‌سوزاند هم آمار را خراب می‌کند.
#
# ⚠️ این محافظ باید دو شرطی بماند — ارجاعِ صریح به بیرونِ متن، *و* نیامدنِ
# توصیف بعد از آن. نسخه‌ی اولش یک regexِ تخت روی «شکل|جدول|روبه‌رو|…» بود و
# «زاویه‌های روبه‌رو در لوزی» یا «کسرهای زیر را گویا کنید: ۲/√۳» را هم می‌گرفت:
# در نمونه‌ی ۵۰تایی ۸۴٪ از ردشده‌ها واقعاً قابلِ قضاوت بودند، یعنی ~۷۸۴ مسئله‌ی
# سالم بی‌صدا از چرخه‌ی ساختِ سوال بیرون مانده بود.
_FIGURE_REFERENCE = re.compile(
r'(شکل|جدول|نمودار|تصویر|نقشه)\s*(ِ|های|‌های)?\s*'
r'(زیر|بالا|روبه\u200cرو|روبرو|مقابل|فوق|داده\s*شده)'
r'|با توجه به\s*(شکل|جدول|نمودار|تصویر)'
)
# نشانه‌ی اینکه محتوای شکل داخلِ متن آمده.
_FIGURE_DESCRIBED = re.compile(r'که\s|شامل|اعضای|مقادیر|برابر|=|\{|\d')
# کارهایی که ذاتاً تصویری‌اند و هیچ متنی نجاتشان نمی‌دهد.
_ART_TASK = re.compile(r'رنگ[‌ ]?آمیزی|رنگ کنید|رنگ\u200cآمیزی')
def needs_figure(problem: str) -> bool:
"""آیا این مسئله بدونِ دیدنِ صفحه‌ی کتاب واقعاً قابلِ قضاوت نیست؟"""
text = problem or ''
if _ART_TASK.search(text):
return True
match = _FIGURE_REFERENCE.search(text)
if not match:
return False
# ۱۶۰ نویسه بعد از ارجاع: جایی که توصیفِ شکل اگر بیاید، می‌آید.
return not _FIGURE_DESCRIBED.search(text[match.end():match.end() + 160])
PROMPT = """تو یک ریاضی‌دانِ دقیق هستی که راه‌حل‌های کتابِ درسیِ ایران را بازبینی می‌کند.
⚠️ سه قاعده‌ی مهم درباره‌ی نگارشِ این متن‌ها:
۱. علامتِ «/» بینِ دو عدد، در این کتاب‌ها تقریباً همیشه **جداکننده‌ی اعشار**
است، نه خطِ کسر. یعنی «۳/۱۰۴» عددِ ۳٫۱۰۴ است و «۲/۵» عددِ ۲٫۵.
کسرها معمولاً به شکلِ «۳ بر ۴» یا با کسرِ عمودی می‌آیند.
۲. صفر در ریاضیِ مدرسه‌ی ایران عددِ طبیعی **نیست**.
۳. متن با OCR از PDF درآمده؛ فاصله و نیم‌فاصله ممکن است به‌هم‌ریخته باشد.
تفاوتِ صرفاً نگارشی، گِردکردن، یا ترتیبِ نوشتن **خطا نیست**.
مسئله:
{problem}
راه‌حلِ کتاب:
{solution}
کارَت:
۱. اول خودت مسئله را قدم‌به‌قدم حل کن.
۲. بعد راه‌حلِ کتاب را با جوابِ خودت بسنج.
یکی از این سه را برگردان:
• "agrees" — راه‌حلِ کتاب از نظرِ ریاضی درست است (حتی اگر نگارشش فرق دارد)
• "disagrees" — خطای قطعیِ ریاضی دارد. فقط وقتی مطمئنی.
• "unjudgeable" — بدونِ شکل/جدولِ کتاب یا داده‌ی جاافتاده نمی‌شود قضاوت کرد،
یا مسئله جوابِ عددیِ مشخصی ندارد (پرسشِ باز یا توضیحی).
اگر شک داری بینِ disagrees و unjudgeable، unjudgeable را انتخاب کن.
فقط این JSON را برگردان، بدونِ هیچ متنِ اضافه:
{{"verdict": "agrees|disagrees|unjudgeable", "answer": "جوابِ خودت، کوتاه", "why": "حداکثر ۱۲۰ نویسه"}}"""
def first_json(text: str):
"""
اولین شیءِ JSON را از متن بیرون می‌کشد.
مدل‌های استدلالی گاهی قبل یا بعد از JSON توضیح می‌چسبانند و json.loads خام
روی همان می‌افتد. در محکِ اولیه همین باعث شد deepseek به‌اشتباه ۷ از ۸
به‌نظر برسد در حالی که ۸ از ۸ بود.
"""
depth = 0
start = None
for i, ch in enumerate(text or ''):
if ch == '{':
if depth == 0:
start = i
depth += 1
elif ch == '}':
depth -= 1
if depth == 0 and start is not None:
try:
return json.loads(text[start:i + 1])
except json.JSONDecodeError:
start = None
return None
class Command(BaseCommand):
help = 'بازبینیِ درستیِ راه‌حل‌های استخراج‌شده از کتاب (هزینه دارد، کم).'
def add_arguments(self, parser):
parser.add_argument('--collection', default=None)
parser.add_argument('--all', action='store_true')
parser.add_argument('--limit', type=int, default=None)
parser.add_argument('--workers', type=int, default=12)
parser.add_argument('--adjudicate', action='store_true',
help='پاسِ دوم: مشکوک‌ها را با مدلِ دقیق‌تر بسنج.')
parser.add_argument('--recheck', action='store_true')
parser.add_argument('--report', action='store_true',
help='فقط گزارشِ وضعیت؛ هیچ فراخوانی‌ای نمی‌کند.')
parser.add_argument('--sync-qdrant', action='store_true',
help='برچسب‌ها را در payloadِ Qdrant بنویس (رایگان).')
def handle(self, *args, **opts):
if opts['report']:
return self._report()
if opts['sync_qdrant']:
return self._sync_qdrant(opts)
if not rag_is_configured():
raise CommandError('RAG پیکربندی نشده (کلیدها خالی‌اند).')
if opts['adjudicate']:
return self._adjudicate(opts)
qs = LessonChunk.objects.exclude(problem='').exclude(solution='')
if opts['collection']:
qs = qs.filter(collection=opts['collection'])
elif not opts['all']:
raise CommandError('یا --collection بدهید یا --all.')
if not opts['recheck']:
qs = qs.filter(solution_checked_at__isnull=True)
rows = list(qs.order_by('collection', 'source_page', 'order'))
skipped = self._mark_figure_dependent(rows)
rows = [r for r in rows if not needs_figure(r.problem or '')]
if opts['limit']:
rows = rows[:opts['limit']]
if skipped:
self.stdout.write(
f' {skipped} مسئله‌ی وابسته به شکل/جدول بدونِ فراخوانی '
'برچسبِ unjudgeable گرفتند.'
)
if not rows:
self.stdout.write(self.style.WARNING('چیزی برای بررسی نماند.'))
return
self.stdout.write(self.style.MIGRATE_HEADING(
f'\n{len(rows)} chunk با {SCOUT_MODEL} (~${len(rows) * 0.00014:.2f})\n'))
self._run(rows, SCOUT_MODEL, opts['workers'])
def _mark_figure_dependent(self, rows) -> int:
"""قبل از خرجِ یک ریال، آن‌هایی که ذاتاً قابلِ قضاوت نیستند کنار می‌روند."""
marked = []
for chunk in rows:
if needs_figure(chunk.problem or ''):
chunk.solution_verdict = LessonChunk.VERDICT_UNJUDGEABLE
chunk.check_note = 'به شکل/جدولِ کتاب ارجاع می‌دهد؛ از متن قابلِ قضاوت نیست.'
chunk.checked_by = 'pre-filter'
chunk.solution_checked_at = timezone.now()
marked.append(chunk)
if marked:
LessonChunk.objects.bulk_update(
marked, ['solution_verdict', 'check_note', 'checked_by',
'solution_checked_at'])
return len(marked)
def _adjudicate(self, opts):
qs = LessonChunk.objects.filter(
solution_verdict=LessonChunk.VERDICT_DISAGREES).exclude(problem='')
if opts['collection']:
qs = qs.filter(collection=opts['collection'])
if not opts['recheck']:
qs = qs.exclude(checked_by=JUDGE_MODEL)
rows = list(qs.order_by('collection', 'source_page')[:opts['limit'] or 10_000])
if not rows:
self.stdout.write(self.style.WARNING('مشکوکی برای داوری نمانده.'))
return
# مدلِ داور روی این پروکسی سخت‌گیرتر نرخ‌بندی می‌شود؛ نصفِ کارگرها.
workers = max(1, min(opts['workers'], 4))
self.stdout.write(self.style.MIGRATE_HEADING(
f'\nداوریِ {len(rows)} مورد با {JUDGE_MODEL} ({workers} کارگر) '
f'(~${len(rows) * 0.0018:.2f})\n'))
self._run(rows, JUDGE_MODEL, workers)
def _run(self, rows, model, workers):
"""
فراخوانی‌ها موازی، نوشتن دسته‌ای از نخِ اصلی.
هر chunk چند ثانیه منتظرِ شبکه است، پس ترتیبی یعنی ساعت‌ها؛ ولی نوشتنِ
هم‌زمان از چند نخ روی SQLite به قفلِ نوشتن می‌خورد.
"""
client = clients.get_openai_client()
stop = threading.Event()
def ask(chunk):
# اگر یک نخ به سقفِ حساب خورده، بقیه بی‌درنگ برمی‌گردند؛ ادامه دادن
# فقط پنجره‌ی محدودیت را طولانی‌تر می‌کند.
if stop.is_set():
return chunk, None, None
delay = 4.0
last = None
for _ in range(5):
try:
completion = client.chat.completions.create(
model=model,
messages=[{'role': 'user', 'content': PROMPT.format(
problem=chunk.problem[:900], solution=chunk.solution[:900])}],
)
data = first_json(completion.choices[0].message.content)
verdict = (data or {}).get('verdict')
if verdict not in dict(LessonChunk.VERDICT_CHOICES):
raise ValueError(f'verdictِ نامعتبر: {verdict!r}')
return chunk, data, None
except Exception as exc: # noqa: BLE001
last = exc
text = str(exc).lower()
if is_account_limit(exc):
stop.set()
return chunk, None, AccountLimitReached(str(exc)[:200])
if '429' not in text and 'rate' not in text:
break
if stop.is_set():
break
time.sleep(delay)
delay *= 2
return chunk, None, last
counts = {v: 0 for v, _ in LessonChunk.VERDICT_CHOICES}
failed = 0
aborted: AccountLimitReached | None = None
batch: list[LessonChunk] = []
t0 = time.time()
done = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
for chunk, data, err in pool.map(ask, rows):
done += 1
if isinstance(err, AccountLimitReached):
aborted = err
continue
if err is None and data is None:
continue # بعد از توقف، نوبتش نرسید
if err is not None:
failed += 1
self.stdout.write(self.style.ERROR(f' ✗ #{chunk.pk}: {str(err)[:75]}'))
else:
chunk.solution_verdict = data['verdict']
chunk.checked_answer = str(data.get('answer') or '')[:2000]
chunk.check_note = str(data.get('why') or '')[:300]
chunk.checked_by = model
chunk.solution_checked_at = timezone.now()
batch.append(chunk)
counts[data['verdict']] += 1
if data['verdict'] == LessonChunk.VERDICT_DISAGREES:
self.stdout.write(
f' ⚠ {chunk.collection} ص{chunk.source_page}: '
f'{chunk.problem[:50]} → {chunk.checked_answer[:40]}')
if len(batch) >= 40:
self._flush(batch)
if done % 100 == 0:
rate = done / max(time.time() - t0, 1)
self.stdout.write(
f' … {done}/{len(rows)} (~{(len(rows)-done)/max(rate,.001)/60:.0f} دقیقه مانده)')
self._flush(batch)
self.stdout.write(self.style.SUCCESS(
f"\n{counts['agrees']} درست، {counts['disagrees']} مشکوک، "
f"{counts['unjudgeable']} غیرقابلِ قضاوت، {failed} خطا — {time.time()-t0:.0f}s"))
if aborted is not None:
# آنچه تا اینجا سنجیده شده ذخیره شده و اجرای بعدی از همین‌جا ادامه
# می‌دهد، چون فقط بررسی‌نشده‌ها برداشته می‌شوند.
raise CommandError(
'سقفِ مصرفِ حسابِ سرویس پر شده و اجرا همین‌جا متوقف شد.\n'
f' پیامِ سرویس: {aborted}\n'
' تا باز شدنِ سقف دوباره تلاش نکنید؛ هر تلاش مهلت را جلوتر می‌برد.\n'
' وضعیت با: python manage.py rag_verify_solutions --report'
)
@staticmethod
def _flush(batch):
if batch:
LessonChunk.objects.bulk_update(
batch, ['solution_verdict', 'checked_answer', 'check_note',
'checked_by', 'solution_checked_at'])
batch.clear()
def _sync_qdrant(self, opts):
"""
برچسب‌ها را روی نقاطِ موجود می‌نویسد، بدونِ بردار و بدونِ هزینه.
نقطه‌ها بر اساسِ مقدارِ برچسب دسته می‌شوند، پس برای هر collection سه
فراخوانی کافی است نه هزاران‌تا.
"""
from rag.services.qdrant_store import set_payload_fields
qs = LessonChunk.objects.exclude(solution_verdict='')
if opts['collection']:
qs = qs.filter(collection=opts['collection'])
buckets: dict[tuple[str, str], list[str]] = {}
for collection, verdict, point_id in qs.values_list(
'collection', 'solution_verdict', 'qdrant_point_id'):
buckets.setdefault((collection, verdict), []).append(str(point_id))
if not buckets:
self.stdout.write(self.style.WARNING('برچسبی برای همگام‌سازی نیست.'))
return
total = 0
for (collection, verdict), ids in sorted(buckets.items()):
set_payload_fields(collection, ids, {'solution_verdict': verdict})
total += len(ids)
self.stdout.write(f' {collection:14s} {verdict:12s} {len(ids):4d} نقطه')
self.stdout.write(self.style.SUCCESS(f'\n{total} نقطه در Qdrant به‌روز شد.'))
def _report(self):
rows = (LessonChunk.objects.exclude(problem='').exclude(solution='')
.values('collection')
.annotate(n=Count('id'),
ok=Count('id', filter=Q(solution_verdict='agrees')),
bad=Count('id', filter=Q(solution_verdict='disagrees')),
unk=Count('id', filter=Q(solution_verdict='unjudgeable')),
todo=Count('id', filter=Q(solution_checked_at__isnull=True)))
.order_by('collection'))
self.stdout.write(f"\n{'کتاب':14s} {'کل':>5s} {'درست':>6s} {'مشکوک':>7s} "
f"{'بی‌قضاوت':>9s} {'مانده':>6s}")
for r in rows:
self.stdout.write(f"{r['collection']:14s} {r['n']:5d} {r['ok']:6d} "
f"{r['bad']:7d} {r['unk']:9d} {r['todo']:6d}")