cover the new paths, especially the ones that fail silently

خطاهای این پروژه خاموش‌اند: نامِ اشتباهِ collection یا فیلترِ نخورده نتیجه‌ی
خالی می‌دهد نه استثنا، و دستیار فقط بدتر جواب می‌دهد. این تست‌ها نگهبانِ
همان‌جاهایند.

TextbookLayoutTests بررسی می‌کند رشته‌ی هر فصل بینِ ingest و retrieval یکی
باشد و بازه‌ی صفحه‌ها پیوسته بماند. IngestBookResumeTests مطمئن می‌شود
صفحه‌ی بی‌مسئله دوبار پول نمی‌گیرد، صفحه‌ی خطادار در اجرای بعد دوباره
سراغش می‌روند، و upsertِ شکست‌خورده rollback می‌شود.
LessonContextResolutionTests نگهبانِ نرمال‌سازیِ «پایه نهم» → «ریاضی نهم»
است و اینکه مسیرِ gradeId و مسیرِ lessonId به یک collection برسند.
GradeAwarePromptTests جلوی برگشتنِ لحنِ دبستانی برای پایه‌های بالا را
می‌گیرد. FollowUpStaysGroundedTests پیامِ دومِ یک گفتگو را هم مستند نگه
می‌دارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 19:01:24 +03:30
parent ac1b1af398
commit 5f3aae0756
4 changed files with 859 additions and 0 deletions

View File

@@ -1493,3 +1493,70 @@ def _orig_loader():
except _U.DoesNotExist: except _U.DoesNotExist:
raise AuthenticationFailed('کاربر پیدا نشد.') raise AuthenticationFailed('کاربر پیدا نشد.')
return _load return _load
class UpdateUserProfileTests(TestCase):
"""
تست‌های PATCH /api/user/profile.
صفحه‌ی «بیا باهم آشنا بشیم» در اپ اسم می‌گیرد و تا پیش از این هیچ مسیری
برای نوشتنش نبود — پروفایل فقط خواندنی بود.
"""
def setUp(self):
self.client = APIClient()
cache.clear()
self.user = User.objects.create(device_id='device-patch-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
def tearDown(self):
cache.clear()
def _patch(self, body, token=None):
kwargs = {'format': 'json'}
token = self.token if token is None else token
if token:
kwargs['HTTP_AUTHORIZATION'] = f'Bearer {token}'
return self.client.patch('/api/user/profile', body, **kwargs)
def test_requires_authentication(self):
self.assertEqual(self._patch({'name': 'سارا'}, token='').status_code, 401)
def test_sets_name_and_returns_the_updated_profile(self):
response = self._patch({'name': 'سارا'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['name'], 'سارا')
self.user.refresh_from_db()
self.assertEqual(self.user.name, 'سارا')
def test_partial_update_leaves_other_fields_alone(self):
self._patch({'name': 'علی', 'bio': 'عاشقِ ریاضی'})
response = self._patch({'bio': 'کتاب‌خوان'})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['name'], 'علی')
self.assertEqual(response.data['bio'], 'کتاب‌خوان')
def test_creates_the_profile_row_lazily(self):
self.assertFalse(Profile.objects.filter(user_id=self.user.id).exists())
response = self._patch({'birthYear': 1391})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['birthYear'], 1391)
self.assertTrue(Profile.objects.filter(user_id=self.user.id).exists())
def test_empty_body_is_rejected(self):
self.assertEqual(self._patch({}).status_code, 400)
def test_phone_and_guest_flag_cannot_be_changed(self):
response = self._patch({'name': 'رضا', 'phone': '09120000000', 'isGuest': False})
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertIsNone(self.user.phone)
self.assertTrue(self.user.is_guest)
def test_out_of_range_birth_year_is_rejected(self):
self.assertEqual(self._patch({'birthYear': 2026}).status_code, 400)

View File

