write down the extraction method so another team can reuse it
docs/extraction-playbook.md: چهار تصمیمی که تفاوتِ چند برابری در هزینه و کیفیت ساختند، با عددهای اندازهگیریشدهی واقعی نه تخمین. سرفصلها: انتخابِ مدل از روی اندازهگیری (gpt-4.1-mini هم دقیقتر از gpt-4o بود هم ۳٫۵ برابر ارزانتر، و gemini برخلافِ جدولِ قیمت سه برابر گرانتر)، قاعدهی self-contained، تعمیرِ بکاسلشِ LaTeX، محافظِ شکل، و ساختِ سوال با subagent بهجای API. وجهِ مشترکِ هر چهار مورد: هیچکدام خطا تولید نمیکردند — نه در لاگ، نه در تست. فقط دادهٔ کمتر یا بدتر میساختند. برای همین توصیهی محوریِ فایل این است که قبل از اعتماد به هر ران انبوه، نمونهای ۴۰-۵۰تایی با چشم خوانده شود. تستهای rag هم اینجا میآیند: بازخوانی، تعمیرِ JSON، و محافظِ شکل. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
171
rag/tests.py
171
rag/tests.py
@@ -627,6 +627,139 @@ class IngestBookResumeTests(TestCase):
|
||||
self.assertEqual(chunk.grade_title, 'نهم')
|
||||
|
||||
|
||||
class VisionJsonRepairTests(TestCase):
|
||||
"""
|
||||
مدلِ vision در متنِ ریاضی LaTeX مینویسد و بکاسلشِ تنها JSON را میشکند،
|
||||
پس صفحههای پرفرمول — باارزشترینها — بیسروصدا دور ریخته میشدند.
|
||||
"""
|
||||
|
||||
def _load(self, raw):
|
||||
import json
|
||||
|
||||
from rag.services.pdf import repair_json_escapes
|
||||
return json.loads(repair_json_escapes(raw))['p']
|
||||
|
||||
def test_latex_commands_survive_instead_of_killing_the_page(self):
|
||||
self.assertEqual(self._load(r'{"p":"\frac{2}{\sqrt{3}}"}'),
|
||||
r'\frac{2}{\sqrt{3}}')
|
||||
|
||||
def test_latex_starting_with_a_valid_escape_letter_is_not_mangled(self):
|
||||
# دامِ اصلی: \f و \t و \b و \n از نظرِ JSON معتبرند، پس بدونِ تفکیک
|
||||
# \frac به formfeed+«rac» و \theta به tab+«heta» تبدیل میشد.
|
||||
self.assertEqual(self._load(r'{"p":"\times \theta \beta \nabla"}'),
|
||||
r'\times \theta \beta \nabla')
|
||||
|
||||
def test_a_real_newline_is_still_a_newline(self):
|
||||
self.assertEqual(self._load('{"p":"گام ۱\\n۲. ادامه"}'), 'گام ۱\n۲. ادامه')
|
||||
self.assertEqual(self._load('{"p":"اول\\nحالت دوم"}'), 'اول\nحالت دوم')
|
||||
|
||||
def test_escaped_quotes_still_work(self):
|
||||
self.assertEqual(self._load(r'{"p":"او گفت \"سلام\""}'), 'او گفت "سلام"')
|
||||
|
||||
|
||||
class IngestBookRefreshTests(TestCase):
|
||||
"""
|
||||
بازخوانیِ صفحههای معیوب (شکل/جدولِ نوشتهنشده).
|
||||
|
||||
ingestِ دوباره **جایگزین نمیکند**: هر ردیف uuid4ِ تازه میگیرد، پس بدونِ
|
||||
پاکسازی نسخهی تکراری کنارِ قدیمی مینشیند. این کلاس همان را میبندد.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
reset_clients_cache()
|
||||
self.calls = []
|
||||
|
||||
def tearDown(self):
|
||||
reset_clients_cache()
|
||||
|
||||
def _problem(self, text):
|
||||
return {'type': 'نوع', 'keywords': [], 'generalSolution': 'حل',
|
||||
'exampleFromImage': {'problem': text, 'solution': 'ج'}}
|
||||
|
||||
def _run(self, text, **kwargs):
|
||||
from rag.services import ingest as ingest_service
|
||||
|
||||
def fake_images(path, first_page=1, dpi=200, last_page=None):
|
||||
self.calls.append(first_page)
|
||||
return [f'image-p{first_page}']
|
||||
|
||||
with patch('rag.services.pdf.extract_page_images', side_effect=fake_images), \
|
||||
patch('rag.services.pdf.extract_problem_types',
|
||||
return_value={'grade': 'نهم', 'chapter': 'فصل ۱: مجموعهها',
|
||||
'problemTypes': [self._problem(text)]}), \
|
||||
patch('rag.services.ingest.ensure_collection'), \
|
||||
patch('rag.services.ingest.upsert_points'), \
|
||||
patch('rag.services.ingest.delete_points') as delete_mock, \
|
||||
patch('rag.services.ingest.embed_text', return_value=[0.01] * 1536):
|
||||
result = ingest_service.ingest_book(subject='ریاضی', grade='نهم', **kwargs)
|
||||
return result, delete_mock
|
||||
|
||||
def test_refreshing_a_page_replaces_its_chunks_instead_of_duplicating_them(self):
|
||||
from rag.models import LessonChunk
|
||||
|
||||
self._run('با توجه به شکلِ زیر', chapters=[1], max_pages=1)
|
||||
old = LessonChunk.objects.get()
|
||||
old_point_id = old.qdrant_point_id
|
||||
|
||||
result, delete_mock = self._run(
|
||||
'مثلثی با اضلاع ۳ و ۴ و ۵', chapters=[1], max_pages=1, refresh_pages={9},
|
||||
)
|
||||
|
||||
# یک ردیف، نه دو تا — و متنش تازه است.
|
||||
chunk = LessonChunk.objects.get()
|
||||
self.assertEqual(chunk.problem, 'مثلثی با اضلاع ۳ و ۴ و ۵')
|
||||
self.assertEqual(result['chunks_replaced'], 1)
|
||||
# نقطهی قدیمیِ Qdrant هم واقعاً پاک شده، نه فقط ردیفِ دیتابیس.
|
||||
delete_mock.assert_called_once_with('ریاضی نهم', [str(old_point_id)])
|
||||
self.assertNotEqual(chunk.qdrant_point_id, old_point_id)
|
||||
|
||||
def test_a_page_marked_for_refresh_is_not_skipped_by_resume(self):
|
||||
self._run('اولی', chapters=[1], max_pages=1)
|
||||
self.calls.clear()
|
||||
|
||||
# بدونِ کم کردنِ صفحههای refresh از مجموعهی «انجامشده»، منطقِ
|
||||
# ازسرگیری ردش میکرد و بازخوانی بیسروصدا هیچ کاری نمیکرد.
|
||||
self._run('دومی', chapters=[1], max_pages=1, refresh_pages={9})
|
||||
self.assertIn(9, self.calls)
|
||||
|
||||
def test_a_failed_clear_leaves_the_old_data_alone(self):
|
||||
from rag.models import LessonChunk
|
||||
from rag.services import ingest as ingest_service
|
||||
|
||||
self._run('اصلی', chapters=[1], max_pages=1)
|
||||
|
||||
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.ingest.ensure_collection'), \
|
||||
patch('rag.services.ingest.delete_points',
|
||||
side_effect=RuntimeError('qdrant down')):
|
||||
result = ingest_service.ingest_book(
|
||||
subject='ریاضی', grade='نهم', chapters=[1], max_pages=1,
|
||||
refresh_pages={9},
|
||||
)
|
||||
|
||||
# پاکسازی نشد، پس صفحه اصلاً خوانده نشد و دادهٔ قبلی سرِ جایش است —
|
||||
# وگرنه خواندنِ دوباره نسخهی تکراری میساخت.
|
||||
self.assertEqual(LessonChunk.objects.get().problem, 'اصلی')
|
||||
self.assertEqual(result['failed_pages'], [9])
|
||||
self.assertEqual(result['chunks_replaced'], 0)
|
||||
|
||||
def test_refresh_is_limited_to_the_chapters_of_this_run(self):
|
||||
from rag.management.commands.rag_ingest_book import Command
|
||||
from rag.services.textbooks import TEXTBOOKS
|
||||
|
||||
book = TEXTBOOKS['ریاضی نهم']
|
||||
chapter_one = book.chapter(1)
|
||||
first, last = book.pdf_pages(chapter_one)
|
||||
|
||||
opts = {'refresh_pages': f'{first},{last},{last + 5}', 'refresh': None}
|
||||
chosen = Command()._resolve_refresh(opts, 'ریاضی نهم', book, [chapter_one])
|
||||
|
||||
# صفحهی بیرونِ فصلِ انتخابشده کنار گذاشته میشود، وگرنه یک اجرای
|
||||
# محدود به یک فصل میتوانست صفحهی فصلِ دیگر را پاک کند.
|
||||
self.assertEqual(chosen, {first, last})
|
||||
|
||||
|
||||
class LessonContextResolutionTests(TestCase):
|
||||
"""
|
||||
اپ فقط lessonId دارد؛ نباید مجبور باشد نامِ collectionِ Qdrant را بداند.
|
||||
@@ -1005,3 +1138,41 @@ class VerifySolutionsLimitTests(TestCase):
|
||||
self.chunk.refresh_from_db()
|
||||
self.assertEqual(self.chunk.solution_verdict, LessonChunk.VERDICT_UNJUDGEABLE)
|
||||
self.assertEqual(self.chunk.checked_by, 'pre-filter')
|
||||
|
||||
|
||||
class FigureGuardTests(TestCase):
|
||||
"""
|
||||
محافظی که تصمیم میگیرد کدام مسئله بدونِ دیدنِ صفحه قابلِ قضاوت نیست.
|
||||
|
||||
نسخهی اولش یک regexِ تخت بود و فاجعهبار گشاد: «روبهرو» را میگرفت که در
|
||||
هندسه یعنی «زاویهی مقابل»، و «زیر را» را که در «کسرهای زیر را گویا کنید»
|
||||
میآید. اندازهگیری روی نمونهی ۵۰تایی نشان داد **۸۴٪** از چیزهایی که رد
|
||||
کرده بود واقعاً قابلِ قضاوت بودند — یعنی ~۷۸۴ مسئلهی سالم بیصدا از چرخهی
|
||||
ساختِ سوال بیرون مانده بود، بیهیچ خطایی در هیچ لاگی.
|
||||
"""
|
||||
|
||||
def _guard(self, text):
|
||||
from rag.management.commands.rag_verify_solutions import needs_figure
|
||||
return needs_figure(text)
|
||||
|
||||
def test_opposite_angles_are_geometry_not_a_figure_reference(self):
|
||||
self.assertFalse(self._guard('آیا در هر لوزی زاویههای روبهرو با هم برابر است؟'))
|
||||
|
||||
def test_data_that_follows_the_word_below_is_not_a_dangling_reference(self):
|
||||
self.assertFalse(self._guard('کسرهای زیر را گویا کنید: ۲/√۳'))
|
||||
self.assertFalse(self._guard('دستگاه زیر را حل کنید: ۲x - ۳y = ۷'))
|
||||
|
||||
def test_shaped_like_is_not_a_reference_to_a_drawing(self):
|
||||
self.assertFalse(self._guard('ظرفی به شکل مخروطی با شعاع ۴ و ارتفاع ۱۲ داریم'))
|
||||
|
||||
def test_a_reference_followed_by_its_description_is_self_contained(self):
|
||||
# همان چیزی که قاعدهی self-contained در پرامپتِ ingest تولید میکند.
|
||||
self.assertFalse(self._guard(
|
||||
'با توجه به شکل مقابل که شامل مجموعه A با اعضای {a,1} است، ...'))
|
||||
|
||||
def test_a_bare_reference_with_no_description_is_still_caught(self):
|
||||
self.assertTrue(self._guard('با توجه به شکل، مختصات هر نقطه را بنویسید.'))
|
||||
self.assertTrue(self._guard('مساحت شکل روبهرو چقدر است؟'))
|
||||
|
||||
def test_colouring_tasks_can_never_be_judged_from_text(self):
|
||||
self.assertTrue(self._guard('شکل زیر را رنگآمیزی کنید'))
|
||||
|
||||
Reference in New Issue
Block a user