نقشهی فیزیکیِ کتابها (افستِ صفحه، بازهی هر فصل، درسهای داخلِ فصل از فهرستِ خودِ کتاب) در 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>
194 lines
9.7 KiB
Python
194 lines
9.7 KiB
Python
"""
|
||
واردکردنِ نقاطِ یک 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 و بازبینی وارد شدند.'
|
||
))
|