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:
@@ -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)
|
||||
|
||||
@@ -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'], [])
|
||||
|
||||
220
content/tests_flow.py
Normal file
220
content/tests_flow.py
Normal 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'], 'متنِ درس')
|
||||
505
rag/tests.py
505
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)
|
||||
|
||||
Reference in New Issue
Block a user