@@ -712,3 +712,70 @@ class ReadThrottleTests(TestCase):
f'/api/home?grade={self.grade.id}', f'/api/home?grade={self.grade.id}',
HTTP_AUTHORIZATION=f'Bearer {self.token}').status_code) HTTP_AUTHORIZATION=f'Bearer {self.token}').status_code)
self.assertIn(429, codes) self.assertIn(429, codes)
class HomeGradeFallbackTests(TestCase):
"""
/api/home باید در نبودِ ?grade= از پایه‌ی ذخیره‌شده‌ی خودِ کاربر استفاده کند.
پیش‌تر این‌طور نبود و نتیجه‌اش در اپ این می‌شد: کاربر در صفحه‌ی آشنایی پایه
را انتخاب می‌کرد و بعد صفحه‌ی خانه خالی می‌آمد.
"""
def setUp(self):
_django_cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='dev-home-fallback', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
self.grade = Grade.objects.create(title='پایه نهم', icon='grade-9', color='#FF9800')
unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
Lesson.objects.create(node=node, title='توان صحیح', intro_text='.')
def tearDown(self):
_django_cache.clear()
def _home(self, query=''):
return self.client.get(f'/api/home{query}', HTTP_AUTHORIZATION=f'Bearer {self.token}')
def test_uses_the_saved_grade_when_no_param_is_given(self):
self.user.grade_id = self.grade.id
self.user.save(update_fields=['grade_id'])
response = self._home()
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data['units']), 1)
self.assertEqual(response.data['units'][0]['title'], 'فصل ۱')
def test_explicit_param_wins_over_the_saved_grade(self):
other = Grade.objects.create(title='پایه دهم', icon='grade-10', color='#E91E63')
unit = Unit.objects.create(grade=other, title='فصل مثلثات', order=1)
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
Lesson.objects.create(node=node, title='سینوس', intro_text='.')
self.user.grade_id = self.grade.id
self.user.save(update_fields=['grade_id'])
response = self._home(f'?grade={other.id}')
self.assertEqual(response.data['units'][0]['title'], 'فصل مثلثات')
def test_user_without_a_grade_gets_an_empty_map_not_an_error(self):
response = self._home()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['units'], [])
def test_unknown_explicit_grade_is_still_404(self):
self.assertEqual(self._home('?grade=999999').status_code, 404)
def test_a_deleted_saved_grade_degrades_to_an_empty_map(self):
# پایه‌ای که کاربر ذخیره کرده حذف شده — این خطای کاربر نیست.
self.user.grade_id = 999999
self.user.save(update_fields=['grade_id'])
response = self._home()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['units'], [])

220
content/tests_flow.py Normal file
View File

