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:
105
docs/extraction-playbook.md
Normal file
105
docs/extraction-playbook.md
Normal 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` واردش کنید — که اعتبارسنجی دارد و
|
||||
فلسفهاش این است که **فایلِ بد هیچچیز نمینویسد**.
|
||||
|
||||
---
|
||||
|
||||
## درسِ فراگیر
|
||||
|
||||
هر چهار موردِ بالا یک وجهِ مشترک دارند: **هیچکدام خطا تولید نمیکردند.**
|
||||
نه در لاگ، نه در خروجی، نه در تست. فقط دادهٔ کمتر یا بدتر میساختند.
|
||||
|
||||
پس: **قبل از اعتماد به هر ران انبوه، یک نمونهی ۴۰ تا ۵۰تایی را با چشم
|
||||
بخوانید.** هر چهار موردِ این فایل با همین کار پیدا شدند، نه با کدخوانی.
|
||||
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