Files
MathApp/rag/management/commands/rag_import_collection.py
Zareian c79be0a336 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>
2026-09-13 18:59:03 +03:30

194 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
واردکردنِ نقاطِ یک 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 و بازبینی وارد شدند.'
))