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

View File

@@ -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