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

@@ -218,3 +218,86 @@ class LessonWithoutQuestionsTests(TestCase):
response = self.client.get(f'/api/lessons/{self.empty.id}/intro')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['introText'], 'متنِ درس')
class NodeQuizTests(TestCase):
"""
آزمونِ مبحث: واحدِ بازی در اپ «مبحث» است نه «درس».
درس در این پروژه ریزترین دانه است (یک نوعِ مسئله از کتاب) و معمولاً یکی-دو
سوال دارد؛ اپ برای هر جزیره چهار سوال می‌خواهد. بدونِ این اندپوینت، فرانت
باید درس‌به‌درس می‌گشت و خودش سرِ هم می‌کرد.
"""
def setUp(self):
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل ۴', order=1)
self.node = Node.objects.create(unit=unit, title='توان', order=1)
for i in range(6):
lesson = Lesson.objects.create(node=self.node, title=f'درس {i}')
question = Question.objects.create(
lesson=lesson, type='multiple_choice', prompt=f'سوال {i}')
Choice.objects.create(question=question, value='الف', is_correct=True)
Choice.objects.create(question=question, value='ب', is_correct=False)
def test_returns_four_questions_by_default(self):
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
self.assertEqual(body['questionCount'], 4)
self.assertEqual(len(body['questions']), 4)
self.assertEqual(body['nodeTitle'], 'توان')
def test_each_question_says_which_lesson_it_came_from(self):
"""برای اینکه بعدِ جوابِ غلط بشود گفت کدام مهارت را مرور کند."""
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
for question in body['questions']:
self.assertTrue(question['lessonId'])
self.assertTrue(question['lessonTitle'])
def test_it_spreads_across_lessons_instead_of_drilling_one(self):
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json()
lesson_ids = [q['lessonId'] for q in body['questions']]
self.assertEqual(len(set(lesson_ids)), len(lesson_ids))
def test_the_same_seed_gives_the_same_quiz(self):
"""رفرشِ صفحه نباید آزمونِ تازه‌ای بدهد و پیشرفت را بی‌معنا کند."""
first = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json()
again = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json()
self.assertEqual([q['id'] for q in first['questions']],
[q['id'] for q in again['questions']])
def test_a_different_seed_gives_a_different_order(self):
a = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=one').json()
b = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=two').json()
self.assertNotEqual([q['id'] for q in a['questions']],
[q['id'] for q in b['questions']])
def test_a_thin_node_reports_what_it_actually_has(self):
"""
مبحثی که هنوز چهار سوال ندارد نباید وانمود کند دارد — فرانت باید
بتواند تفاوتِ «آزمونِ کوتاه» و «خطا» را بفهمد.
"""
thin_unit = Unit.objects.create(
grade=self.node.unit.grade, title='فصل ۵', order=2)
thin = Node.objects.create(unit=thin_unit, title='کم‌سوال', order=1)
lesson = Lesson.objects.create(node=thin, title='تنها درس')
question = Question.objects.create(
lesson=lesson, type='multiple_choice', prompt='تنها سوال')
Choice.objects.create(question=question, value='الف', is_correct=True)
body = self.client.get(f'/api/nodes/{thin.pk}/quiz').json()
self.assertEqual(body['questionCount'], 1)
self.assertEqual(body['requested'], 4)
def test_size_is_capped_so_one_request_cannot_pull_everything(self):
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz?size=999').json()
self.assertLessEqual(body['questionCount'], 20)
def test_unknown_node_is_a_clean_404(self):
self.assertEqual(self.client.get('/api/nodes/999999/quiz').status_code, 404)