From acce78bb03eb31e3a138df6057e2036a20df5fe9 Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 20 Sep 2026 12:36:45 +0330 Subject: [PATCH] write down the extraction method so another team can reuse it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/extraction-playbook.md: چهار تصمیمی که تفاوتِ چند برابری در هزینه و کیفیت ساختند، با عددهای اندازه‌گیری‌شده‌ی واقعی نه تخمین. سرفصل‌ها: انتخابِ مدل از روی اندازه‌گیری (gpt-4.1-mini هم دقیق‌تر از gpt-4o بود هم ۳٫۵ برابر ارزان‌تر، و gemini برخلافِ جدولِ قیمت سه برابر گران‌تر)، قاعده‌ی self-contained، تعمیرِ بک‌اسلشِ LaTeX، محافظِ شکل، و ساختِ سوال با subagent به‌جای API. وجهِ مشترکِ هر چهار مورد: هیچ‌کدام خطا تولید نمی‌کردند — نه در لاگ، نه در تست. فقط دادهٔ کمتر یا بدتر می‌ساختند. برای همین توصیه‌ی محوریِ فایل این است که قبل از اعتماد به هر ران انبوه، نمونه‌ای ۴۰-۵۰تایی با چشم خوانده شود. تست‌های rag هم اینجا می‌آیند: بازخوانی، تعمیرِ JSON، و محافظِ شکل. Co-Authored-By: Claude Opus 5 --- docs/extraction-playbook.md | 105 ++++++++++++++++++++++ rag/tests.py | 171 ++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 docs/extraction-playbook.md diff --git a/docs/extraction-playbook.md b/docs/extraction-playbook.md new file mode 100644 index 0000000..6aaac5d --- /dev/null +++ b/docs/extraction-playbook.md @@ -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` واردش کنید — که اعتبارسنجی دارد و +فلسفه‌اش این است که **فایلِ بد هیچ‌چیز نمی‌نویسد**. + +--- + +## درسِ فراگیر + +هر چهار موردِ بالا یک وجهِ مشترک دارند: **هیچ‌کدام خطا تولید نمی‌کردند.** +نه در لاگ، نه در خروجی، نه در تست. فقط دادهٔ کمتر یا بدتر می‌ساختند. + +پس: **قبل از اعتماد به هر ران انبوه، یک نمونه‌ی ۴۰ تا ۵۰تایی را با چشم +بخوانید.** هر چهار موردِ این فایل با همین کار پیدا شدند، نه با کدخوانی. diff --git a/rag/tests.py b/rag/tests.py index 182d3af..0a62136 100644 --- a/rag/tests.py +++ b/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('شکل زیر را رنگ‌آمیزی کنید'))