From 52d969458338393f012548071c950e73fcc612a3 Mon Sep 17 00:00:00 2001 From: Zareian Date: Thu, 10 Sep 2026 16:24:18 +0330 Subject: [PATCH] keep ingested chunks findable and structured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs made self-ingested content useless, both silently: - _payload wrote `chapter_number` (int) while math retrieval filters on `chapter` (the full "فصل ۲: کسر" string), so anything we ingest could never match a search — no error, just permanently invisible content. - ingest_pdf flattened the vision extraction into one text blob and threw away type/keywords/generalSolution/problem/solution. LessonChunk is the source of truth, so rebuilding meant paying for vision again — and the problem/solution pairs are the input for building lesson questions. LessonChunk now stores those fields and _payload emits them under the same keys the other team's collections use, so ours and theirs stay one shape. Also fixes a regression from the whitenoise commit: the Manifest storage variant raises when staticfiles.json is absent, which broke all 23 admin rendering tests (Django forces DEBUG=False in tests, so USE_FINDERS is off) and would have taken the admin panel down entirely the day DEBUG=0 ships without a collectstatic run. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 + config/settings.py | 9 ++- data/textbooks/README.md | 26 +++++++ ...0002_lessonchunk_chapter_title_and_more.py | 48 ++++++++++++ rag/models.py | 16 ++++ rag/services/ingest.py | 57 +++++++++++++- rag/tests.py | 78 +++++++++++++++++++ 7 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 data/textbooks/README.md create mode 100644 rag/migrations/0002_lessonchunk_chapter_title_and_more.py diff --git a/.gitignore b/.gitignore index 9cb98cd..e187e09 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ __pycache__/ db.sqlite3 .DS_Store .env + +# کتاب‌های درسی: خودِ PDFها در گیت نمی‌آیند (حجیم‌اند) +data/textbooks/* +!data/textbooks/README.md diff --git a/config/settings.py b/config/settings.py index a4e2c51..f407976 100644 --- a/config/settings.py +++ b/config/settings.py @@ -227,7 +227,14 @@ STORAGES = { 'BACKEND': 'django.core.files.storage.FileSystemStorage', }, 'staticfiles': { - 'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage', + # عمداً نسخه‌ی Manifest نیست: اون نسخه اگه staticfiles.json نباشه + # (یعنی هر وقت collectstatic اجرا نشده) موقعِ رندرِ هر صفحه‌ی ادمین + # ValueError پرت می‌کنه. این یعنی تست‌ها می‌شکستن — جنگو در تست همیشه + # DEBUG=False می‌کنه، پس WHITENOISE_USE_FINDERS هم خاموشه — و مهم‌تر، + # روزی که روی سرور DEBUG=0 بشه و collectstatic فراموش بشه، پنلِ ادمین + # کاملاً می‌خوابید. این نسخه فقط فشرده‌سازی می‌کنه و هیچ‌وقت fail نمی‌شه؛ + # بهاش از دست دادنِ hash در نامِ فایله که برای پنلِ ادمین ناچیزه. + 'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage', }, } # با DEBUG=1، از finders مستقیم سرو می‌کنه؛ نیازی به collectstatic نیست. diff --git a/data/textbooks/README.md b/data/textbooks/README.md new file mode 100644 index 0000000..8870120 --- /dev/null +++ b/data/textbooks/README.md @@ -0,0 +1,26 @@ +# کتاب‌های درسی (PDF) + +فایل‌های PDF کتاب‌های درسی را در همین پوشه بگذارید. خودِ PDFها در گیت +ذخیره نمی‌شوند (حجمشان زیاد است) — فقط همین فایل راهنما ردیابی می‌شود. + +## منبع + +نسخه‌ی رسمی و به‌روزِ کتاب‌ها: +(از خارج ایران در دسترس نیست؛ دانلود باید از داخل ایران انجام شود.) + +## نام‌گذاری پیشنهادی + + math-grade1.pdf ریاضی اول دبستان + math-grade2.pdf ریاضی دوم دبستان + math-grade4.pdf ریاضی چهارم دبستان + math-grade5.pdf ریاضی پنجم دبستان + +پایه‌های سوم و ششم لازم نیستند: محتوایشان از قبل روی Qdrant موجود است +(collectionهای «ریاضی سوم» و «ریاضی ششم» ساخته‌ی تیم دیگر). + +## بعدش چه اتفاقی می‌افتد + +هر صفحه به تصویر تبدیل می‌شود، مدل vision انواع مسئله را استخراج می‌کند +(`type` / `keywords` / `generalSolution` / `problem` / `solution`)، نتیجه هم در +جدول `rag.LessonChunk` (منبع حقیقت، پیش خودمان) ذخیره می‌شود و هم به‌صورت +نقطه در Qdrant ایندکس می‌شود — با همان قرارداد payloadی که تیم دیگر دارد. diff --git a/rag/migrations/0002_lessonchunk_chapter_title_and_more.py b/rag/migrations/0002_lessonchunk_chapter_title_and_more.py new file mode 100644 index 0000000..d52c66b --- /dev/null +++ b/rag/migrations/0002_lessonchunk_chapter_title_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.17 on 2026-09-10 12:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rag', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='lessonchunk', + name='chapter_title', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='lessonchunk', + name='general_solution', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='lessonchunk', + name='grade_title', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='lessonchunk', + name='keywords', + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name='lessonchunk', + name='problem', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='lessonchunk', + name='problem_type', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='lessonchunk', + name='solution', + field=models.TextField(blank=True), + ), + ] diff --git a/rag/models.py b/rag/models.py index 67297f9..0573b21 100644 --- a/rag/models.py +++ b/rag/models.py @@ -24,12 +24,28 @@ class LessonChunk(models.Model): lesson_number = models.IntegerField() chapter = models.IntegerField(null=True, blank=True) + # عنوانِ کاملِ فصل، دقیقاً همون رشته‌ای که در payloadِ تیمِ دیگه هست + # («فصل ۲: کسر»). retrieval برای ریاضی روی همین مقدار فیلتر می‌کنه، پس + # بدونِ این هر چیزی که ingest کنیم در جستجو پیدا نمی‌شه. + chapter_title = models.CharField(max_length=255, blank=True) + grade_title = models.CharField(max_length=100, blank=True) + order = models.PositiveIntegerField() title = models.CharField(max_length=255) original_text = models.TextField() summary = models.TextField() embedding_vector = models.JSONField(null=True, blank=True) + # --- ساختارِ استخراج‌شده از کتاب (همون قراردادِ ریاضیِ تیمِ دیگه) ------- + # vision یک‌بار و با هزینه این‌ها رو درمی‌آره؛ اگه فقط متنِ صاف‌شده رو + # نگه داریم، برای هر بازسازی باید دوباره همون هزینه رو بدیم. ضمناً + # problem/solution ورودیِ مستقیمِ ساختِ سؤال‌های مسیرِ درس‌اند. + problem_type = models.CharField(max_length=255, blank=True) + keywords = models.JSONField(default=list, blank=True) + general_solution = models.TextField(blank=True) + problem = models.TextField(blank=True) + solution = models.TextField(blank=True) + collection = models.CharField(max_length=200, db_index=True) qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False) diff --git a/rag/services/ingest.py b/rag/services/ingest.py index 8a2fec8..a58909b 100644 --- a/rag/services/ingest.py +++ b/rag/services/ingest.py @@ -15,6 +15,7 @@ from ..models import LessonChunk from .chunking import chunk_and_summarize from .config import build_collection_name, resolve_grade_title from .embedding import embed_text +from .known_collections import is_math_subject, resolve_math_chapter from . import pdf as pdf_service from .qdrant_store import ensure_collection, upsert_points @@ -22,7 +23,16 @@ logger = logging.getLogger(__name__) def _payload(chunk: LessonChunk) -> dict: - return { + """ + payloadِ نقطه‌ی Qdrant. + + ⚠️ کلیدِ `chapter` (رشته‌ی کاملِ فصل) حتماً باید نوشته بشه: جستجوی ریاضی در + retrieval.py دقیقاً روی همین کلید فیلتر می‌کنه (نه `chapter_number`)، پس + بدونش هر چیزی که ingest کنیم برای همیشه نامرئی می‌مونه — بدونِ هیچ خطایی. + بقیه‌ی کلیدهای ساختاری هم عمداً هم‌نامِ قراردادِ تیمِ دیگه‌ان تا collectionِ + ما و اونا یک شکل بمونن. + """ + payload = { 'lesson_id': chunk.lesson_id, 'lesson_number': chunk.lesson_number, 'chapter_number': chunk.chapter, @@ -30,6 +40,18 @@ def _payload(chunk: LessonChunk) -> dict: 'lesson_title': chunk.title, 'summary': chunk.summary, } + for key, value in ( + ('chapter', chunk.chapter_title), + ('grade', chunk.grade_title), + ('type', chunk.problem_type), + ('keywords', chunk.keywords), + ('generalSolution', chunk.general_solution), + ('problem', chunk.problem), + ('solution', chunk.solution), + ): + if value: + payload[key] = value + return payload def _persist_and_upsert(rows: list[dict], *, lesson_id, grade_id, subject, @@ -53,6 +75,9 @@ def _persist_and_upsert(rows: list[dict], *, lesson_id, grade_id, subject, summary=row['summary'], embedding_vector=vector, collection=collection, + # فیلدهای ساختاری فقط از مسیرِ PDF/vision می‌آن؛ ingest_text + # اون‌ها رو نداره و خالی می‌مونن (payload هم اون‌وقت نمی‌نویسدشون). + **row.get('structured', {}), ) created.append(chunk) points.append({'id': chunk.qdrant_point_id, 'vector': vector, 'payload': _payload(chunk)}) @@ -71,6 +96,15 @@ def ingest_text(*, lesson_id: int, grade_id, subject: str, lesson_number: int, if not rows: return {'status': 'empty', 'chunks_created': 0, 'collection': collection} + # برای ریاضی، فیلترِ جستجو رشته‌ی کاملِ فصله؛ از شماره‌ی فصل درش می‌آریم تا + # محتوایی که این‌طوری ingest می‌شه هم قابلِ پیدا شدن باشه. اگه فصل ناشناخته + # باشه (پایه‌ای که هنوز در known_collections نیست) خالی می‌مونه. + structured = {'grade_title': grade_title} + if is_math_subject(subject) and chapter is not None: + structured['chapter_title'] = resolve_math_chapter(subject, grade_title, chapter) or '' + for row in rows: + row.setdefault('structured', structured) + created = _persist_and_upsert( rows, lesson_id=lesson_id, grade_id=grade_id, subject=subject, lesson_number=lesson_number, chapter=chapter, title=title, collection=collection, @@ -111,12 +145,31 @@ def ingest_pdf(*, lesson_id: int, grade_id, subject: str, lesson_number: int, except Exception: logger.exception('rag.ingest_pdf: تحلیلِ یک صفحه شکست خورد؛ رد شد') continue + page_chapter = (parsed.get('chapter') or '').strip() or chapter_name + page_grade = (parsed.get('grade') or '').strip() or (detected_grade or grade_title) for problem_type in parsed.get('problemTypes', []) or []: text = pdf_service.problem_type_to_text(problem_type) if not text: continue order += 1 - rows.append({'order': order, 'original_text': text, 'summary': text}) + example = problem_type.get('exampleFromImage', {}) or {} + rows.append({ + 'order': order, + 'original_text': text, + 'summary': text, + # ساختار رو نگه می‌داریم، نه فقط متنِ صاف‌شده‌اش را: هم + # `chapter` برای فیلترِ جستجو لازمه، هم problem/solution + # بعداً ورودیِ ساختِ سؤال‌های چهارگزینه‌ای می‌شن. + 'structured': { + 'chapter_title': page_chapter, + 'grade_title': page_grade, + 'problem_type': (problem_type.get('type') or '').strip(), + 'keywords': problem_type.get('keywords') or [], + 'general_solution': (problem_type.get('generalSolution') or '').strip(), + 'problem': (example.get('problem') or '').strip(), + 'solution': (example.get('solution') or '').strip(), + }, + }) finally: try: os.remove(tmp_path) diff --git a/rag/tests.py b/rag/tests.py index d4086a7..f764920 100644 --- a/rag/tests.py +++ b/rag/tests.py @@ -160,6 +160,84 @@ class IngestTextTests(TestCase): self.assertIn(resp.status_code, (401, 403)) +class IngestPayloadContractTests(TestCase): + """ + قراردادِ بینِ چیزی که ingest می‌نویسه و چیزی که retrieval دنبالش می‌گرده. + + این تست‌ها عمداً ریزبینانه‌ان چون شکستنِ این قرارداد هیچ خطایی تولید نمی‌کنه: + نقطه‌ها سالم در Qdrant می‌شینن، ولی فیلترِ جستجو هیچ‌وقت بهشون نمی‌خوره و + محتوا بی‌صدا نامرئی می‌مونه. + """ + + def test_math_payload_key_matches_the_key_retrieval_filters_on(self): + from rag.services.ingest import _payload + from rag.services.known_collections import filter_key_for_subject + + chunk = LessonChunk( + lesson_id=1, grade_id=1, subject='ریاضی', lesson_number=2, chapter=2, + chapter_title='فصل ۲: کسر', order=1, title='کسر', + original_text='x', summary='x', collection='ریاضی ششم', + ) + payload = _payload(chunk) + + # کلیدی که ریاضی روش فیلتر می‌شه باید دقیقاً در payload باشه، با همون مقدار. + self.assertEqual(filter_key_for_subject('ریاضی'), 'chapter') + self.assertIn('chapter', payload) + self.assertEqual(payload['chapter'], 'فصل ۲: کسر') + + def test_text_subject_payload_keeps_lesson_number_filter(self): + from rag.services.ingest import _payload + from rag.services.known_collections import filter_key_for_subject + + chunk = LessonChunk( + lesson_id=1, grade_id=1, subject='علوم تجربی', lesson_number=3, chapter=None, + order=1, title='آب', original_text='x', summary='x', collection='علوم تجربی سوم', + ) + payload = _payload(chunk) + + self.assertEqual(filter_key_for_subject('علوم تجربی'), 'lesson_number') + self.assertEqual(payload['lesson_number'], 3) + # فصل نداره، پس نباید کلیدِ خالیِ chapter بنویسه. + self.assertNotIn('chapter', payload) + + def test_structured_fields_survive_into_payload(self): + """ + استخراجِ vision گرونه؛ اگه ساختارش فقط به متنِ صاف تبدیل بشه، برای + ساختِ سؤال‌های مسیرِ درس باید دوباره همون هزینه داده بشه. + """ + from rag.services.ingest import _payload + + chunk = LessonChunk( + lesson_id=1, grade_id=1, subject='ریاضی', lesson_number=2, chapter=2, + chapter_title='فصل ۲: کسر', grade_title='ششم دبستان', + problem_type='مقایسه کسرها', keywords=['کسر', 'مقایسه'], + general_solution='مخرج مشترک بگیرید.', + problem='کدام کسر بزرگ‌تر است؟', solution='۳/۴ بزرگ‌تر است.', + order=1, title='کسر', original_text='x', summary='x', collection='ریاضی ششم', + ) + payload = _payload(chunk) + + self.assertEqual(payload['type'], 'مقایسه کسرها') + self.assertEqual(payload['keywords'], ['کسر', 'مقایسه']) + self.assertEqual(payload['generalSolution'], 'مخرج مشترک بگیرید.') + self.assertEqual(payload['problem'], 'کدام کسر بزرگ‌تر است؟') + self.assertEqual(payload['solution'], '۳/۴ بزرگ‌تر است.') + self.assertEqual(payload['grade'], 'ششم دبستان') + + def test_payload_text_reads_what_ingest_writes(self): + """پلِ نهایی: متنی که retrieval از payload درمی‌آره نباید خالی باشه.""" + from rag.services.ingest import _payload + from rag.services.retrieval import _payload_text + + chunk = LessonChunk( + lesson_id=1, grade_id=1, subject='ریاضی', lesson_number=2, chapter=2, + chapter_title='فصل ۲: کسر', general_solution='مخرج مشترک بگیرید.', + order=1, title='کسر', original_text='متنِ اصلی', summary='خلاصه‌ی تکه', + collection='ریاضی ششم', + ) + self.assertTrue(_payload_text(_payload(chunk))) + + class RagConfigTests(TestCase): def test_rag_disabled_without_keys(self): from rag.services.config import rag_is_configured