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:
2026-09-20 12:36:45 +03:30
parent 3156bcbbf3
commit acce78bb03
2 changed files with 276 additions and 0 deletions

View File

@@ -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('شکل زیر را رنگ‌آمیزی کنید'))