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:
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