ingest whole textbooks chapter by chapter, resumably

نقشه‌ی فیزیکیِ کتاب‌ها (افستِ صفحه، بازه‌ی هر فصل، درس‌های داخلِ فصل از
فهرستِ خودِ کتاب) در rag/services/textbooks.py ثبت شد تا عنوانِ فصل از
کتاب بیاید نه از حدسِ مدل — رشته‌ی فصل باید دقیقاً بینِ ingest و retrieval
یکی باشد وگرنه نتیجه بی‌سروصدا خالی برمی‌گردد.

rag_ingest_book کتاب را فصل‌به‌فصل ingest می‌کند و قابلِ ازسرگیری است:
rag.IngestedPage نشانه‌ی «این صفحه پردازش شد» را نگه می‌دارد حتی وقتی
صفحه chunkی نساخته، وگرنه هر بار اجرای دوباره بابتِ صفحه‌های بی‌مسئله
دوباره پول می‌گرفت. --plan هزینه را رایگان برآورد می‌کند.

rag_import_collection نقاطِ موجودِ Qdrant را در LessonChunk می‌ریزد —
فقط می‌خواند، رایگان و idempotent، برای پایه‌هایی که تیمِ دیگر ingest
کرده بود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 18:59:03 +03:30
parent 52d9694583
commit c79be0a336
11 changed files with 1139 additions and 35 deletions

View File