@@ -0,0 +1,220 @@
"""
تستِ سرتاسریِ «بیسیکِ کامل» — همان چیزی که سینیور خواست کامل و اوکی باشد.
یک کاربر از صفر تا آخر می‌رود: ورود → انتخاب پایه و اسم → نقشه‌ی مهارت →
درس → سوال → پاسخ → اتمام درس → پرسش از دستیار. هرجا زنجیره بشکند، اپ همان‌جا
گیر می‌کند؛ این تست دقیقاً همان زنجیره را می‌بندد.
عمداً از 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'], 'متنِ درس')

View File

@@ -355,3 +355,508 @@ class AskAIAnswerCacheTests(TestCase):
# ولی پیام و کوتا برای هر دو درخواست ثبت/مصرف شده # ولی پیام و کوتا برای هر دو درخواست ثبت/مصرف شده
self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 2) self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 2)
self.assertGreater(r1.data['remainingQuota'], r2.data['remainingQuota']) self.assertGreater(r1.data['remainingQuota'], r2.data['remainingQuota'])
class MathPayloadShapeTests(TestCase):
"""
نقاطِ ریاضیِ ما باید هم‌شکلِ نقاطِ موجودِ تیمِ دیگر باشند. مهم‌ترینش نبودِ
`summary` است: با آن، retrieval به‌جای راه‌حل، متنِ صافِ «Type: …» را
به‌عنوان context به مدل می‌دهد و کیفیت بی‌سروصدا افت می‌کند.
"""
def _math_chunk(self, **over):
data = dict(
lesson_id=None, subject='ریاضی', lesson_number=4, chapter=4,
chapter_title='فصل ۴: توان و ریشه', grade_title='نهم', order=1,
title='فصل ۴: توان و ریشه', original_text='Type: توان صحیح',
summary='Type: توان صحیح', collection='ریاضی نهم', source_page=67,
problem_type='توان صحیح', keywords=['توان'],
general_solution='پایه را در خودش ضرب کن.',
problem='۲ به توان ۳ چند است؟', solution='۸',
)
data.update(over)
return LessonChunk(**data)
def test_math_payload_has_no_summary_or_lesson_title(self):
from rag.services.ingest import _payload
payload = _payload(self._math_chunk())
self.assertNotIn('summary', payload)
self.assertNotIn('lesson_title', payload)
self.assertEqual(payload['chapter'], 'فصل ۴: توان و ریشه')
self.assertEqual(payload['grade'], 'نهم')
self.assertEqual(payload['source_page'], 67)
def test_retrieval_reads_solution_not_the_flattened_text(self):
from rag.services.ingest import _payload
from rag.services.retrieval import _payload_text
text = _payload_text(_payload(self._math_chunk()))
self.assertIn('پایه را در خودش ضرب کن.', text)
self.assertNotIn('Type:', text)
def test_text_subject_still_gets_summary(self):
from rag.services.ingest import _payload
payload = _payload(self._math_chunk(
problem_type='', keywords=[], general_solution='', problem='', solution='',
subject='علوم تجربی', summary='خلاصه‌ی درس', title='زنگ علوم',
))
self.assertEqual(payload['summary'], 'خلاصه‌ی درس')
self.assertEqual(payload['lesson_title'], 'زنگ علوم')
class TextbookLayoutTests(TestCase):
def test_every_chapter_title_is_registered_for_retrieval(self):
"""عنوانی که ingest می‌نویسد باید همانی باشد که retrieval می‌سازد."""
from rag.services.known_collections import resolve_math_chapter
from rag.services.textbooks import TEXTBOOKS
for book in TEXTBOOKS.values():
for chapter in book.chapters:
self.assertEqual(
resolve_math_chapter(book.subject, book.grade, chapter.number),
chapter.title,
f'{book.key} فصل {chapter.number}',
)
def test_page_ranges_are_contiguous_for_books_we_ingest_ourselves(self):
"""
فقط کتاب‌هایی که فایلِ PDF دارند. پایه‌های ششم و هفتم را تیمِ دیگر
ingest کرده و نقاطشان شماره‌ی صفحه ندارند، پس بازه‌ی صفحه برایشان صفر
است و معنایی ندارد.
"""
from rag.services.textbooks import INGESTABLE
self.assertTrue(INGESTABLE, 'هیچ کتابِ قابلِ ingest ثبت نشده')
for book in INGESTABLE.values():
previous = None
for chapter in book.chapters:
self.assertLess(0, chapter.first_page, book.key)
self.assertLessEqual(chapter.first_page, chapter.last_page, book.key)
if previous is not None:
# بازه‌ها نباید همپوشانی یا حفره داشته باشند، وگرنه صفحه‌ای
# دو بار ingest می‌شود یا اصلاً دیده نمی‌شود.
self.assertEqual(chapter.first_page, previous.last_page + 1, book.key)
previous = chapter
def test_pdf_pages_apply_the_offset(self):
from rag.services.textbooks import MATH_9
first, last = MATH_9.pdf_pages(MATH_9.chapter(1))
self.assertEqual((first, last), (9, 25))
class GradeAwarePromptTests(TestCase):
def test_secondary_grade_is_not_addressed_as_an_elementary_pupil(self):
from rag.services.generation import system_prompt
self.assertIn('نهم', system_prompt('نهم'))
self.assertNotIn('دبستان', system_prompt('نهم'))
self.assertIn('دبستان', system_prompt('سوم'))
@override_settings(**RAG_ON)
class AnswerAnnotationTests(TestCase):
"""پاسخ باید با متادیتای تولید ثبت شود، وگرنه لایک/دیسلایک بعداً قابلِ تحلیل نیست."""
def setUp(self):
cache.clear()
reset_clients_cache()
self.user = User.objects.create(phone='09120000777', is_guest=False, grade_id=1)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def tearDown(self):
cache.clear()
reset_clients_cache()
def test_assistant_message_records_model_prompt_version_and_retrieval(self):
from ai_tutor.models import Message
from rag.services.generation import PROMPT_VERSION
qdrant = _FakeQdrant(points=[{'summary': 'خلاصه', 'lesson_title': 'کسر', 'lesson_number': 2}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخ')), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
response = self.client.post('/api/ai/ask', {
'userQuestion': 'کسر چیست؟', 'subject': 'ریاضی',
'grade': 'ششم', 'lessonNumber': 2,
}, format='json')
self.assertEqual(response.status_code, 200)
message = Message.objects.get(pk=response.data['messageId'])
self.assertEqual(message.prompt_version, PROMPT_VERSION)
self.assertTrue(message.model_name)
self.assertEqual(message.grade_title, 'ششم')
self.assertEqual(message.retrieval_collection, 'ریاضی ششم')
self.assertEqual(message.chunk_count, 1)
self.assertFalse(message.from_cache)
def test_second_identical_question_is_marked_as_cached(self):
from ai_tutor.models import Message
qdrant = _FakeQdrant(points=[{'summary': 'خلاصه', 'lesson_title': 'کسر', 'lesson_number': 2}])
body = {'userQuestion': 'همان سوال', 'subject': 'ریاضی', 'grade': 'ششم', 'lessonNumber': 2}
with override_settings(RAG_ANSWER_CACHE_TTL=600), \
patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخ')), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
self.client.post('/api/ai/ask', body, format='json')
second = self.client.post('/api/ai/ask', body, format='json')
message = Message.objects.get(pk=second.data['messageId'])
self.assertTrue(message.from_cache)
# متادیتای تولید از cache هم می‌آید، پس نباید خالی شود.
self.assertTrue(message.prompt_version)
def test_feedback_stores_optional_reason(self):
from ai_tutor.models import MessageFeedback
qdrant = _FakeQdrant(points=[{'summary': 'خلاصه', 'lesson_number': 2}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai()), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
ask = self.client.post('/api/ai/ask', {
'userQuestion': 'سوال', 'subject': 'ریاضی', 'grade': 'ششم', 'lessonNumber': 2,
}, format='json')
response = self.client.post(
f"/api/ai/messages/{ask.data['messageId']}/feedback",
{'isHelpful': False, 'reason': 'جواب ربطی به سوال نداشت'}, format='json',
)
self.assertEqual(response.status_code, 200)
feedback = MessageFeedback.objects.get(message_id=ask.data['messageId'])
self.assertFalse(feedback.is_helpful)
self.assertEqual(feedback.reason, 'جواب ربطی به سوال نداشت')
@override_settings(**RAG_ON)
class IngestBookResumeTests(TestCase):
"""
ازسرگیریِ ingestِ کتاب. مهم‌ترین حالت، صفحه‌ی بی‌مسئله است: بدونِ نشانه‌ی
IngestedPage هر اجرای دوباره یک فراخوانیِ vision دیگر برایش خرج می‌کند.
"""
def setUp(self):
reset_clients_cache()
self.calls = []
def tearDown(self):
reset_clients_cache()
def _run(self, pages_content, **kwargs):
"""pages_content: {pdf_page: [problemType, ...]}؛ صفحه‌ی غایب یعنی بی‌مسئله."""
from rag.services import ingest as ingest_service
def fake_images(path, first_page=1, dpi=200, last_page=None):
return [f'image-p{first_page}']
def fake_extract(image, grade, chapter_name):
page = int(str(image).split('p')[-1])
self.calls.append(page)
return {'grade': grade, 'chapter': chapter_name,
'problemTypes': pages_content.get(page, [])}
with patch('rag.services.pdf.extract_page_images', side_effect=fake_images), \
patch('rag.services.pdf.extract_problem_types', side_effect=fake_extract), \
patch('rag.services.ingest.ensure_collection'), \
patch('rag.services.ingest.upsert_points'), \
patch('rag.services.ingest.embed_text', return_value=[0.01] * 1536):
return ingest_service.ingest_book(subject='ریاضی', grade='نهم', **kwargs)
def test_pages_without_problems_are_not_billed_twice(self):
problem = {'type': 'مجموعه', 'keywords': ['مجموعه'], 'generalSolution': 'حل',
'exampleFromImage': {'problem': 'سوال', 'solution': 'جواب'}}
first = self._run({10: [problem]}, chapters=[1], max_pages=3)
self.assertEqual(first['pages_processed'], 3)
self.assertEqual(first['chunks_created'], 1)
self.assertEqual(self.calls, [9, 10, 11])
# اجرای دوم: هر سه صفحه — شاملِ ۹ و ۱۱ که chunk نساختند — رد می‌شوند.
self.calls.clear()
second = self._run({10: [problem]}, chapters=[1], max_pages=3)
self.assertEqual(self.calls, [12, 13, 14])
self.assertEqual(second['pages_skipped'], 3)
def test_a_failing_page_is_retried_on_the_next_run(self):
from rag.services import ingest as ingest_service
def fake_images(path, first_page=1, dpi=200, last_page=None):
return [f'image-p{first_page}']
def boom(image, grade, chapter_name):
raise RuntimeError('provider down')
with patch('rag.services.pdf.extract_page_images', side_effect=fake_images), \
patch('rag.services.pdf.extract_problem_types', side_effect=boom), \
patch('rag.services.ingest.ensure_collection'), \
patch('rag.services.ingest.upsert_points'):
result = ingest_service.ingest_book(
subject='ریاضی', grade='نهم', chapters=[1], max_pages=2,
)
self.assertEqual(result['status'], 'partial')
self.assertEqual(result['failed_pages'], [9, 10])
self.assertEqual(result['pages_processed'], 0)
# هیچ نشانه‌ای زده نشده، پس اجرای بعدی دوباره سراغشان می‌رود.
self.calls.clear()
self._run({}, chapters=[1], max_pages=2)
self.assertEqual(self.calls, [9, 10])
def test_chapter_title_comes_from_our_catalog_not_from_the_model(self):
from rag.models import LessonChunk
# مدل عنوانِ فصلِ متفاوتی برمی‌گرداند؛ باید نادیده گرفته شود، وگرنه
# فیلترِ retrieval روی رشته‌ی دقیق دیگر نمی‌خورد.
problem = {'type': 'مجموعه', 'keywords': [], 'generalSolution': 'حل',
'exampleFromImage': {'problem': 'س', 'solution': 'ج'}}
with patch('rag.services.pdf.extract_page_images',
side_effect=lambda path, first_page=1, dpi=200, last_page=None: [f'image-p{first_page}']), \
patch('rag.services.pdf.extract_problem_types',
return_value={'grade': 'چیزِ دیگر', 'chapter': 'فصلِ اشتباه',
'problemTypes': [problem]}), \
patch('rag.services.ingest.ensure_collection'), \
patch('rag.services.ingest.upsert_points'), \
patch('rag.services.ingest.embed_text', return_value=[0.01] * 1536):
from rag.services import ingest as ingest_service
ingest_service.ingest_book(subject='ریاضی', grade='نهم', chapters=[1], max_pages=1)
chunk = LessonChunk.objects.get()
self.assertEqual(chunk.chapter_title, 'فصل ۱: مجموعه‌ها')
self.assertEqual(chunk.grade_title, 'نهم')
class LessonContextResolutionTests(TestCase):
"""
اپ فقط lessonId دارد؛ نباید مجبور باشد نامِ collectionِ Qdrant را بداند.
"""
def setUp(self):
from content.models import Grade, Lesson, Node, Unit
grade = Grade.objects.create(title='پایه نهم', icon='grade-9', color='#FF9800')
unit = Unit.objects.create(grade=grade, title='فصل ۴: توان و ریشه', order=4)
node = Node.objects.create(unit=unit, title='توان صحیح', order=1)
self.lesson = Lesson.objects.create(node=node, title='محاسبه توان منفی', intro_text='.')
other_unit = Unit.objects.create(grade=grade, title='یک عنوانِ ناشناخته', order=9)
other_node = Node.objects.create(unit=other_unit, title='درس', order=1)
self.orphan_lesson = Lesson.objects.create(node=other_node, title='x', intro_text='.')
def test_derives_subject_grade_and_chapter(self):
from rag.services.lesson_context import resolve_from_lesson
self.assertEqual(
resolve_from_lesson(self.lesson.id),
{'subject': 'ریاضی', 'gradeTitle': 'نهم', 'chapter': 4},
)
def test_unknown_chapter_title_still_yields_the_grade(self):
from rag.services.lesson_context import resolve_from_lesson
self.assertEqual(resolve_from_lesson(self.orphan_lesson.id), {'gradeTitle': 'نهم'})
def test_bad_input_is_not_an_error(self):
from rag.services.lesson_context import resolve_from_lesson
for value in (None, '', 'abc', 999999):
self.assertEqual(resolve_from_lesson(value), {})
def test_grade_prefix_variants_normalize_to_the_collection_form(self):
from rag.services.lesson_context import normalize_grade_title
self.assertEqual(normalize_grade_title('پایه نهم'), 'نهم')
self.assertEqual(normalize_grade_title('پایهٔ دهم'), 'دهم')
self.assertEqual(normalize_grade_title('ششم'), 'ششم')
@override_settings(**RAG_ON)
class AskDerivesContextFromLessonTests(TestCase):
def setUp(self):
from content.models import Grade, Lesson, Node, Unit
cache.clear()
reset_clients_cache()
self.user = User.objects.create(device_id='dev-ctx-1', is_guest=True)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۴: توان و ریشه', order=4)
node = Node.objects.create(unit=unit, title='توان صحیح', order=1)
self.lesson = Lesson.objects.create(node=node, title='توان منفی', intro_text='.')
def tearDown(self):
cache.clear()
reset_clients_cache()
def test_lesson_id_alone_is_enough_to_hit_the_right_collection(self):
from ai_tutor.models import Message
qdrant = _FakeQdrant(points=[{'generalSolution': 'معکوسِ پایه را بگیر.',
'chapter': 'فصل ۴: توان و ریشه'}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخ')), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
response = self.client.post('/api/ai/ask', {
'userQuestion': 'چرا توان منفی معکوس می‌شود؟',
'lessonId': str(self.lesson.id),
}, format='json')
self.assertEqual(response.status_code, 200)
message = Message.objects.get(pk=response.data['messageId'])
# بدونِ اینکه اپ subject/grade/lessonNumber فرستاده باشد:
self.assertEqual(message.retrieval_collection, 'ریاضی نهم')
self.assertEqual(message.retrieval_filter_value, 'فصل ۴: توان و ریشه')
self.assertEqual(message.grade_title, 'نهم')
def test_explicit_values_still_win_over_the_derived_ones(self):
from ai_tutor.models import Message
qdrant = _FakeQdrant(points=[{'summary': 'خلاصه', 'lesson_number': 2}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai()), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
response = self.client.post('/api/ai/ask', {
'userQuestion': 'سوال', 'lessonId': str(self.lesson.id),
'subject': 'علوم تجربی', 'grade': 'سوم', 'lessonNumber': 2,
}, format='json')
message = Message.objects.get(pk=response.data['messageId'])
self.assertEqual(message.retrieval_collection, 'علوم تجربی سوم')
class NonIngestableBookTests(TestCase):
def test_a_book_without_a_pdf_is_rejected_by_ingest_not_silently_skipped(self):
from rag.services.ingest import ingest_book
# پایه‌ی ششم فقط ساختار دارد (دادهٔ واقعی‌اش روی Qdrantِ تیمِ دیگر است).
with self.assertRaises(FileNotFoundError):
ingest_book(subject='ریاضی', grade='ششم', chapters=[1], max_pages=1)
@override_settings(**RAG_ON)
class FollowUpStaysGroundedTests(TestCase):
"""
پیامِ دومِ مکالمه هم باید به کتاب مستند بماند.
اپ در ادامه فقط `conversationId` می‌فرستد. چون Conversation فیلدی برای
عنوانِ پایه ندارد، grade_title خالی می‌شد و retrieval بی‌هیچ خطایی رد
می‌شد — یعنی فقط جوابِ اول از کتاب می‌آمد.
"""
def setUp(self):
from content.models import Grade, Lesson, Node, Unit
cache.clear()
reset_clients_cache()
self.user = User.objects.create(device_id='dev-follow-1', is_guest=True)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۴: توان و ریشه', order=4)
node = Node.objects.create(unit=unit, title='توان صحیح', order=1)
self.lesson = Lesson.objects.create(node=node, title='توان منفی', intro_text='.')
def tearDown(self):
cache.clear()
reset_clients_cache()
def test_second_message_of_a_conversation_still_hits_the_collection(self):
from ai_tutor.models import Message
qdrant = _FakeQdrant(points=[{'generalSolution': 'معکوسِ پایه.',
'chapter': 'فصل ۴: توان و ریشه'}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخ')), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
first = self.client.post('/api/ai/ask', {
'userQuestion': 'سوالِ اول', 'lessonId': str(self.lesson.id),
}, format='json')
# پیامِ دوم: فقط conversationId، همان‌طور که اپ می‌فرستد.
second = self.client.post('/api/ai/ask', {
'userQuestion': 'سوالِ دوم، متفاوت',
'conversationId': str(first.data['conversationId']),
}, format='json')
self.assertEqual(second.status_code, 200, second.data)
message = Message.objects.get(pk=second.data['messageId'])
self.assertEqual(message.retrieval_collection, 'ریاضی نهم')
self.assertEqual(message.retrieval_filter_value, 'فصل ۴: توان و ریشه')
self.assertEqual(message.chunk_count, 1)
class ReviewFixRegressionTests(TestCase):
"""نگهبانِ باگ‌هایی که مرورِ کد پیدا کرد؛ هرکدام قبلاً بی‌سروصدا خراب می‌کردند."""
def test_a_chunk_with_only_a_problem_type_still_counts_as_structured(self):
from rag.services.ingest import _payload
chunk = LessonChunk(
lesson_id=None, subject='ریاضی', lesson_number=1, chapter=1,
chapter_title='فصل ۱: مجموعه‌ها', grade_title='نهم', order=1,
title='فصل ۱: مجموعه‌ها', original_text='Type: مجموعه', summary='Type: مجموعه',
collection='ریاضی نهم', problem_type='مجموعه', keywords=['مجموعه'],
general_solution='', problem='', solution='',
)
payload = _payload(chunk)
self.assertNotIn('summary', payload)
self.assertEqual(payload['type'], 'مجموعه')
def test_cache_key_separates_grades(self):
from rag.services.answer_cache import make_key
self.assertNotEqual(
make_key(None, None, 'کسر چیست؟', 'نهم'),
make_key(None, None, 'کسر چیست؟', 'سوم'),
)
def test_grade_id_path_builds_the_same_collection_as_the_lesson_path(self):
from content.models import Grade
from rag.services.config import build_collection_name, resolve_grade_title
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
# «پایه نهم» نباید به «ریاضی پایه نهم» تبدیل شود.
self.assertEqual(resolve_grade_title(grade.id), 'نهم')
self.assertEqual(
build_collection_name('ریاضی', resolve_grade_title(grade.id)), 'ریاضی نهم',
)
def test_feedback_reason_survives_a_second_vote_without_one(self):
from ai_tutor.models import Conversation, Message, MessageFeedback
user = User.objects.create(device_id='dev-reason', is_guest=True)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
conversation = Conversation.objects.create(user_id=user.id)
message = Message.objects.create(conversation=conversation, role='assistant', text='پاسخ')
client.post(f'/api/ai/messages/{message.id}/feedback',
{'isHelpful': False, 'reason': 'ربطی نداشت'}, format='json')
client.post(f'/api/ai/messages/{message.id}/feedback',
{'isHelpful': True}, format='json')
feedback = MessageFeedback.objects.get(message=message)
self.assertTrue(feedback.is_helpful)
self.assertEqual(feedback.reason, 'ربطی نداشت')
def test_a_failed_upsert_rolls_back_so_the_page_is_retried(self):
from rag.services import ingest as ingest_service
def boom(*args, **kwargs):
raise RuntimeError('qdrant down')
rows = [{'order': 1, 'source_page': 42, 'original_text': 'x', 'summary': 'x',
'structured': {'chapter_title': 'فصل ۱: مجموعه‌ها', 'grade_title': 'نهم',
'problem_type': 'مجموعه'}}]
with patch('rag.services.ingest.ensure_collection'), \
patch('rag.services.ingest.embed_text', return_value=[0.01] * 1536), \
patch('rag.services.ingest.upsert_points', side_effect=boom):
with self.assertRaises(RuntimeError):
ingest_service._persist_and_upsert(
rows, lesson_id=None, grade_id=None, subject='ریاضی',
lesson_number=1, chapter=1, title='فصل ۱: مجموعه‌ها',
collection='ریاضی نهم',
)
# هیچ ردیفی نباید بماند، وگرنه _done_pages آن صفحه را انجام‌شده می‌بیند
# و chunkها برای همیشه در DB می‌مانند بدونِ اینکه به Qdrant برسند.
self.assertEqual(LessonChunk.objects.filter(source_page=42).count(), 0)