label every textbook solution, and build quizzes at the level the app plays

سه کارِ به‌هم‌پیوسته که از یک کشف شروع شدند: راه‌حل‌هایی که vision از کتاب
درآورده گاهی غلط‌اند، و این خطا در بازیابی دیده نمی‌شود — فقط وقتی کسی به
`solution` اعتماد کند خودش را نشان می‌دهد.

۱) بازبینیِ راه‌حل‌ها (rag_verify_solutions)

هر chunk دوباره حل می‌شود و با کتاب مقایسه. نتیجه سه‌حالته است، نه بله/خیر:
اجرای اول با بولی ۱۹٪ خطا گزارش کرد؛ خواندنِ دستیِ نمونه‌ها نشان داد ۳۵٪ از
آن‌ها مسئله‌هایی بودند که به شکلِ کتاب ارجاع می‌دادند و اصلاً قابلِ قضاوت
نبودند، و ۲۴٪ موردی که «۳/۱۰۴» را کسرِ ۳÷۱۰۴ خوانده بود نه عددِ ۳٫۱۰۴. با دو
حالت، «نمی‌دانم» ناچار به «غلط است» ترجمه می‌شد.

با سه حالت و پرامپتی که این دو تله را می‌شناسد: ۲٬۱۱۹ chunk بررسی شد،
۱٬۱۱۲ درست، ۱۲۳ غلط (۵٫۸٪)، ۸۸۱ غیرقابلِ قضاوت. آن ۸۸۱ رایگان برچسب خوردند
چون پیش‌فیلتر قبل از هر فراخوانی کنارشان گذاشت.

`solution` هرگز بازنویسی نمی‌شود — کتاب باید کتاب بماند. برچسب در payloadِ
Qdrant هم می‌نشیند، ولی با نامی که `retrieval._payload_text` نمی‌خواند: یک
نویسه هم به متنی که به مدل می‌رسد اضافه نشد.

نمونه‌ی خطاهای واقعیِ کتاب که پیدا شد:
    BMI = ۱۰۰/(۲٫۷۴)²          کتاب: ۳۳٫۲      درست: ۱۳٫۳
    ۱۶ آبی + ۲۴ صورتی          کتاب: «کل ۵۰»   درست: ۴۰
    بزرگ‌ترین عددِ ۴رقمیِ بی‌تکرار کتاب: ۸۵۳۲     درست: ۹۸۷۶
    g(x) = x³ − ۲              کتاب: «نمایی»   درست: چندجمله‌ای

۲) سوال‌ها فقط از راه‌حل‌های تأییدشده

۱٬۱۹۹ سوال در content/seed/questions.json، همه از chunkهایی که برچسبِ
«درست» گرفته‌اند. هر شش پایه برنامه‌ی درسی دارند (۱٬۹۲۸ درس) — هشتم و دهم
تازه ساخته شدند.

۳) GET /api/nodes/{id}/quiz

واحدِ بازی در اپ «مبحث» است ولی سوال‌ها به «درس» بسته‌اند، و درس اینجا
ریزترین دانه است: یک نوعِ مسئله، نه یک جلسه. نتیجه‌اش این بود که اپ برای یک
آزمونِ چهارسوالی باید ده‌ها درخواست می‌زد. این اندپوینت از همه‌ی درس‌های مبحث
نمونه برمی‌دارد، حداکثر یکی از هر درس تا آزمون روی یک مهارت گیر نکند.

درهم‌ریزی با بذرِ nodeId:userId پایدار است: رفرشِ صفحه همان آزمون را می‌دهد،
نه مجموعه‌ی تازه‌ای که پیشرفت را بی‌معنا کند. `questionCount` جدا از
`requested` می‌آید تا مبحثِ کم‌سوال وانمود نکند کامل است.

──────────
اصلاحاتِ جانبی که سرِ راه لازم شدند:

• QDRANT_TIMEOUT_SECONDS: کلاینت بدونِ timeout ساخته می‌شد و پیش‌فرضِ ۵
  ثانیه‌ایِ کتابخانه ingestِ کتابِ هشتم را روی اولین فراخوانی کشت.
• پاک‌سازیِ نقاطِ نیمه‌نوشته: upsert با wait=True ممکن است نوشته باشد و فقط
  جوابش تایم‌اوت شده باشد؛ مسیرِ جبران فقط ردیف‌های DB را پس می‌گرفت و هر
  retry یک نسخه‌ی تکراری در Qdrant جا می‌گذاشت (۸ مورد در پایه نهم، پاک شد).
• تشخیصِ سقفِ حساب: «insufficient user quota» با فاصله می‌آید نه زیرخط، و
  محافظِ اول نمی‌گرفتش — یک اجرا ۷۷۰ فراخوانیِ محکوم‌به‌شکست زد.
