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

105
docs/extraction-playbook.md Normal file
View File

@@ -0,0 +1,105 @@
# روشِ استخراجِ ارزان و دقیق از کتابِ درسی
این فایل نتیجه‌ی یک روزِ اندازه‌گیریِ واقعی روی پنج کتابِ ریاضی است
(۱۴۰۵/۰۶/۲۹). هر عددی که اینجا می‌بینید از پنلِ صورتحساب یا از دادهٔ واقعی
درآمده، نه از جدولِ قیمتِ رسمی و نه از تخمین.
**برای تیم‌های دیگر:** اگر کتابِ درسی را با مدلِ vision استخراج می‌کنید،
این چهار تصمیم تفاوتِ چند برابری در هزینه و کیفیت می‌سازند.
---
## ۱. مدل را اندازه بگیرید، از جدولِ قیمت نخوانید
قیمتِ GapGPT با قیمتِ رسمیِ OpenAI یکی نیست. اگر از جدولِ رسمی تخمین بزنید،
گران‌ترین گزینه را ارزان می‌بینید.
اندازه‌گیریِ واقعی روی سه صفحه‌ی یکسان، با خواندنِ
`/v1/dashboard/billing/usage` قبل و بعد:
| مدل | سنت/صفحه (واقعی) | عددهای درست¹ | مسئله‌ی جعلی |
|---|---|---|---|
| **gpt-4.1-mini** | **۰٫۲۲** | **۹۸٪** | خیر |
| gpt-4o | ۰٫۷۷ | ۹۷٪ | بله |
| gemini-3.6-flash | ۲٫۱۳ | ۱۰۰٪ | خیر |
| gpt-4o-mini | — | ۰٪ | مختصات را از خود ساخت |
¹ سنجه‌ی رایگان: هر عددی که مدل در متنِ مسئله می‌نویسد باید در لایه‌ی متنیِ
همان صفحه‌ی PDF وجود داشته باشد. عددی که آنجا نیست یا از شکل خوانده شده
(خوب) یا جعل شده (بد)، و نرخِ نسبیِ مدل‌ها قابلِ مقایسه است.
**نتیجه:** `gpt-4.1-mini` هم دقیق‌تر از `gpt-4o` بود هم ۳٫۵ برابر ارزان‌تر.
`gemini` دقیق‌ترین بود ولی سه برابرِ `gpt-4o` گران. `gpt-4o-mini` صریحاً
مختصات را از خود درمی‌آورد («با فرض مختصات A(1,2)…») — شکستِ بی‌صدا، بدترین نوع.
**Batch API روی GapGPT وجود ندارد** (`/v1/batches` → ۴۰۴)، پس تخفیفِ ۵۰٪
در دسترس نیست. هر ابزاری که آن را وعده بدهد دارد گمراه می‌کند.
**DPI اهرمِ هزینه نیست.** OpenAI تصویر را به ضلعِ کوتاهِ ۷۶۸ نرمال می‌کند،
پس رندر با ۲۰۰ یا ۱۱۰ DPI توکنِ یکسانی می‌دهد. ۲۰۰ را نگه دارید.
## ۲. قاعده‌ی self-contained را در پرامپت بگذارید
مدلِ vision شکل را **می‌بیند**؛ فقط از او خواسته نشده بنویسدش. بدونِ این
قاعده، ۴۲٪ مسئله‌ها به «شکلِ زیر» ارجاع می‌دهند و برای هیچ‌کس قابلِ حل نیستند.
قاعده (در `rag/services/pdf.py`): هرجا مسئله به شکل/جدول ارجاع می‌دهد،
اندازه‌ها، برچسبِ رأس‌ها، زاویه‌ها و مقادیرِ جدول را **داخلِ متنِ مسئله**
بنویس؛ و اگر شکلی واقعاً قابلِ توصیف نیست (رسمِ آزاد، رنگ‌آمیزی) مسئله را
رد کن به‌جای نوشتنِ ارجاعِ بی‌فایده.
اثرش با عدد: نرخِ `unjudgeable` در بازبینی از **۴۰٪ به ۳٪** رسید.
## ۳. بک‌اسلشِ LaTeX را تعمیر کنید، وگرنه صفحه‌های پرفرمول را می‌بازید
مدل در متنِ ریاضی `\frac` و `\sqrt` می‌نویسد و بک‌اسلشِ تنها JSON را می‌شکند.
`json.loads` کلِ صفحه را دور می‌ریزد — یعنی دقیقاً باارزش‌ترین صفحه‌ها.
⚠️ **تله‌ی نسخه‌ی اولِ تعمیر:** `\f`، `\t`، `\b`، `\r` و `\n` از نظرِ JSON
**معتبر**اند، پس «سالم» رد می‌شوند و `\frac` به formfeed+«rac» و `\theta` به
tab+«heta» تبدیل می‌شود — خرابیِ بی‌صدا به‌جای خطای بلند.
قاعده‌ی تفکیک: دستورِ LaTeX همیشه با حرفِ ASCII ادامه می‌یابد (`\frac`,
`\times`)، ولی escapeِ واقعی با رقم، فاصله، فارسی یا پایانِ رشته می‌آید
(`\n۲.`). پیاده‌سازی: `rag/services/pdf.py::repair_json_escapes`.
## ۴. محافظِ «وابسته به شکل» را با regexِ تخت نسازید
برای صرفه‌جویی، مسئله‌های وابسته به شکل را قبل از فرستادن کنار می‌گذاشتیم.
regexِ اولیه شاملِ `شکل|جدول|روبه‌رو|مقابل|زیر را` بود و فاجعه‌بار گشاد:
| متن | چرا رد می‌شد | واقعیت |
|---|---|---|
| «زاویه‌های **روبه‌رو** در لوزی برابرند؟» | کلمه‌ی «روبه‌رو» | یعنی زاویه‌ی مقابل |
| «کسرهای **زیر را** گویا کنید: ۲/√۳» | «زیر را» | داده همان‌جاست |
| «ظرفی **به شکل** مخروطی با شعاع ۴» | کلمه‌ی «شکل» | یعنی شبیهِ مخروط |
اندازه‌گیری روی نمونه‌ی ۵۰تایی: **۸۴٪ از رد‌شده‌ها واقعاً قابلِ قضاوت بودند**
— حدود ۷۸۴ مسئله‌ی سالم بی‌صدا از چرخه بیرون مانده بود.
قاعده‌ی درست دو شرط دارد و هر دو باید برقرار باشند: (۱) ارجاعِ صریح به چیزی
بیرونِ متن، و (۲) بعد از آن ارجاع هیچ توصیفی نیامده باشد. پیاده‌سازی:
`rag/management/commands/rag_verify_solutions.py::needs_figure`.
## ۵. ساختِ سوال: subagent به‌جای API
ساختِ سوالِ چهارگزینه‌ای با `o4-mini` روی GapGPT **۰٫۷۷ سنت برای هر سوال**
تمام می‌شود (چهار برابرِ تخمینِ خودِ ابزار، چون مدلِ ریزنینگ است).
همان کار با subagentهای Claude Code (مدلِ Sonnet) **رایگان** است و بازده‌اش
روی ۳۴۵ ورودی **۹۲٪** بود. Haiku برای این کار مناسب نیست — جوابِ نهایی را
از راه‌حل بیرون نمی‌کشد.
مسیر: جفت‌های (درس، مسئله‌ی تأییدشده) را به JSON بیرون بدهید، subagent فایلِ
خروجی بنویسد، و از `content.question_io` واردش کنید — که اعتبارسنجی دارد و
فلسفه‌اش این است که **فایلِ بد هیچ‌چیز نمی‌نویسد**.
---
## درسِ فراگیر
هر چهار موردِ بالا یک وجهِ مشترک دارند: **هیچ‌کدام خطا تولید نمی‌کردند.**
نه در لاگ، نه در خروجی، نه در تست. فقط دادهٔ کمتر یا بدتر می‌ساختند.
پس: **قبل از اعتماد به هر ران انبوه، یک نمونه‌ی ۴۰ تا ۵۰تایی را با چشم
بخوانید.** هر چهار موردِ این فایل با همین کار پیدا شدند، نه با کدخوانی.

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