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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,3 +4,7 @@ __pycache__/
|
|||||||
db.sqlite3
|
db.sqlite3
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# کتابهای درسی: خودِ PDFها در گیت نمیآیند (حجیماند)
|
||||||
|
data/textbooks/*
|
||||||
|
!data/textbooks/README.md
|
||||||
|
|||||||
@@ -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
26
data/textbooks/README.md
Normal 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ی که تیم دیگر دارد.
|
||||||
48
rag/migrations/0002_lessonchunk_chapter_title_and_more.py
Normal file
48
rag/migrations/0002_lessonchunk_chapter_title_and_more.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
78
rag/tests.py
78
rag/tests.py
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user