• پرامپتِ vision حالا می‌خواهد محتوای شکل داخلِ متنِ مسئله نوشته شود. ۴۲٪ از
  محتوای موجود به شکلی ارجاع می‌دهد که در متن نیست؛ مدل آن را دیده بود ولی
  از او خواسته نشده بود بنویسدش. کتاب‌های موجود قبل از این قاعده وارد شده‌اند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 02:11:15 +03:30
parent 92dc2242f8
commit b26aca0f63
17 changed files with 18319 additions and 41 deletions

View File

@@ -6,6 +6,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@@ -911,3 +912,96 @@ class IngestUpsertRollbackTests(TestCase):
)
self.assertEqual(LessonChunk.objects.filter(collection='ریاضی نهم').count(), 0)
class VerifySolutionsLimitTests(TestCase):
"""
دو خطای ۴۲۹ هست و نباید یکسان با آن‌ها رفتار کرد.
فشارِ لحظه‌ایِ موازی‌کاری گذراست و عقب‌نشینی حلش می‌کند؛ ولی سقفِ مصرفِ
حساب ساعت‌ها باز نمی‌شود. یک اجرا ۱۱۹ بار دوباره تلاش کرد، ۶۵ دقیقه طول
کشید، هیچ chunkی سنجیده نشد، و مهلتِ باز شدن را هم جلوتر برد.
"""
def setUp(self):
self.chunk = LessonChunk.objects.create(
subject='ریاضی', lesson_number=1, order=1, title='توان',
original_text='x', summary='x', collection='ریاضی نهم',
problem='حاصل ۲^۳ چند است؟', solution='۸',
)
@override_settings(**RAG_ON)
def test_account_limit_stops_at_once_instead_of_retrying(self):
from django.core.management.base import CommandError
error = Exception("Error code: 429 - {'code': 'initial_usage_limit_exceeded'}")
client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(
create=lambda **kw: (_ for _ in ()).throw(error))))
with patch('rag.services.clients.get_openai_client', return_value=client), \
patch('rag.management.commands.rag_verify_solutions.time.sleep') as slept:
with self.assertRaises(CommandError) as caught:
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
self.assertIn('سقفِ مصرف', str(caught.exception))
slept.assert_not_called() # حتی یک بار هم نباید صبر کرده باشد
@override_settings(**RAG_ON)
def test_an_exhausted_quota_also_stops_at_once(self):
"""
۴۰۳ با متنِ «insufficient user quota» — با فاصله، نه زیرخط.
محافظِ اول دنبالِ `insufficient_quota` می‌گشت و این را نمی‌گرفت؛ یک
اجرا ۷۷۰ فراخوانیِ محکوم‌به‌شکست زد به‌جای اینکه سرِ اولی بایستد.
"""
from django.core.management.base import CommandError
error = Exception("Error code: 403 - {'message': 'insufficient user quota, "
"remaining quota: $-0.1'}")
client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(
create=lambda **kw: (_ for _ in ()).throw(error))))
with patch('rag.services.clients.get_openai_client', return_value=client), \
patch('rag.management.commands.rag_verify_solutions.time.sleep') as slept:
with self.assertRaises(CommandError):
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
slept.assert_not_called()
@override_settings(**RAG_ON)
def test_a_transient_429_is_still_retried(self):
calls = {'n': 0}
def flaky(**kwargs):
calls['n'] += 1
if calls['n'] == 1:
raise Exception('Error code: 429 - rate limit, slow down')
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(
content='{"verdict": "agrees", "answer": "۸", "why": ""}'))])
client = SimpleNamespace(chat=SimpleNamespace(
completions=SimpleNamespace(create=flaky)))
with patch('rag.services.clients.get_openai_client', return_value=client), \
patch('rag.management.commands.rag_verify_solutions.time.sleep'):
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
self.chunk.refresh_from_db()
self.assertEqual(self.chunk.solution_verdict, LessonChunk.VERDICT_AGREES)
self.assertEqual(calls['n'], 2, 'باید دقیقاً یک بار دوباره تلاش می‌کرد.')
@override_settings(**RAG_ON)
def test_figure_dependent_problems_are_labelled_without_any_call(self):
"""هزینه‌ی صفر برای چیزی که جوابش از متن درنمی‌آید."""
self.chunk.problem = 'مساحت شکلِ زیر را حساب کنید.'
self.chunk.save(update_fields=['problem'])
def boom(**kwargs):
raise AssertionError('نباید هیچ فراخوانی‌ای می‌شد.')
client = SimpleNamespace(chat=SimpleNamespace(
completions=SimpleNamespace(create=boom)))
with patch('rag.services.clients.get_openai_client', return_value=client):
call_command('rag_verify_solutions', '--collection', 'ریاضی نهم')
self.chunk.refresh_from_db()
self.assertEqual(self.chunk.solution_verdict, LessonChunk.VERDICT_UNJUDGEABLE)
self.assertEqual(self.chunk.checked_by, 'pre-filter')