سه کارِ بههمپیوسته که از یک کشف شروع شدند: راهحلهایی که 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>
304 lines
15 KiB
Python
304 lines
15 KiB
Python
"""
|
||
تستِ سرتاسریِ «بیسیکِ کامل» — همان چیزی که سینیور خواست کامل و اوکی باشد.
|
||
|
||
یک کاربر از صفر تا آخر میرود: ورود → انتخاب پایه و اسم → نقشهی مهارت →
|
||
درس → سوال → پاسخ → اتمام درس → پرسش از دستیار. هرجا زنجیره بشکند، اپ همانجا
|
||
گیر میکند؛ این تست دقیقاً همان زنجیره را میبندد.
|
||
|
||
عمداً از APIهای واقعی میرود (نه از ORM)، چون شکافی که ماه پیش پیدا شد
|
||
(«/api/home بدونِ ?grade= خالی برمیگشت») فقط از همین زاویه دیده میشد.
|
||
"""
|
||
from django.core.cache import cache
|
||
from django.test import TestCase
|
||
from rest_framework.test import APIClient
|
||
|
||
from accounts.models import User
|
||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||
|
||
|
||
class BasicLearningFlowTests(TestCase):
|
||
def setUp(self):
|
||
cache.clear()
|
||
self.client = APIClient()
|
||
|
||
self.grade = Grade.objects.create(title='پایه نهم', icon='grade-9', color='#FF9800')
|
||
unit = Unit.objects.create(grade=self.grade, title='فصل ۴: توان و ریشه', order=4)
|
||
node = Node.objects.create(unit=unit, title='توان صحیح', order=1)
|
||
self.lesson = Lesson.objects.create(
|
||
node=node, title='محاسبه توان منفی',
|
||
intro_text='۱. معکوسِ پایه را بگیر. ۲. توان را مثبت کن.',
|
||
mascot_animation='explain',
|
||
)
|
||
question = Question.objects.create(
|
||
lesson=self.lesson, type='multiple_choice', prompt='۲ به توان منفی ۳ چند است؟',
|
||
)
|
||
self.correct = Choice.objects.create(question=question, value='۱/۸', is_correct=True)
|
||
Choice.objects.create(question=question, value='-۸', is_correct=False)
|
||
Choice.objects.create(question=question, value='۸', is_correct=False)
|
||
Choice.objects.create(question=question, value='-۱/۸', is_correct=False)
|
||
self.question = question
|
||
|
||
def tearDown(self):
|
||
cache.clear()
|
||
|
||
def _auth(self, token):
|
||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||
|
||
def test_a_new_user_can_go_from_signup_to_finishing_a_lesson(self):
|
||
# ۱. ورودِ مهمان — سریعترین مسیرِ ورود که اپ میتواند بگیرد.
|
||
response = self.client.post(
|
||
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-1'}, format='json',
|
||
)
|
||
self.assertEqual(response.status_code, 200, response.data)
|
||
self._auth(response.data['token'])
|
||
|
||
# ۲. صفحهی «بیا باهم آشنا بشیم»: اسم + پایه.
|
||
response = self.client.patch('/api/user/profile', {'name': 'سارا'}, format='json')
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(response.data['name'], 'سارا')
|
||
|
||
grades = self.client.get('/api/grades').data['grades']
|
||
self.assertIn('پایه نهم', [g['title'] for g in grades])
|
||
response = self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
|
||
self.assertEqual(response.status_code, 200)
|
||
|
||
# ۳. صفحهی خانه — بدونِ پارامتر، از پایهی خودِ کاربر.
|
||
home = self.client.get('/api/home')
|
||
self.assertEqual(home.status_code, 200)
|
||
self.assertEqual(len(home.data['units']), 1)
|
||
node = home.data['units'][0]['nodes'][0]
|
||
self.assertEqual(node['status'], 'unlocked')
|
||
self.assertIn(self.lesson.id, node['lessonIds'])
|
||
|
||
# ۴. صفحهی درس.
|
||
intro = self.client.get(f'/api/lessons/{self.lesson.id}/intro')
|
||
self.assertEqual(intro.status_code, 200)
|
||
self.assertTrue(intro.data['introText'])
|
||
self.assertTrue(intro.data['mascotAnimation'])
|
||
|
||
questions = self.client.get(f'/api/lessons/{self.lesson.id}/questions').data['questions']
|
||
self.assertEqual(len(questions), 1)
|
||
self.assertEqual(len(questions[0]['options']), 4)
|
||
|
||
# ۵. پاسخِ درست.
|
||
answer = self.client.post(
|
||
f'/api/questions/{self.question.id}/answer',
|
||
{'selectedOptionId': self.correct.id}, format='json',
|
||
)
|
||
self.assertEqual(answer.status_code, 200, answer.data)
|
||
self.assertTrue(answer.data['correct'])
|
||
self.assertEqual(str(answer.data['correctOptionId']), str(self.correct.id))
|
||
self.assertGreater(answer.data['xpEarned'], 0)
|
||
self.assertEqual(answer.data['mascotMood'], 'happy')
|
||
|
||
# ۶. اتمامِ درس. correctCount/totalQuestions را کلاینت میفرستد، ولی
|
||
# سرور آن را با تعدادِ واقعیِ سوالهای درس سقف میزند.
|
||
complete = self.client.post(
|
||
f'/api/lessons/{self.lesson.id}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1}, format='json',
|
||
)
|
||
self.assertEqual(complete.status_code, 200, complete.data)
|
||
self.assertGreater(complete.data['stars'], 0)
|
||
|
||
# ۷. آمار باید پیشرفت را نشان دهد و گره کامل شده باشد.
|
||
summary = self.client.get('/api/user/summary')
|
||
self.assertGreater(summary.data['xp'], 0)
|
||
self.assertEqual(summary.data['name'], 'سارا')
|
||
|
||
home = self.client.get('/api/home')
|
||
self.assertEqual(home.data['units'][0]['nodes'][0]['status'], 'completed')
|
||
self.assertGreater(home.data['stats']['progressPercent'], 0)
|
||
|
||
def test_completing_twice_with_the_same_key_does_not_double_count(self):
|
||
from content.models import UserLessonCompletion
|
||
|
||
response = self.client.post(
|
||
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-2'}, format='json',
|
||
)
|
||
self._auth(response.data['token'])
|
||
self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
|
||
self.client.post(
|
||
f'/api/questions/{self.question.id}/answer',
|
||
{'selectedOptionId': self.correct.id}, format='json',
|
||
)
|
||
|
||
body = {'correctCount': 1, 'totalQuestions': 1}
|
||
first = self.client.post(
|
||
f'/api/lessons/{self.lesson.id}/complete', body, format='json',
|
||
HTTP_IDEMPOTENCY_KEY='same-key',
|
||
)
|
||
second = self.client.post(
|
||
f'/api/lessons/{self.lesson.id}/complete', body, format='json',
|
||
HTTP_IDEMPOTENCY_KEY='same-key',
|
||
)
|
||
self.assertEqual(first.status_code, 200)
|
||
self.assertEqual(second.status_code, 200)
|
||
|
||
# قطعیِ شبکه + دکمهی دوباره نباید دوبار امتیاز بدهد.
|
||
self.assertEqual(
|
||
UserLessonCompletion.objects.filter(lesson=self.lesson).count(), 1,
|
||
)
|
||
summary = self.client.get('/api/user/summary')
|
||
self.assertEqual(summary.data['xp'], first.data['xpEarned'])
|
||
|
||
def test_answer_xp_lands_in_the_total_only_after_the_lesson_is_completed(self):
|
||
"""
|
||
قراردادی که فرانت باید بداند: `xpEarned` در پاسخِ هر سوال، ارزشِ همان
|
||
سوال است و بلافاصله در مجموعِ کاربر نمینشیند؛ مجموع موقعِ اتمامِ درس
|
||
بهروز میشود. بدونِ دانستنِ این، توسعهدهندهی فرانت دنبالِ باگی
|
||
میگردد که وجود ندارد.
|
||
"""
|
||
response = self.client.post(
|
||
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-4'}, format='json',
|
||
)
|
||
self._auth(response.data['token'])
|
||
self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
|
||
|
||
answer = self.client.post(
|
||
f'/api/questions/{self.question.id}/answer',
|
||
{'selectedOptionId': self.correct.id}, format='json',
|
||
)
|
||
self.assertGreater(answer.data['xpEarned'], 0)
|
||
self.assertEqual(self.client.get('/api/user/summary').data['xp'], 0)
|
||
|
||
self.client.post(
|
||
f'/api/lessons/{self.lesson.id}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1}, format='json',
|
||
)
|
||
self.assertGreater(self.client.get('/api/user/summary').data['xp'], 0)
|
||
|
||
def test_the_map_is_empty_but_healthy_before_a_grade_is_chosen(self):
|
||
response = self.client.post(
|
||
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-3'}, format='json',
|
||
)
|
||
self._auth(response.data['token'])
|
||
|
||
home = self.client.get('/api/home')
|
||
self.assertEqual(home.status_code, 200)
|
||
self.assertEqual(home.data['units'], [])
|
||
|
||
|
||
class LessonWithoutQuestionsTests(TestCase):
|
||
"""
|
||
درسی که هنوز سوال ندارد نباید بنبستِ خاموش باشد.
|
||
|
||
الان همهی درسهای واردشده از کتاب در همین حالتاند (ساختِ سوال به اعتبارِ
|
||
API نیاز دارد)، پس فرانت باید بتواند قبل از باز کردنِ کویز بفهمد.
|
||
"""
|
||
|
||
def setUp(self):
|
||
cache.clear()
|
||
self.client = APIClient()
|
||
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
|
||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
|
||
self.empty = Lesson.objects.create(node=node, title='بدونِ سوال', intro_text='متنِ درس')
|
||
self.filled = Lesson.objects.create(node=node, title='با سوال', intro_text='متنِ درس')
|
||
question = Question.objects.create(
|
||
lesson=self.filled, type='multiple_choice', prompt='؟',
|
||
)
|
||
Choice.objects.create(question=question, value='الف', is_correct=True)
|
||
|
||
user = User.objects.create(device_id='empty-lesson', is_guest=True, grade_id=grade.id)
|
||
from accounts.authentication import issue_access_token
|
||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||
|
||
def tearDown(self):
|
||
cache.clear()
|
||
|
||
def test_intro_reports_the_question_count(self):
|
||
self.assertEqual(
|
||
self.client.get(f'/api/lessons/{self.empty.id}/intro').data['questionCount'], 0,
|
||
)
|
||
self.assertEqual(
|
||
self.client.get(f'/api/lessons/{self.filled.id}/intro').data['questionCount'], 1,
|
||
)
|
||
|
||
def test_intro_still_serves_the_teaching_text(self):
|
||
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)
|