@@ -0,0 +1,193 @@
"""
واردکردنِ نقاطِ یک collectionِ موجودِ Qdrant به جدولِ محلیِ LessonChunk.
چرا: پایه‌های ششم و هفتم را تیمِ دیگر قبلاً ingest کرده و روی Qdrant هستند،
ولی سمتِ ما هیچ ردیفی از آن‌ها نداریم — و `seed_curriculum_from_textbook`
برنامه‌ی درسی را از روی LessonChunkهای محلی می‌سازد. این فرمان آن شکاف را پر
می‌کند.
**خواندن از Qdrant رایگان است** (هیچ فراخوانیِ OpenAI ندارد)، پس این مسیر
حتی وقتی اعتبارِ API صفر است هم کار می‌کند.
فقط می‌خوانَد: هیچ نقطه‌ای در Qdrant نوشته یا عوض نمی‌شود. `qdrant_point_id`
همان شناسه‌ی واقعیِ نقطه ذخیره می‌شود، پس اجرای دوباره ردیفِ تکراری نمی‌سازد.
python manage.py rag_import_collection --collection "ریاضی ششم" --dry-run
python manage.py rag_import_collection --collection "ریاضی ششم"
"""
from django.core.management.base import BaseCommand, CommandError
from rag.models import LessonChunk
from rag.services import clients
from rag.services.known_collections import MATH_CHAPTERS
from rag.services.pdf import problem_type_to_text
class Command(BaseCommand):
help = 'واردکردنِ نقاطِ یک collectionِ Qdrant به LessonChunk (فقط خواندن، بدونِ هزینه).'
def add_arguments(self, parser):
parser.add_argument('--collection', required=True)
parser.add_argument('--subject', default='',
help='پیش‌فرض از نامِ collection حدس زده می‌شود.')
parser.add_argument('--batch', type=int, default=256)
parser.add_argument('--dry-run', action='store_true')
@staticmethod
def _split(collection, subject):
"""«علوم تجربی سوم» → ('علوم تجربی', 'سوم'). آخرین کلمه پایه است."""
if subject and collection.startswith(subject):
return subject, collection[len(subject):].strip()
parts = collection.rsplit(' ', 1)
return (parts[0], parts[1]) if len(parts) == 2 else (collection, '')
def handle(self, *args, **opts):
collection = opts['collection']
subject = opts['subject']
try:
client = clients.get_qdrant_client()
existing = {c.name for c in client.get_collections().collections}
except Exception as exc: # noqa: BLE001
raise CommandError(f'اتصال به Qdrant شکست خورد: {exc}') from exc
if collection not in existing:
raise CommandError(
f'collection «{collection}» وجود ندارد. موجود: ' + '، '.join(sorted(existing))
)
# «ریاضی ششم» → («ریاضی», «ششم») برای پیدا کردنِ جدولِ فصل‌ها.
subject, grade = self._split(collection, subject)
chapter_numbers = {
title: number
for (subj, grd), table in MATH_CHAPTERS.items()
if subj == subject and grd == grade
for number, title in table.items()
}
if not chapter_numbers:
self.stdout.write(self.style.NOTICE(
f'«{subject} {grade}» جدولِ فصلِ ریاضی ندارد — اگر درسِ متنی است '
'طبیعی‌ست و از lesson_number استفاده می‌شود.'
))
known_ids = set(
LessonChunk.objects.filter(collection=collection)
.values_list('qdrant_point_id', flat=True)
)
known = {str(k) for k in known_ids}
rows, skipped, unmapped = [], 0, set()
offset = None
while True:
points, offset = client.scroll(
collection_name=collection, limit=opts['batch'], offset=offset,
with_payload=True, with_vectors=False,
)
for point in points:
payload = point.payload or {}
# دو شکلِ payload روی همین سرور هست:
# ریاضی → chapter (رشته‌ی کاملِ فصل) + type/generalSolution/…
# درسِ متنی → lesson_number (عدد) + lesson_title + summary
# گروه‌بندی برای هر کدام از کلیدِ خودش می‌آید.
chapter_title = (payload.get('chapter') or '').strip()
if chapter_title and chapter_title not in chapter_numbers:
unmapped.add(chapter_title)
if str(point.id) in known:
skipped += 1
continue
rows.append((point.id, payload, chapter_title))
if offset is None:
break
self.stdout.write(self.style.MIGRATE_HEADING(
f'\n«{collection}» — {len(rows)} نقطه‌ی تازه، {skipped} از قبل موجود\n'
))
if unmapped:
self.stdout.write(self.style.WARNING(
'عنوانِ فصلِ ناشناخته (شماره نمی‌گیرد): ' + '، '.join(sorted(unmapped))
))
if opts['dry_run']:
self.stdout.write(self.style.WARNING('--dry-run: چیزی نوشته نشد.'))
return
if not rows:
self.stdout.write('چیزی برای واردکردن نیست.')
return
def group_number(payload, chapter_title):
"""شماره‌ی گروه: برای ریاضی شماره‌ی فصل، برای درسِ متنی شماره‌ی درس."""
if chapter_title:
return chapter_numbers.get(chapter_title)
try:
return int(payload.get('lesson_number'))
except (TypeError, ValueError):
return None
# order در هر گروه ادامه‌دار می‌ماند تا اجرای دوباره شماره‌ها را از نو
# نزند. کلیدِ گروه همان چیزی‌ست که در دیتابیس هم رویش فیلتر می‌کنیم.
orders: dict[tuple[str, int], int] = {}
for _pid, payload, chapter_title in rows:
key = (chapter_title, group_number(payload, chapter_title) or 0)
if key not in orders:
orders[key] = LessonChunk.objects.filter(
collection=collection, chapter_title=key[0], lesson_number=key[1],
).count()
objects = []
for point_id, payload, chapter_title in rows:
is_math = bool(payload.get('generalSolution') or payload.get('problem'))
number = group_number(payload, chapter_title)
key = (chapter_title, number or 0)
orders[key] = orders.get(key, 0) + 1
if is_math:
text = problem_type_to_text({
'type': payload.get('type', ''),
'keywords': payload.get('keywords', []),
'generalSolution': payload.get('generalSolution', ''),
'exampleFromImage': {
'problem': payload.get('problem', ''),
'solution': payload.get('solution', ''),
},
})
title = chapter_title or subject
summary = text
else:
# درسِ متنی: گروه‌بندی با lesson_number، محتوا در summary.
text = summary = (payload.get('summary') or '').strip()
title = (payload.get('lesson_title') or '').strip() or f'درس {number or "؟"}'
objects.append(LessonChunk(
lesson_id=None,
subject=subject,
lesson_number=number or 0,
chapter=number if is_math else None,
chapter_title=chapter_title,
grade_title=(payload.get('grade') or grade).strip(),
order=orders[key],
title=title,
original_text=text,
summary=summary,
# embedding عمداً ذخیره نمی‌شود: بردار همین حالا در Qdrant هست و
# گرفتنش هیچ سودی ندارد جز حجم. این ردیف‌ها فقط برای ساختنِ
# برنامه‌ی درسی و بازبینی‌اند، نه برای بازسازیِ collection.
embedding_vector=None,
problem_type=(payload.get('type') or '').strip(),
keywords=payload.get('keywords') or [],
general_solution=(payload.get('generalSolution') or '').strip(),
problem=(payload.get('problem') or '').strip(),
solution=(payload.get('solution') or '').strip(),
collection=collection,
qdrant_point_id=point_id,
source_page=None, # دادهٔ تیمِ دیگر شماره‌ی صفحه ندارد
))
LessonChunk.objects.bulk_create(objects, batch_size=200)
self.stdout.write(self.style.SUCCESS(f'{len(objects)} ردیف ساخته شد.'))
if subject == 'ریاضی':
self.stdout.write(self.style.HTTP_INFO(
f'قدمِ بعد: python manage.py seed_curriculum_from_textbook --grade {grade}'
))
else:
self.stdout.write(self.style.HTTP_INFO(
'این درسِ متنی است و در textbooks.py ثبت نشده، پس برنامه‌ی درسی '
'برایش ساخته نمی‌شود؛ فقط chunkها برای RAG و بازبینی وارد شدند.'
))