From 5f3aae0756238a0e36021325b40691ba3659ab76 Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 13 Sep 2026 19:01:24 +0330 Subject: [PATCH] cover the new paths, especially the ones that fail silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit خطاهای این پروژه خاموش‌اند: نامِ اشتباهِ collection یا فیلترِ نخورده نتیجه‌ی خالی می‌دهد نه استثنا، و دستیار فقط بدتر جواب می‌دهد. این تست‌ها نگهبانِ همان‌جاهایند. TextbookLayoutTests بررسی می‌کند رشته‌ی هر فصل بینِ ingest و retrieval یکی باشد و بازه‌ی صفحه‌ها پیوسته بماند. IngestBookResumeTests مطمئن می‌شود صفحه‌ی بی‌مسئله دوبار پول نمی‌گیرد، صفحه‌ی خطادار در اجرای بعد دوباره سراغش می‌روند، و upsertِ شکست‌خورده rollback می‌شود. LessonContextResolutionTests نگهبانِ نرمال‌سازیِ «پایه نهم» → «ریاضی نهم» است و اینکه مسیرِ gradeId و مسیرِ lessonId به یک collection برسند. GradeAwarePromptTests جلوی برگشتنِ لحنِ دبستانی برای پایه‌های بالا را می‌گیرد. FollowUpStaysGroundedTests پیامِ دومِ یک گفتگو را هم مستند نگه می‌دارد. Co-Authored-By: Claude Opus 5 --- accounts/tests.py | 67 ++++++ content/tests.py | 67 ++++++ content/tests_flow.py | 220 ++++++++++++++++++ rag/tests.py | 505 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 859 insertions(+) create mode 100644 content/tests_flow.py diff --git a/accounts/tests.py b/accounts/tests.py index a2f56d0..cb7ace9 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1493,3 +1493,70 @@ def _orig_loader(): except _U.DoesNotExist: raise AuthenticationFailed('کاربر پیدا نشد.') 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) diff --git a/content/tests.py b/content/tests.py index 5094a7a..dd94494 100644 --- a/content/tests.py +++ b/content/tests.py @@ -712,3 +712,70 @@ class ReadThrottleTests(TestCase): f'/api/home?grade={self.grade.id}', HTTP_AUTHORIZATION=f'Bearer {self.token}').status_code) 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'], []) diff --git a/content/tests_flow.py b/content/tests_flow.py new file mode 100644 index 0000000..08c5eb4 --- /dev/null +++ b/content/tests_flow.py @@ -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'], 'متنِ درس') diff --git a/rag/tests.py b/rag/tests.py index f764920..8a5083d 100644 --- a/rag/tests.py +++ b/rag/tests.py @@ -355,3 +355,508 @@ class AskAIAnswerCacheTests(TestCase): # ولی پیام و کوتا برای هر دو درخواست ثبت/مصرف شده self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 2) 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)