keep ingested chunks findable and structured

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 16:24:18 +03:30
parent f0e3e3c96c
commit 52d9694583
7 changed files with 235 additions and 3 deletions

4
.gitignore vendored
View File

@@ -4,3 +4,7 @@ __pycache__/
db.sqlite3 db.sqlite3
.DS_Store .DS_Store
.env .env
# کتاب‌های درسی: خودِ PDFها در گیت نمی‌آیند (حجیم‌اند)
data/textbooks/*
!data/textbooks/README.md

View File

@@ -227,7 +227,14 @@ STORAGES = {
'BACKEND': 'django.core.files.storage.FileSystemStorage', 'BACKEND': 'django.core.files.storage.FileSystemStorage',
}, },
'staticfiles': { '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 نیست. # با DEBUG=1، از finders مستقیم سرو می‌کنه؛ نیازی به collectstatic نیست.

26
data/textbooks/README.md Normal file
View File

@@ -0,0 +1,26 @@
# کتاب‌های درسی (PDF)
فایل‌های PDF کتاب‌های درسی را در همین پوشه بگذارید. خودِ PDFها در گیت
ذخیره نمی‌شوند (حجمشان زیاد است) — فقط همین فایل راهنما ردیابی می‌شود.
## منبع
نسخه‌ی رسمی و به‌روزِ کتاب‌ها: <https://irtextbook.ir>
(از خارج ایران در دسترس نیست؛ دانلود باید از داخل ایران انجام شود.)
## نام‌گذاری پیشنهادی
math-grade1.pdf ریاضی اول دبستان
math-grade2.pdf ریاضی دوم دبستان
math-grade4.pdf ریاضی چهارم دبستان
math-grade5.pdf ریاضی پنجم دبستان
پایه‌های سوم و ششم لازم نیستند: محتوایشان از قبل روی Qdrant موجود است
(collectionهای «ریاضی سوم» و «ریاضی ششم» ساخته‌ی تیم دیگر).
## بعدش چه اتفاقی می‌افتد
هر صفحه به تصویر تبدیل می‌شود، مدل vision انواع مسئله را استخراج می‌کند
(`type` / `keywords` / `generalSolution` / `problem` / `solution`)، نتیجه هم در
جدول `rag.LessonChunk` (منبع حقیقت، پیش خودمان) ذخیره می‌شود و هم به‌صورت
نقطه در Qdrant ایندکس می‌شود — با همان قرارداد payloadی که تیم دیگر دارد.

View File

@@ -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),
),
]

View File

@@ -24,12 +24,28 @@ class LessonChunk(models.Model):
lesson_number = models.IntegerField() lesson_number = models.IntegerField()
chapter = models.IntegerField(null=True, blank=True) 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() order = models.PositiveIntegerField()
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
original_text = models.TextField() original_text = models.TextField()
summary = models.TextField() summary = models.TextField()
embedding_vector = models.JSONField(null=True, blank=True) 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) collection = models.CharField(max_length=200, db_index=True)
qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False) qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False)

View File

@@ -15,6 +15,7 @@ from ..models import LessonChunk
from .chunking import chunk_and_summarize from .chunking import chunk_and_summarize
from .config import build_collection_name, resolve_grade_title from .config import build_collection_name, resolve_grade_title
from .embedding import embed_text from .embedding import embed_text
from .known_collections import is_math_subject, resolve_math_chapter
from . import pdf as pdf_service from . import pdf as pdf_service
from .qdrant_store import ensure_collection, upsert_points from .qdrant_store import ensure_collection, upsert_points
@@ -22,7 +23,16 @@ logger = logging.getLogger(__name__)
def _payload(chunk: LessonChunk) -> dict: def _payload(chunk: LessonChunk) -> dict:
return { """
payloadِ نقطه‌ی Qdrant.
⚠️ کلیدِ `chapter` (رشته‌ی کاملِ فصل) حتماً باید نوشته بشه: جستجوی ریاضی در
retrieval.py دقیقاً روی همین کلید فیلتر می‌کنه (نه `chapter_number`)، پس
بدونش هر چیزی که ingest کنیم برای همیشه نامرئی می‌مونه — بدونِ هیچ خطایی.
بقیه‌ی کلیدهای ساختاری هم عمداً هم‌نامِ قراردادِ تیمِ دیگه‌ان تا collectionِ
ما و اونا یک شکل بمونن.
"""
payload = {
'lesson_id': chunk.lesson_id, 'lesson_id': chunk.lesson_id,
'lesson_number': chunk.lesson_number, 'lesson_number': chunk.lesson_number,
'chapter_number': chunk.chapter, 'chapter_number': chunk.chapter,
@@ -30,6 +40,18 @@ def _payload(chunk: LessonChunk) -> dict:
'lesson_title': chunk.title, 'lesson_title': chunk.title,
'summary': chunk.summary, '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, 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'], summary=row['summary'],
embedding_vector=vector, embedding_vector=vector,
collection=collection, collection=collection,
# فیلدهای ساختاری فقط از مسیرِ PDF/vision می‌آن؛ ingest_text
# اون‌ها رو نداره و خالی می‌مونن (payload هم اون‌وقت نمی‌نویسدشون).
**row.get('structured', {}),
) )
created.append(chunk) created.append(chunk)
points.append({'id': chunk.qdrant_point_id, 'vector': vector, 'payload': _payload(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: if not rows:
return {'status': 'empty', 'chunks_created': 0, 'collection': collection} 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( created = _persist_and_upsert(
rows, lesson_id=lesson_id, grade_id=grade_id, subject=subject, rows, lesson_id=lesson_id, grade_id=grade_id, subject=subject,
lesson_number=lesson_number, chapter=chapter, title=title, collection=collection, 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: except Exception:
logger.exception('rag.ingest_pdf: تحلیلِ یک صفحه شکست خورد؛ رد شد') logger.exception('rag.ingest_pdf: تحلیلِ یک صفحه شکست خورد؛ رد شد')
continue 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 []: for problem_type in parsed.get('problemTypes', []) or []:
text = pdf_service.problem_type_to_text(problem_type) text = pdf_service.problem_type_to_text(problem_type)
if not text: if not text:
continue continue
order += 1 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: finally:
try: try:
os.remove(tmp_path) os.remove(tmp_path)

View File

@@ -160,6 +160,84 @@ class IngestTextTests(TestCase):
self.assertIn(resp.status_code, (401, 403)) 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): class RagConfigTests(TestCase):
def test_rag_disabled_without_keys(self): def test_rag_disabled_without_keys(self):
from rag.services.config import rag_is_configured from rag.services.config import rag_is_configured