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:
193
rag/management/commands/rag_import_collection.py
Normal file
193
rag/management/commands/rag_import_collection.py
Normal 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 و بازبینی وارد شدند.'
|
||||
))
|
||||
142
rag/management/commands/rag_ingest_book.py
Normal file
142
rag/management/commands/rag_ingest_book.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
ingestِ یک کتابِ درسیِ کامل از data/textbooks/ به Qdrant.
|
||||
|
||||
نمونه:
|
||||
python manage.py rag_ingest_book --subject ریاضی --grade نهم --plan
|
||||
python manage.py rag_ingest_book --subject ریاضی --grade نهم --chapters 1 --max-pages 3
|
||||
python manage.py rag_ingest_book --subject ریاضی --grade نهم
|
||||
|
||||
هر اجرا از جایی که مانده ادامه میدهد (صفحههای ثبتشده رد میشوند)، پس
|
||||
قطع کردنش با Ctrl-C بیخطر است و اجرای دوباره کارِ رفته را دوباره نمیخرد.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from rag.services.clients import RagNotConfigured
|
||||
from rag.services.config import build_collection_name, rag_is_configured
|
||||
from rag.services.ingest import _done_pages, estimate_page_cost, ingest_book
|
||||
from rag.services.textbooks import TEXTBOOKS, get_textbook
|
||||
from rag.models import IngestedPage, LessonChunk
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'ingestِ کتابِ درسی (PDF) به LessonChunk + Qdrant، فصلبهفصل و قابلِ ازسرگیری.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--subject', default='ریاضی')
|
||||
parser.add_argument('--grade', required=True, help='مثلاً: نهم')
|
||||
parser.add_argument('--chapters', nargs='*', type=int, default=None,
|
||||
help='شمارهی فصلها؛ خالی یعنی همه.')
|
||||
parser.add_argument('--max-pages', type=int, default=None,
|
||||
help='سقفِ صفحه در این اجرا (کنترلِ هزینه).')
|
||||
parser.add_argument('--dpi', type=int, default=200)
|
||||
parser.add_argument('--no-resume', action='store_true',
|
||||
help='صفحههای قبلاً ثبتشده را هم دوباره پردازش کن (نقطهی تکراری میسازد).')
|
||||
parser.add_argument('--plan', action='store_true',
|
||||
help='فقط نشان بده چه چیزی پردازش میشود؛ هیچ فراخوانیِ پولی نکن.')
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
subject, grade = opts['subject'], opts['grade']
|
||||
book = get_textbook(subject, grade)
|
||||
if book is None:
|
||||
known = '، '.join(sorted(TEXTBOOKS)) or '—'
|
||||
raise CommandError(f'کتابی برای «{subject} {grade}» ثبت نشده. موجود: {known}')
|
||||
if not book.filename:
|
||||
raise CommandError(
|
||||
f'«{book.key}» فایلِ PDF ندارد؛ دادهٔ واقعیاش از قبل روی Qdrant است.\n'
|
||||
f'برای واردکردنش: python manage.py rag_import_collection '
|
||||
f'--collection "{book.key}"'
|
||||
)
|
||||
|
||||
collection = build_collection_name(book.subject, book.grade)
|
||||
wanted = list(book.chapters) if not opts['chapters'] else [
|
||||
c for n in opts['chapters'] if (c := book.chapter(n)) is not None
|
||||
]
|
||||
if not wanted:
|
||||
raise CommandError('هیچ فصلی با شمارههای دادهشده پیدا نشد.')
|
||||
|
||||
if opts['plan']:
|
||||
return self._plan(book, collection, wanted)
|
||||
|
||||
if not rag_is_configured():
|
||||
raise CommandError(
|
||||
'RAG پیکربندی نشده (OPENAI_API_KEY / QDRANT_API_KEY خالی است یا RAG_FORCE_MOCK روشن است).'
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||
f'ingest «{book.key}» → collection «{collection}»'
|
||||
))
|
||||
try:
|
||||
result = ingest_book(
|
||||
subject=book.subject, grade=book.grade,
|
||||
chapters=[c.number for c in wanted],
|
||||
dpi=opts['dpi'], max_pages=opts['max_pages'],
|
||||
resume=not opts['no_resume'], progress=self._progress,
|
||||
)
|
||||
except (RagNotConfigured, FileNotFoundError, ValueError) as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
|
||||
self.stdout.write('')
|
||||
style = self.style.SUCCESS if result['status'] == 'success' else self.style.WARNING
|
||||
self.stdout.write(style(
|
||||
f"وضعیت: {result['status']} — {result['pages_processed']} صفحه پردازش شد، "
|
||||
f"{result['pages_skipped']} صفحه از قبل موجود بود، "
|
||||
f"{result['chunks_created']} chunk ساخته شد."
|
||||
))
|
||||
self.stdout.write(
|
||||
f"مصرف: {result['prompt_tokens']:,} توکن ورودی + "
|
||||
f"{result['completion_tokens']:,} خروجی → ≈ ${result['cost_usd']:.2f} "
|
||||
f"({result['model']})"
|
||||
)
|
||||
if result['failed_pages']:
|
||||
self.stdout.write(self.style.ERROR(
|
||||
'صفحههای خطادار (اجرای دوباره سراغشان میرود): '
|
||||
+ '، '.join(str(p) for p in result['failed_pages'])
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _plan(self, book, collection, wanted):
|
||||
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||
f'نقشهی «{book.key}» → collection «{collection}» (فایل: {book.filename})'
|
||||
))
|
||||
total_pages = total_left = 0
|
||||
for chapter in wanted:
|
||||
first, last = book.pdf_pages(chapter)
|
||||
pages = last - first + 1
|
||||
done = len(_done_pages(collection, chapter.title))
|
||||
left = max(pages - done, 0)
|
||||
total_pages += pages
|
||||
total_left += left
|
||||
self.stdout.write(
|
||||
f' فصل {chapter.number}: {chapter.title}\n'
|
||||
f' صفحهی کتاب {chapter.first_page}–{chapter.last_page}'
|
||||
f' → صفحهی PDF {first}–{last} ({pages} صفحه، {done} انجامشده، {left} مانده)'
|
||||
)
|
||||
self.stdout.write('')
|
||||
self.stdout.write(f'جمع: {total_pages} صفحه، {total_left} صفحه مانده.')
|
||||
from django.conf import settings
|
||||
|
||||
model = settings.OPENAI_VISION_MODEL
|
||||
per_page = estimate_page_cost(model)
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'هر صفحه یک فراخوانیِ vision دارد ({model}، ~${per_page:.4f} در صفحه، '
|
||||
f'اندازهگیریشده روی ۱۱۱ صفحهی واقعی): '
|
||||
f'حدود ${total_left * per_page:.2f} برای صفحههای مانده '
|
||||
f'(با Batch API نصف: ${total_left * per_page / 2:.2f}).'
|
||||
))
|
||||
|
||||
def _progress(self, event):
|
||||
kind = event.get('kind')
|
||||
if kind == 'chapter':
|
||||
first, last = event['pages']
|
||||
self.stdout.write(self.style.HTTP_INFO(
|
||||
f"\n▸ فصل {event['chapter']}: {event['title']} "
|
||||
f"(PDF {first}–{last}، {event['already']} صفحه از قبل موجود)"
|
||||
))
|
||||
elif kind == 'page':
|
||||
self.stdout.write(f" صفحه {event['page']}: {event['chunks']} chunk")
|
||||
elif kind == 'page_failed':
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f" صفحه {event['page']}: خطا — {event['error'][:120]}"
|
||||
))
|
||||
elif kind == 'budget_reached':
|
||||
self.stdout.write(self.style.WARNING(' سقفِ --max-pages پر شد؛ توقف.'))
|
||||
Reference in New Issue
Block a user