From c79be0a336147cd11d07e354380fc49202769cce Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 13 Sep 2026 18:59:03 +0330 Subject: [PATCH] ingest whole textbooks chapter by chapter, resumably MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit نقشه‌ی فیزیکیِ کتاب‌ها (افستِ صفحه، بازه‌ی هر فصل، درس‌های داخلِ فصل از فهرستِ خودِ کتاب) در 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 --- data/textbooks/README.md | 51 ++- .../commands/rag_import_collection.py | 193 +++++++++++ rag/management/commands/rag_ingest_book.py | 142 ++++++++ ...ge_alter_lessonchunk_lesson_id_and_more.py | 27 ++ rag/migrations/0004_ingestedpage.py | 28 ++ rag/models.py | 39 ++- rag/services/config.py | 13 +- rag/services/ingest.py | 300 ++++++++++++++++- rag/services/known_collections.py | 13 +- rag/services/pdf.py | 66 +++- rag/services/textbooks.py | 302 ++++++++++++++++++ 11 files changed, 1139 insertions(+), 35 deletions(-) create mode 100644 rag/management/commands/rag_import_collection.py create mode 100644 rag/management/commands/rag_ingest_book.py create mode 100644 rag/migrations/0003_lessonchunk_source_page_alter_lessonchunk_lesson_id_and_more.py create mode 100644 rag/migrations/0004_ingestedpage.py create mode 100644 rag/services/textbooks.py diff --git a/data/textbooks/README.md b/data/textbooks/README.md index 8870120..93a3c23 100644 --- a/data/textbooks/README.md +++ b/data/textbooks/README.md @@ -8,15 +8,52 @@ نسخه‌ی رسمی و به‌روزِ کتاب‌ها: (از خارج ایران در دسترس نیست؛ دانلود باید از داخل ایران انجام شود.) -## نام‌گذاری پیشنهادی +## اولویت (به‌روزشده ۱۴۰۵/۰۶/۲۱) - math-grade1.pdf ریاضی اول دبستان - math-grade2.pdf ریاضی دوم دبستان - math-grade4.pdf ریاضی چهارم دبستان - math-grade5.pdf ریاضی پنجم دبستان +سینیور اولویت را عوض کرد: **از پایه ششم به بالا**، با تمرکز روی پایه‌های +ترانزیشنی ۶، ۷، ۹ و ۱۰. کتاب‌های ابتدایی (اول/دوم/چهارم/پنجم) که قبلاً +دانلود شده‌اند فعلاً در نوبت نیستند. -پایه‌های سوم و ششم لازم نیستند: محتوایشان از قبل روی Qdrant موجود است -(collectionهای «ریاضی سوم» و «ریاضی ششم» ساخته‌ی تیم دیگر). +| پایه | وضعیت | +|---|---| +| ششم، هفتم | از قبل روی Qdrant موجود است (ساخته‌ی تیم دیگر) | +| نهم، دهم | PDF موجود است؛ ingest با `rag_ingest_book` | +| هشتم | PDF موجود است، هنوز در `textbooks.py` ثبت نشده | +| اول، دوم، چهارم، پنجم | PDF موجود است، خارج از اولویتِ فعلی | + +## نام‌گذاری + +نامِ فایل باید دقیقاً با `filename` در `rag/services/textbooks.py` یکی باشد، +مثلاً `ریاضی نهم.pdf`. + +## چطور ingest کنیم + +```bash +# اول ببینید چه چیزی قرار است پردازش شود (رایگان، هیچ فراخوانیِ مدلی ندارد) +python manage.py rag_ingest_book --grade نهم --plan + +# یک فصل، با سقفِ صفحه برای کنترلِ هزینه +python manage.py rag_ingest_book --grade نهم --chapters 1 --max-pages 5 + +# کلِ کتاب (از جایی که مانده ادامه می‌دهد) +python manage.py rag_ingest_book --grade نهم +``` + +هر اجرا صفحه‌هایی را که قبلاً chunk ساخته‌اند رد می‌کند، پس قطع کردنش با +Ctrl-C بی‌خطر است و اجرای دوباره هزینه‌ی رفته را دوباره نمی‌پردازد. + +## افزودنِ کتابِ جدید + +در `rag/services/textbooks.py` یک `Textbook` بسازید با: + +- `page_offset` — اختلافِ شماره‌ی صفحه‌ی چاپ‌شده با شماره‌ی صفحه در PDF + (جلد و فهرست جلوترند). از روی فهرستِ کتاب و بازبینیِ یک صفحه‌ی مرزی درمی‌آید. +- `chapters` — بازه‌ی صفحه‌ی هر فصل بر اساسِ شماره‌ی چاپ‌شده. بازه‌ها باید + پشتِ‌هم و بدونِ حفره باشند (تستِ `TextbookLayoutTests` همین را چک می‌کند). +- `grade_label` — همان رشته‌ای که در payloadِ `grade` می‌نشیند. + +عنوانِ فصل‌ها خودکار در `known_collections.MATH_CHAPTERS` ثبت می‌شود، پس +جستجو بلافاصله می‌تواند شماره‌ی فصل را به رشته‌ی دقیق تبدیل کند. ## بعدش چه اتفاقی می‌افتد diff --git a/rag/management/commands/rag_import_collection.py b/rag/management/commands/rag_import_collection.py new file mode 100644 index 0000000..707fa90 --- /dev/null +++ b/rag/management/commands/rag_import_collection.py @@ -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 و بازبینی وارد شدند.' + )) diff --git a/rag/management/commands/rag_ingest_book.py b/rag/management/commands/rag_ingest_book.py new file mode 100644 index 0000000..1a75b7f --- /dev/null +++ b/rag/management/commands/rag_ingest_book.py @@ -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 پر شد؛ توقف.')) diff --git a/rag/migrations/0003_lessonchunk_source_page_alter_lessonchunk_lesson_id_and_more.py b/rag/migrations/0003_lessonchunk_source_page_alter_lessonchunk_lesson_id_and_more.py new file mode 100644 index 0000000..f8ed386 --- /dev/null +++ b/rag/migrations/0003_lessonchunk_source_page_alter_lessonchunk_lesson_id_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.17 on 2026-09-12 14:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rag', '0002_lessonchunk_chapter_title_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='lessonchunk', + name='source_page', + field=models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + migrations.AlterField( + model_name='lessonchunk', + name='lesson_id', + field=models.IntegerField(blank=True, db_index=True, null=True), + ), + migrations.AddIndex( + model_name='lessonchunk', + index=models.Index(fields=['collection', 'chapter_title', 'source_page'], name='rag_lessonc_collect_6f5d51_idx'), + ), + ] diff --git a/rag/migrations/0004_ingestedpage.py b/rag/migrations/0004_ingestedpage.py new file mode 100644 index 0000000..0591136 --- /dev/null +++ b/rag/migrations/0004_ingestedpage.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.17 on 2026-09-12 14:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rag', '0003_lessonchunk_source_page_alter_lessonchunk_lesson_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='IngestedPage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('collection', models.CharField(db_index=True, max_length=200)), + ('chapter_title', models.CharField(max_length=255)), + ('page', models.PositiveIntegerField()), + ('chunk_count', models.PositiveSmallIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['collection', 'page'], + 'constraints': [models.UniqueConstraint(fields=('collection', 'page'), name='uniq_ingested_page_per_collection')], + }, + ), + ] diff --git a/rag/models.py b/rag/models.py index 0573b21..f93ab4c 100644 --- a/rag/models.py +++ b/rag/models.py @@ -18,7 +18,9 @@ class LessonChunk(models.Model): دوباره‌ی embedding قابلِ بازسازی باشه. """ - lesson_id = models.IntegerField(db_index=True) # ارجاع سبک به content.Lesson + # ارجاع سبک به content.Lesson. برای ingestِ کتابِ کامل خالی می‌ماند: آن + # مسیر فصل‌به‌فصل کار می‌کند و هنوز رکوردِ Lessonی برایش وجود ندارد. + lesson_id = models.IntegerField(db_index=True, null=True, blank=True) grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade subject = models.CharField(max_length=100) lesson_number = models.IntegerField() @@ -46,6 +48,11 @@ class LessonChunk(models.Model): problem = models.TextField(blank=True) solution = models.TextField(blank=True) + # شماره‌ی صفحه‌ی PDF که این chunk از آن درآمده. ingestِ کتاب با همین فیلد + # می‌فهمد کدام صفحه‌ها قبلاً پردازش شده‌اند و از سر می‌گیرد؛ بدونش هر + # اجرای دوباره پس از قطعی، نقاطِ تکراری در Qdrant می‌ساخت. + source_page = models.PositiveIntegerField(null=True, blank=True, db_index=True) + collection = models.CharField(max_length=200, db_index=True) qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False) @@ -61,6 +68,7 @@ class LessonChunk(models.Model): ] indexes = [ models.Index(fields=['subject', 'lesson_number']), + models.Index(fields=['collection', 'chapter_title', 'source_page']), ] def __str__(self): @@ -77,3 +85,32 @@ class LessonChunkImage(models.Model): def __str__(self): return f"image-chunk{self.chunk_id}" + + +class IngestedPage(models.Model): + """ + نشانه‌ی «این صفحه‌ی کتاب پردازش شد». + + چرا جدا از LessonChunk: صفحه‌هایی که هیچ مسئله‌ای ندارند (جلد، فهرست، + سخنی با معلم، صفحه‌ی جداکننده‌ی فصل) هیچ chunkی نمی‌سازند. اگر ازسرگیری + فقط به chunkها نگاه کند، این صفحه‌ها در هر اجرای دوباره یک فراخوانیِ + vision دیگر خرج می‌کنند و هیچ‌وقت هم «تمام» نمی‌شوند. + """ + + collection = models.CharField(max_length=200, db_index=True) + chapter_title = models.CharField(max_length=255) + page = models.PositiveIntegerField() + chunk_count = models.PositiveSmallIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['collection', 'page'], + name='uniq_ingested_page_per_collection', + ), + ] + ordering = ['collection', 'page'] + + def __str__(self): + return f"{self.collection} p{self.page} ({self.chunk_count} chunk)" diff --git a/rag/services/config.py b/rag/services/config.py index bbaa7d3..841a194 100644 --- a/rag/services/config.py +++ b/rag/services/config.py @@ -19,7 +19,14 @@ def rag_is_configured() -> bool: def resolve_grade_title(grade_id, fallback: str = '') -> str: - """gradeId → عنوانِ گرید همان‌طور که در نامِ collectionِ تیمِ دیگه اومده.""" + """ + gradeId → عنوانِ گرید همان‌طور که در نامِ collectionِ تیمِ دیگه اومده. + + ⚠️ عنوانِ Grade در دیتابیسِ ما «پایه نهم» است ولی نامِ collection «ریاضی + نهم»؛ بدونِ نرمال‌سازی، build_collection_name رشته‌ی «ریاضی پایه نهم» را + می‌ساخت که وجود ندارد و کلِ retrieval از مسیرِ gradeId بی‌سروصدا خالی + برمی‌گشت. مسیرِ lessonId خودش نرمال می‌کرد، این یکی نه. + """ overrides = getattr(settings, 'RAG_GRADE_TITLE_OVERRIDES', {}) or {} if grade_id in overrides: return str(overrides[grade_id]) @@ -27,9 +34,11 @@ def resolve_grade_title(grade_id, fallback: str = '') -> str: try: from content.models import Grade + from .lesson_context import normalize_grade_title + grade = Grade.objects.filter(pk=grade_id).only('title').first() if grade is not None: - return grade.title + return normalize_grade_title(grade.title) except Exception: pass return fallback diff --git a/rag/services/ingest.py b/rag/services/ingest.py index a58909b..a46162b 100644 --- a/rag/services/ingest.py +++ b/rag/services/ingest.py @@ -8,19 +8,36 @@ DB (LessonChunk، منبعِ حقیقت) + upsert به Qdrant (index). import logging import os import tempfile +from pathlib import Path +from django.conf import settings from django.db import transaction -from ..models import LessonChunk +from ..models import IngestedPage, LessonChunk from .chunking import chunk_and_summarize from .config import build_collection_name, resolve_grade_title from .embedding import embed_text from .known_collections import is_math_subject, resolve_math_chapter from . import pdf as pdf_service from .qdrant_store import ensure_collection, upsert_points +from .textbooks import Textbook, get_textbook logger = logging.getLogger(__name__) +# قیمتِ هر یک میلیون توکن (دلار) برای گزارشِ هزینه‌ی ingest. اندازه‌گیریِ +# ۱۱۱ صفحه‌ی واقعیِ ریاضی نهم: ۱٬۴۲۵ توکن ورودی و ~۴۰۰ توکن خروجی در هر صفحه، +# یعنی حدود ۰٫۰۰۷۵ دلار — نه ۰٫۰۱۷ که اول تخمین زده بودیم. +MODEL_PRICE_PER_MTOK = { + 'gpt-4o': (2.50, 10.00), + 'gpt-4o-mini': (0.15, 0.60), +} + + +def estimate_page_cost(model: str) -> float: + """هزینه‌ی تقریبیِ یک صفحه بر پایه‌ی مصرفِ اندازه‌گیری‌شده‌ی واقعی.""" + price_in, price_out = MODEL_PRICE_PER_MTOK.get(model, (2.50, 10.00)) + return 1425 / 1e6 * price_in + 400 / 1e6 * price_out + def _payload(chunk: LessonChunk) -> dict: """ @@ -29,17 +46,44 @@ def _payload(chunk: LessonChunk) -> dict: ⚠️ کلیدِ `chapter` (رشته‌ی کاملِ فصل) حتماً باید نوشته بشه: جستجوی ریاضی در retrieval.py دقیقاً روی همین کلید فیلتر می‌کنه (نه `chapter_number`)، پس بدونش هر چیزی که ingest کنیم برای همیشه نامرئی می‌مونه — بدونِ هیچ خطایی. - بقیه‌ی کلیدهای ساختاری هم عمداً هم‌نامِ قراردادِ تیمِ دیگه‌ان تا collectionِ - ما و اونا یک شکل بمونن. + + دو شکلِ payload داریم و باید هر کدام دقیقاً مثلِ نقاطِ موجودِ همان نوع + بمانند (نقاطِ «ریاضی هفتم» و «علوم تجربی سوم» را مقایسه کنید): + + - ریاضی: grade / chapter / type / keywords / generalSolution / problem / + solution — و عمداً **بدونِ `summary`**. اگر summary بنویسیم، + `retrieval._payload_text` آن را به generalSolution ترجیح می‌دهد و + به‌جای راه‌حل، متنِ صافِ «Type: ... Keywords: ...» را به‌عنوانِ context + به مدل می‌دهیم — یعنی نقاطِ ما بی‌سروصدا بدتر از نقاطِ تیمِ دیگر جواب + می‌دهند. `lesson_title` هم به همین دلیل نمی‌آید: `_payload_title` + ترجیحش می‌دهد و عنوانِ عمومیِ فصل جایِ نامِ نوعِ مسئله را می‌گیرد. + - درس‌های متنی: lesson_number + summary، همان‌طور که بود. + + کلیدهای دفتری (chapter_number / chunk_order / source_page) در هر دو حالت + می‌آیند؛ روی فیلتر و متنِ بازیابی‌شده اثری ندارند و ردیابیِ «این نقطه از + کدام صفحه‌ی کتاب آمده» را ممکن می‌کنند. """ - payload = { - 'lesson_id': chunk.lesson_id, + # منشأ را از `problem_type` تشخیص می‌دهیم، نه از پر بودنِ راه‌حل: صفحه‌ای + # که vision فقط `type` و `keywords` از آن درآورده هم ردیفِ ساختاریافته است، + # ولی با شرطِ قبلی «متنی» حساب می‌شد و `summary` می‌گرفت — یعنی همان آلودگیِ + # «Type: … Keywords: …» که این تفکیک برای جلوگیری از آن ساخته شده بود. + is_structured = bool( + chunk.problem_type or chunk.general_solution or chunk.problem or chunk.solution + ) + + payload: dict = { 'lesson_number': chunk.lesson_number, 'chapter_number': chunk.chapter, 'chunk_order': chunk.order, - 'lesson_title': chunk.title, - 'summary': chunk.summary, } + if chunk.lesson_id is not None: + payload['lesson_id'] = chunk.lesson_id + if chunk.source_page is not None: + payload['source_page'] = chunk.source_page + if not is_structured: + payload['lesson_title'] = chunk.title + payload['summary'] = chunk.summary + for key, value in ( ('chapter', chunk.chapter_title), ('grade', chunk.grade_title), @@ -57,13 +101,16 @@ def _payload(chunk: LessonChunk) -> dict: def _persist_and_upsert(rows: list[dict], *, lesson_id, grade_id, subject, lesson_number, chapter, title, collection) -> list[LessonChunk]: ensure_collection(collection) - created: list[LessonChunk] = [] - points: list[dict] = [] + # embedding قبل از باز کردنِ transaction: هر فراخوانی چند صد میلی‌ثانیه + # شبکه است و نگه داشتنِ قفلِ نوشتنِ SQLite در آن مدت، بقیه‌ی درخواست‌ها را + # پشتِ «database is locked» می‌خواباند. + vectors = [embed_text(row['summary']) for row in rows] + + created: list[LessonChunk] = [] with transaction.atomic(): - for row in rows: - vector = embed_text(row['summary']) - chunk = LessonChunk.objects.create( + for row, vector in zip(rows, vectors): + created.append(LessonChunk.objects.create( lesson_id=lesson_id, grade_id=grade_id, subject=subject, @@ -75,14 +122,30 @@ def _persist_and_upsert(rows: list[dict], *, lesson_id, grade_id, subject, summary=row['summary'], embedding_vector=vector, collection=collection, + source_page=row.get('source_page'), # فیلدهای ساختاری فقط از مسیرِ PDF/vision می‌آن؛ ingest_text # اون‌ها رو نداره و خالی می‌مونن (payload هم اون‌وقت نمی‌نویسدشون). **row.get('structured', {}), - ) - created.append(chunk) - points.append({'id': chunk.qdrant_point_id, 'vector': vector, 'payload': _payload(chunk)}) + )) - upsert_points(collection, points) + # ⚠️ upsert بعد از commit است تا نقطه‌ای در Qdrant بدونِ ردیفِ متناظر در DB + # نماند. ولی اگر همین upsert شکست بخورد، ردیف‌ها commit شده‌اند و + # `_done_pages` آن صفحه را «انجام‌شده» می‌بیند — یعنی chunkها برای همیشه در + # DB می‌مانند و هیچ‌وقت به Qdrant نمی‌رسند، بی‌آنکه کسی بفهمد. پس در صورتِ + # خطا ردیف‌های همین دسته را پس می‌گیریم تا صفحه واقعاً ناتمام بماند و اجرای + # بعدی دوباره سراغش برود. + try: + upsert_points(collection, [ + {'id': c.qdrant_point_id, 'vector': v, 'payload': _payload(c)} + for c, v in zip(created, vectors) + ]) + except Exception: + LessonChunk.objects.filter(pk__in=[c.pk for c in created]).delete() + logger.exception( + 'rag: upsert به collection %r شکست خورد؛ %d ردیفِ همین دسته پس گرفته شد', + collection, len(created), + ) + raise return created @@ -189,3 +252,208 @@ def ingest_pdf(*, lesson_id: int, grade_id, subject: str, lesson_number: int, 'chunks_created': len(created), 'chunk_ids': [c.id for c in created], } + + +# ─── ingestِ کتابِ کامل ──────────────────────────────────────────────────── +# +# `ingest_pdf` بالا یک PDF را یک‌تکه و زیرِ یک نامِ فصل می‌گیرد؛ برای کتابِ +# درسیِ کامل (چند فصل، ۱۵۰+ صفحه) سه چیز کم دارد و همین‌ها اینجا اضافه شده‌اند: +# ۱. فصل‌بندی — بازه‌ی صفحه‌ی هر فصل از rag/services/textbooks.py می‌آید تا +# هر نقطه زیرِ فصلِ درستِ خودش بنشیند (فیلترِ retrieval روی همین است). +# ۲. ازسرگیری — صفحه‌ای که قبلاً chunk ساخته دوباره پردازش نمی‌شود، پس قطعیِ +# شبکه وسطِ کار پول و زمانِ رفته را دور نمی‌ریزد و نقطه‌ی تکراری نمی‌سازد. +# ۳. صفحه‌به‌صفحه بودن — هم مصرفِ حافظه ثابت می‌ماند، هم هر صفحه بلافاصله +# ذخیره می‌شود، هم خطای یک صفحه بقیه را از کار نمی‌اندازد. + + +def _textbook_path(book: Textbook) -> Path: + root = Path(getattr(settings, 'TEXTBOOK_DIR', Path(settings.BASE_DIR) / 'data' / 'textbooks')) + return root / book.filename + + +def _done_pages(collection: str, chapter_title: str) -> set[int]: + """ + صفحه‌هایی که نباید دوباره پردازش شوند. + + هم نشانه‌های IngestedPage (شاملِ صفحه‌های بی‌مسئله) و هم chunkهای موجود + را می‌گیرد؛ دومی برای دادهٔ ingest‌شده پیش از افزودنِ جدولِ نشانه لازم است. + """ + marked = set( + IngestedPage.objects + .filter(collection=collection, chapter_title=chapter_title) + .values_list('page', flat=True) + ) + chunked = set( + LessonChunk.objects + .filter(collection=collection, chapter_title=chapter_title, source_page__isnull=False) + .values_list('source_page', flat=True) + ) + return marked | chunked + + +def ingest_book(*, subject: str, grade: str, chapters=None, dpi: int = 200, + max_pages: int | None = None, resume: bool = True, + progress=None) -> dict: + """ + یک کتابِ درسیِ کامل از data/textbooks/ را فصل‌به‌فصل ingest می‌کند. + + chapters : فهرستِ شماره‌ی فصل‌ها؛ None یعنی همه‌ی فصل‌های کتاب. + max_pages : سقفِ تعدادِ صفحه‌ی پردازش‌شده در این اجرا (برای کنترلِ هزینه). + resume : صفحه‌هایی که قبلاً chunk ساخته‌اند دوباره پردازش نشوند. + progress : callback(dict) برای گزارشِ زنده به فرمانِ مدیریتی. + + خروجی: خلاصه‌ی اجرا؛ هیچ استثنایی برای خطای یک صفحه بالا نمی‌آید — صفحه‌ی + خطادار در `failed_pages` گزارش می‌شود و اجرای بعدی دوباره سراغش می‌رود. + """ + book = get_textbook(subject, grade) + if book is None: + raise ValueError(f'کتابی برای «{subject} {grade}» در textbooks.py ثبت نشده است.') + + # کتابِ بدونِ filename (ششم/هفتم — دادهٔ واقعی‌شان روی Qdrantِ تیمِ دیگر + # است) از این مسیر نمی‌آید. بدونِ این بررسی، مسیر به خودِ پوشه‌ی + # data/textbooks/ می‌رسید که وجود دارد، پس `exists()` رد می‌شد و کار + # بی‌سروصدا جلو می‌رفت تا جایی که pdf2image روی یک پوشه شکست بخورد. + if not book.filename: + raise FileNotFoundError( + f'«{book.key}» فایلِ PDF ندارد و از مسیرِ ingest نمی‌آید. ' + f'برای واردکردنِ دادهٔ موجودش: ' + f'python manage.py rag_import_collection --collection "{book.key}"' + ) + + path = _textbook_path(book) + if not path.exists() or not path.is_file(): + raise FileNotFoundError(f'فایلِ کتاب پیدا نشد: {path}') + + collection = build_collection_name(book.subject, book.grade) + ensure_collection(collection) + + wanted = list(book.chapters) if chapters is None else [ + c for n in chapters if (c := book.chapter(n)) is not None + ] + + def emit(**event): + if progress is not None: + progress(event) + + totals = {'pages_done': 0, 'pages_skipped': 0, 'chunks_created': 0, + 'prompt_tokens': 0, 'completion_tokens': 0} + failed_pages: list[int] = [] + budget = max_pages + + for chapter in wanted: + first_pdf, last_pdf = book.pdf_pages(chapter) + already = _done_pages(collection, chapter.title) if resume else set() + emit(kind='chapter', chapter=chapter.number, title=chapter.title, + pages=(first_pdf, last_pdf), already=len(already)) + + # order در هر فصل ادامه‌دار می‌ماند تا اجرای دوم شماره‌ها را از نو نزند. + order = LessonChunk.objects.filter( + collection=collection, chapter_title=chapter.title, + ).count() + + for page in range(first_pdf, last_pdf + 1): + if page in already: + totals['pages_skipped'] += 1 + continue + if budget is not None and budget <= 0: + emit(kind='budget_reached') + return _book_result(book, collection, totals, failed_pages, partial=True) + + try: + images = pdf_service.extract_page_images( + str(path), first_page=page, last_page=page, dpi=dpi, + ) + if not images: + raise RuntimeError('صفحه به تصویر تبدیل نشد') + parsed = pdf_service.extract_problem_types( + images[0], book.grade_label, chapter.title, + ) + except Exception as exc: # noqa: BLE001 + logger.exception('rag.ingest_book: صفحه %s شکست خورد', page) + failed_pages.append(page) + emit(kind='page_failed', page=page, error=str(exc)) + if budget is not None: + budget -= 1 + continue + + page_usage = parsed.pop('_usage', None) or {} + totals['prompt_tokens'] += int(page_usage.get('promptTokens') or 0) + totals['completion_tokens'] += int(page_usage.get('completionTokens') or 0) + + rows = [] + for problem_type in parsed.get('problemTypes', []) or []: + text = pdf_service.problem_type_to_text(problem_type) + if not text: + continue + order += 1 + example = problem_type.get('exampleFromImage', {}) or {} + rows.append({ + 'order': order, + 'source_page': page, + 'original_text': text, + 'summary': text, + 'structured': { + # عنوانِ فصل از فهرستِ خودمان می‌آید، نه از چیزی که + # مدل در تصویر دیده: فیلترِ retrieval روی رشته‌ی دقیق + # است و یک حرفِ جابه‌جا کلِ فصل را نامرئی می‌کند. + 'chapter_title': chapter.title, + 'grade_title': book.grade_label, + '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(), + }, + }) + + if rows: + try: + created = _persist_and_upsert( + rows, lesson_id=None, grade_id=None, subject=book.subject, + lesson_number=chapter.number, chapter=chapter.number, + title=chapter.title, collection=collection, + ) + except Exception as exc: # noqa: BLE001 + logger.exception('rag.ingest_book: ذخیره‌ی صفحه %s شکست خورد', page) + failed_pages.append(page) + emit(kind='page_failed', page=page, error=str(exc)) + if budget is not None: + budget -= 1 + continue + totals['chunks_created'] += len(created) + + # نشانه بعد از ذخیره‌ی موفق زده می‌شود، نه قبلش: اگر ذخیره شکست + # بخورد صفحه باید در اجرای بعدی دوباره دیده شود. + IngestedPage.objects.update_or_create( + collection=collection, page=page, + defaults={'chapter_title': chapter.title, 'chunk_count': len(rows)}, + ) + totals['pages_done'] += 1 + if budget is not None: + budget -= 1 + emit(kind='page', page=page, chapter=chapter.number, chunks=len(rows)) + + return _book_result(book, collection, totals, failed_pages, partial=False) + + +def _book_result(book: Textbook, collection: str, totals: dict, + failed_pages: list[int], *, partial: bool) -> dict: + model = settings.OPENAI_VISION_MODEL + price_in, price_out = MODEL_PRICE_PER_MTOK.get(model, (2.50, 10.00)) + cost = ( + totals['prompt_tokens'] / 1e6 * price_in + + totals['completion_tokens'] / 1e6 * price_out + ) + return { + 'status': 'partial' if partial or failed_pages else 'success', + 'book': book.key, + 'collection': collection, + 'pages_processed': totals['pages_done'], + 'pages_skipped': totals['pages_skipped'], + 'chunks_created': totals['chunks_created'], + 'failed_pages': failed_pages, + 'prompt_tokens': totals['prompt_tokens'], + 'completion_tokens': totals['completion_tokens'], + 'cost_usd': round(cost, 4), + 'model': model, + } diff --git a/rag/services/known_collections.py b/rag/services/known_collections.py index 561bde9..4a6519c 100644 --- a/rag/services/known_collections.py +++ b/rag/services/known_collections.py @@ -10,6 +10,8 @@ اگه تیمِ دیگه collection جدید اضافه/تغییر داد، فقط همین فایل به‌روز بشه. """ +from .textbooks import TEXTBOOKS, chapter_titles + # --- درس‌های متنی: payload کلیدِ `lesson_number` (رشته) + `summary` دارد --- # نامِ collection = f"{subject} {grade}".lower() TEXT_SUBJECT_LESSON_FILTER_KEY = 'lesson_number' @@ -22,6 +24,11 @@ MATH_SUBJECTS = {'ریاضی'} # فصول ریاضی به تفکیکِ (subject, grade) — کلیدِ داخلی هم شماره‌ی فصل و هم # رشته‌ی کامل رو می‌پذیره. +# +# پایه‌های سوم/ششم/هفتم را تیمِ دیگر آپلود کرده بود و این جدول از روی دادهٔ +# زنده‌ی Qdrant درآمده. پایه‌های نهم/دهم را خودمان ingest می‌کنیم، پس عنوانِ +# فصلشان از rag/services/textbooks.py می‌آید تا رشته‌ای که ingest می‌نویسد و +# رشته‌ای که retrieval رویش فیلتر می‌کند نتوانند از هم جدا بیفتند. MATH_CHAPTERS = { ('ریاضی', 'سوم'): { 1: 'فصل ۱: الگوها', @@ -55,6 +62,10 @@ MATH_CHAPTERS = { }, } +MATH_CHAPTERS.update({ + (b.subject, b.grade): chapter_titles(b) for b in TEXTBOOKS.values() +}) + # درس‌های علوم/اجتماعی — فقط برای مرجع/اعتبارسنجی (lesson_number → عنوان). TEXT_LESSONS = { ('علوم تجربی', 'سوم'): { @@ -72,7 +83,7 @@ TEXT_LESSONS = { KNOWN_COLLECTIONS = { 'ریاضی سوم', 'ریاضی ششم', 'ریاضی هفتم', 'علوم تجربی سوم', 'مطالعات اجتماعی ششم', 'مطالعات اجتماعی چهارم', -} +} | {b.key for b in TEXTBOOKS.values()} def is_math_subject(subject: str) -> bool: diff --git a/rag/services/pdf.py b/rag/services/pdf.py index 528a383..fa344c9 100644 --- a/rag/services/pdf.py +++ b/rag/services/pdf.py @@ -18,13 +18,20 @@ from . import clients from .clients import RagNotConfigured -def extract_page_images(pdf_path: str, first_page: int = 1, dpi: int = 200): - """لیستی از تصاویرِ PIL برای صفحاتِ PDF از first_page به بعد.""" +def extract_page_images(pdf_path: str, first_page: int = 1, dpi: int = 200, + last_page: int | None = None): + """ + لیستی از تصاویرِ PIL برای صفحاتِ PDF در بازه‌ی [first_page, last_page]. + + `last_page` برای کتابِ کامل ضروری است: بدونِ آن pdf2image کلِ کتاب را + یک‌جا به تصویر تبدیل می‌کند (۱۵۰+ صفحه در حافظه) و امکانِ ingest فصل‌به‌فصل + هم از بین می‌رود. + """ try: from pdf2image import convert_from_path except ModuleNotFoundError as exc: raise RagNotConfigured('pdf2image نصب نیست یا poppler موجود نیست.') from exc - return convert_from_path(pdf_path, dpi=dpi, first_page=first_page) + return convert_from_path(pdf_path, dpi=dpi, first_page=first_page, last_page=last_page) def detect_grade_and_start_page(pdf_path: str) -> tuple[int, str | None]: @@ -37,9 +44,13 @@ def detect_grade_and_start_page(pdf_path: str) -> tuple[int, str | None]: except ModuleNotFoundError as exc: raise RagNotConfigured('pymupdf (fitz) نصب نیست.') from exc - start_pages = {'پنجم دبستان': 11, 'ششم دبستان': 9} - grades = ['هفتم', 'اول دبستان', 'دوم دبستان', 'سوم دبستان', - 'چهارم دبستان', 'پنجم دبستان', 'ششم دبستان'] + # صفحه‌ی شروعِ محتوای واقعی؛ هرچه اینجا نباشد پیش‌فرضِ ۷ می‌گیرد. + start_pages = {'پنجم دبستان': 11, 'ششم دبستان': 9, 'نهم': 9, 'دهم': 7} + # ترتیب مهم است: «دهم» زیررشته‌ی «نهم» نیست ولی «هفتم»/«هشتم» در متنِ + # شناسنامه‌ی کتابِ نهم هم می‌آیند («در ادامهٔ پایه‌های هفتم و هشتم»)، پس + # پایه‌های بالاتر باید زودتر بررسی شوند. + grades = ['دهم', 'نهم', 'هشتم', 'هفتم', 'اول دبستان', 'دوم دبستان', + 'سوم دبستان', 'چهارم دبستان', 'پنجم دبستان', 'ششم دبستان'] doc = fitz.open(pdf_path) try: @@ -63,13 +74,38 @@ _VISION_JSON_TEMPLATE = """ """ +# پایه → توصیفِ معلمِ متناسب. پرامپت قبلاً «معلمِ ابتدایی، بچه‌های ۸ تا ۱۲ +# ساله» را هاردکد کرده بود؛ برای پایه‌های نهم/دهم این هم لحنِ توضیح را بچگانه +# می‌کرد و هم مدل را به ساده‌سازیِ بیش از حدِ مفاهیم (اتحاد، مثلثات، تابع) +# سوق می‌داد. +_TEACHER_BY_GRADE = { + 'اول دبستان': 'a specialist elementary math teacher for children aged 6 to 7', + 'دوم دبستان': 'a specialist elementary math teacher for children aged 7 to 8', + 'سوم دبستان': 'a specialist elementary math teacher for children aged 8 to 9', + 'چهارم دبستان': 'a specialist elementary math teacher for children aged 9 to 10', + 'پنجم دبستان': 'a specialist elementary math teacher for children aged 10 to 11', + 'ششم دبستان': 'a specialist elementary math teacher for children aged 11 to 12', + 'هفتم': 'a specialist middle-school math teacher for students aged 12 to 13', + 'هشتم': 'a specialist middle-school math teacher for students aged 13 to 14', + 'نهم': 'a specialist middle-school math teacher for students aged 14 to 15', + 'دهم': 'a specialist high-school math teacher for students aged 15 to 16', +} + + +def teacher_persona(grade: str) -> str: + return _TEACHER_BY_GRADE.get( + (grade or '').strip(), + 'a specialist school math teacher', + ) + + def extract_problem_types(image, grade: str, chapter_name: str) -> dict: """ یک عکسِ صفحه‌ی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision. خروجی همون شکلی‌ست که uploadِ ریاضیِ تیمِ دیگه هم تولید می‌کرد. """ prompt = f""" -You are a specialist elementary math teacher for children aged 8 to 12. +You are {teacher_persona(grade)}. Analyze the uploaded image and extract every unique math problem type for the given chapter. For each type produce: type, keywords (3-7, Persian), generalSolution (step-by-step), @@ -83,6 +119,10 @@ Rules: - grade = "{grade}" - chapter = "{chapter_name}" - exampleFromImage must be a real problem from the image (not invented). +- Keep the mathematical notation of the textbook; do not simplify the concept + to a lower grade level than "{grade}". +- If the page has no math problem at all (cover, foreword, table of contents, + a page of pure text), return an empty "problemTypes" list instead of inventing one. - Output ONLY the JSON object. No markdown, no backticks. """ @@ -110,7 +150,17 @@ Rules: raw = raw[3:].strip() if raw.endswith('```'): raw = raw[:-3].strip() - return json.loads(raw) + + parsed = json.loads(raw) + # مصرفِ واقعیِ همین فراخوانی را همراهِ نتیجه برمی‌گردانیم تا ingest بتواند + # هزینه‌ی واقعی را گزارش کند. تا پیش از این فقط تخمینِ سرانگشتی داشتیم و + # آن تخمین ۲.۳ برابرِ واقعیت بود. + usage = getattr(response, 'usage', None) + parsed['_usage'] = { + 'promptTokens': getattr(usage, 'prompt_tokens', 0) or 0, + 'completionTokens': getattr(usage, 'completion_tokens', 0) or 0, + } + return parsed def problem_type_to_text(problem_type: dict) -> str: diff --git a/rag/services/textbooks.py b/rag/services/textbooks.py new file mode 100644 index 0000000..c7307af --- /dev/null +++ b/rag/services/textbooks.py @@ -0,0 +1,302 @@ +""" +نقشه‌ی فیزیکیِ فایل‌های PDF کتاب‌های درسی در data/textbooks/. + +چرا این فایل لازم است: `ingest_pdf` یک PDF را یک‌تکه و با یک نامِ فصل ingest +می‌کند. کتابِ کاملِ درسی چند فصل دارد، پس بدونِ نگاشتِ «فصل → بازه‌ی صفحه» +همه‌ی نقاطِ کتاب زیرِ یک فصلِ اشتباه ذخیره می‌شدند و فیلترِ `chapter` در +retrieval هیچ‌وقت درست جواب نمی‌داد. + +دو نکته که موقعِ افزودنِ کتابِ جدید باید حواستان باشد: + +- `page_offset`: شماره‌ی صفحه‌ای که روی کاغذ چاپ شده با شماره‌ی صفحه در PDF + یکی نیست (جلد، شناسنامه، فهرست و سخنی با معلم جلوترند). فهرستِ کتاب شماره‌ی + کاغذی می‌دهد، pdf2image شماره‌ی PDF می‌خواهد: pdf_page = book_page + offset. +- بازه‌ها بر اساسِ فهرستِ خودِ کتاب‌اند و شاملِ صفحه‌ی جداکننده‌ی فصل هم + می‌شوند؛ صفحه‌ی آخرِ هر فصل = یکی قبل از شروعِ فصلِ بعد. + +مقادیرِ زیر با خواندنِ فهرست و بازبینیِ چشمیِ صفحه‌های مرزی از همان فایل‌ها +درآمده‌اند (۱۴۰۵/۰۶/۲۱). +""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Section: + """یک «درس» داخلِ فصل، همان‌طور که در فهرستِ کتاب آمده.""" + + number: int + title: str + first_page: int # شماره‌ی چاپ‌شده روی کاغذ + last_page: int = 0 # از روی درسِ بعدی پر می‌شود؛ صفر یعنی «تا آخرِ فصل» + + +@dataclass(frozen=True) +class Chapter: + number: int + title: str # عنوانِ کاملِ فصل، دقیقاً همان رشته‌ای که در payload می‌نشیند + first_page: int # شماره‌ی چاپ‌شده روی کاغذ + last_page: int + sections: tuple[Section, ...] = () + + def resolved_sections(self) -> tuple[Section, ...]: + """درس‌ها با last_page پرشده (هر درس تا یکی قبل از درسِ بعدی).""" + out = [] + for index, section in enumerate(self.sections): + end = ( + self.sections[index + 1].first_page - 1 + if index + 1 < len(self.sections) else self.last_page + ) + out.append(Section(section.number, section.title, section.first_page, end)) + return tuple(out) + + +@dataclass(frozen=True) +class Textbook: + subject: str + grade: str # همان رشته‌ای که در نامِ collection می‌آید («نهم») + grade_label: str # همان رشته‌ای که در payloadِ `grade` می‌نشیند + filename: str + page_offset: int + chapters: tuple[Chapter, ...] + + @property + def key(self) -> str: + return f'{self.subject} {self.grade}'.strip() + + def pdf_pages(self, chapter: Chapter) -> tuple[int, int]: + return chapter.first_page + self.page_offset, chapter.last_page + self.page_offset + + def chapter(self, number: int) -> Chapter | None: + return next((c for c in self.chapters if c.number == number), None) + + +def _ch(*rows) -> tuple[Chapter, ...]: + """rows: (number, title, first_page, last_page, [(sec_no, sec_title, sec_page), ...])""" + chapters = [] + for row in rows: + number, title, first, last = row[:4] + sections = tuple(Section(*s) for s in (row[4] if len(row) > 4 else ())) + chapters.append(Chapter(number, title, first, last, sections)) + return tuple(chapters) + + +MATH_3 = Textbook( + subject='ریاضی', grade='سوم', grade_label='سوم دبستان', + filename='', page_offset=0, + chapters=_ch( + (1, 'فصل ۱: الگوها', 0, 0), + (2, 'فصل ۲: عددهای چهار رقمی', 0, 0), + (3, 'فصل ۳: عددهای کسری', 0, 0), + (4, 'فصل ۴: ضرب و تقسیم', 0, 0), + (5, 'فصل ۵: محیط و مساحت', 0, 0), + (6, 'فصل ۶: جمع و تفریق', 0, 0), + (7, 'فصل ۷: آمار و احتمال', 0, 0), + (8, 'فصل ۸: ضرب عددها', 0, 0), + ), +) + +# هشتم تنها کتابی‌ست که هم PDF دارد و هم روی Qdrant نیست — یعنی فصل‌بندی و +# درس‌هایش کاملاً از فهرستِ خودِ کتاب می‌آید و ingest دقیقاً همین رشته‌ها را +# می‌نویسد. «مرور فصل» جزء آخرین درسِ هر فصل حساب شده است. +MATH_8 = Textbook( + subject='ریاضی', grade='هشتم', grade_label='هشتم', + filename='ریاضی هشتم.pdf', page_offset=8, + chapters=_ch( + (1, 'فصل ۱: عددهای صحیح و گویا', 1, 18, ( + (1, 'یادآوری عددهای صحیح', 2), + (2, 'معرفی عددهای گویا', 6), + (3, 'جمع و تفریق عددهای گویا', 10), + (4, 'ضرب و تقسیم عددهای گویا', 14), + )), + (2, 'فصل ۲: عددهای اول', 19, 28, ( + (1, 'یادآوری عددهای اول', 20), + (2, 'تعیین عددهای اول', 24), + )), + (3, 'فصل ۳: چندضلعی‌ها', 29, 50, ( + (1, 'چندضلعی‌ها و تقارن', 30), + (2, 'توازی و تعامد', 34), + (3, 'چهارضلعی‌ها', 38), + (4, 'زاویه‌های داخلی', 42), + (5, 'زاویه‌های خارجی', 46), + )), + (4, 'فصل ۴: جبر و معادله', 51, 68, ( + (1, 'ساده کردن عبارت‌های جبری', 52), + (2, 'پیدا کردن مقدار یک عبارت جبری', 56), + (3, 'تجزیه عبارت‌های جبری', 60), + (4, 'معادله', 64), + )), + (5, 'فصل ۵: بردار و مختصات', 69, 82, ( + (1, 'جمع بردارها', 70), + (2, 'ضرب عدد در بردار', 74), + (3, 'بردارهای واحد مختصات', 78), + )), + (6, 'فصل ۶: مثلث', 83, 100, ( + (1, 'رابطهٔ فیثاغورس', 84), + (2, 'شکل‌های هم‌نهشت', 88), + (3, 'مثلث‌های هم‌نهشت', 92), + (4, 'هم‌نهشتی مثلث‌های قائم‌الزاویه', 96), + )), + (7, 'فصل ۷: توان و جذر', 101, 118, ( + (1, 'توان', 102), + (2, 'تقسیم اعداد توان‌دار', 106), + (3, 'جذر تقریبی', 110), + (4, 'نمایش اعداد رادیکالی روی محور اعداد', 114), + (5, 'خواص ضرب و تقسیم رادیکال‌ها', 115), + )), + (8, 'فصل ۸: آمار و احتمال', 119, 136, ( + (1, 'دسته‌بندی داده‌ها', 120), + (2, 'میانگین داده‌ها', 124), + (3, 'احتمال یا اندازه‌گیری شانس', 128), + (4, 'بررسی حالت‌های ممکن', 132), + )), + (9, 'فصل ۹: دایره', 137, 150, ( + (1, 'خط و دایره', 138), + (2, 'زاویه‌های مرکزی', 142), + (3, 'زاویه‌های محاطی', 146), + )), + ), +) + +MATH_9 = Textbook( + subject='ریاضی', grade='نهم', grade_label='نهم', + filename='ریاضی نهم.pdf', page_offset=8, + chapters=_ch( + (1, 'فصل ۱: مجموعه‌ها', 1, 17, ( + (1, 'معرفی مجموعه', 2), + (2, 'مجموعه‌های برابر و نمایش مجموعه‌ها', 6), + (3, 'اجتماع، اشتراک و تفاضلِ مجموعه‌ها', 11), + (4, 'مجموعه‌ها و احتمال', 15), + )), + (2, 'فصل ۲: عددهای حقیقی', 18, 31, ( + (1, 'عددهای گویا', 19), + (2, 'عددهای حقیقی', 23), + (3, 'قدر مطلق و محاسبهٔ تقریبی', 28), + )), + (3, 'فصل ۳: استدلال و اثبات در هندسه', 32, 58, ( + (1, 'استدلال', 33), + (2, 'آشنایی با اثبات در هندسه', 37), + (3, 'هم‌نهشتی مثلث‌ها', 44), + (4, 'حل مسئله در هندسه', 49), + (5, 'شکل‌های متشابه', 53), + )), + (4, 'فصل ۴: توان و ریشه', 59, 77, ( + (1, 'توان صحیح', 60), + (2, 'نماد علمی', 65), + (3, 'ریشه‌گیری', 68), + (4, 'جمع و تفریق رادیکال‌ها', 73), + )), + (5, 'فصل ۵: عبارت‌های جبری', 78, 94, ( + (1, 'عبارت‌های جبری و مفهوم اتحاد', 79), + (2, 'چند اتحاد دیگر، تجزیه و کاربردها', 86), + (3, 'نابرابری‌ها و نامعادله‌ها', 90), + )), + (6, 'فصل ۶: خط و معادله‌های خطی', 95, 112, ( + (1, 'معادلهٔ خط', 96), + (2, 'شیب خط و عرض از مبدأ', 102), + (3, 'دستگاه معادله‌های خطی', 108), + )), + (7, 'فصل ۷: عبارت‌های گویا', 113, 129, ( + (1, 'معرفی و ساده کردن عبارت‌های گویا', 114), + (2, 'محاسبات عبارت‌های گویا', 119), + (3, 'تقسیم چندجمله‌ای‌ها', 126), + )), + (8, 'فصل ۸: حجم و مساحت', 130, 143, ( + (1, 'حجم و مساحت کره', 131), + (2, 'حجم هرم و مخروط', 135), + (3, 'سطح و حجم', 140), + )), + ), +) + +MATH_10 = Textbook( + subject='ریاضی', grade='دهم', grade_label='دهم', + filename='ریاضی دهم.pdf', page_offset=6, + chapters=_ch( + (1, 'فصل ۱: مجموعه، الگو و دنباله', 1, 27, ( + (1, 'مجموعه‌های متناهی و نامتناهی', 2), + (2, 'متمم یک مجموعه', 8), + (3, 'الگو و دنباله', 14), + (4, 'دنباله‌های حسابی و هندسی', 21), + )), + (2, 'فصل ۲: مثلثات', 28, 46, ( + (1, 'نسبت‌های مثلثاتی', 29), + (2, 'دایرهٔ مثلثاتی', 36), + (3, 'روابط بین نسبت‌های مثلثاتی', 42), + )), + (3, 'فصل ۳: توان‌های گویا و عبارت‌های جبری', 47, 68, ( + (1, 'ریشه و توان', 48), + (2, 'ریشهٔ nام', 54), + (3, 'توان‌های گویا', 59), + (4, 'عبارت‌های جبری', 62), + )), + (4, 'فصل ۴: معادله‌ها و نامعادله‌ها', 69, 93, ( + (1, 'معادلهٔ درجه دوم و روش‌های مختلف حل آن', 70), + (2, 'سهمی', 78), + (3, 'تعیین علامت', 83), + )), + (5, 'فصل ۵: تابع', 94, 117, ( + (1, 'مفهوم تابع و بازنمایی‌های آن', 95), + (2, 'دامنه و برد توابع', 101), + (3, 'انواع تابع', 109), + )), + (6, 'فصل ۶: شمارش، بدون شمردن', 118, 140, ( + (1, 'شمارش', 119), + (2, 'جایگشت', 127), + (3, 'ترکیب', 133), + )), + (7, 'فصل ۷: آمار و احتمال', 141, 170, ( + (1, 'احتمال یا اندازه‌گیری شانس', 142), + (2, 'مقدمه‌ای بر علم آمار، جامعه و نمونه', 152), + (3, 'متغیر و انواع آن', 159), + )), + ), +) + +# پایه‌های ششم و هفتم را تیمِ دیگر ingest کرده و روی Qdrant هستند؛ فایلِ PDF +# برایشان لازم نیست و بازه‌ی صفحه هم نداریم (نقاطشان شماره‌ی صفحه ندارند). فقط +# فصل‌بندی ثبت می‌شود تا `seed_curriculum_from_textbook` بتواند برایشان برنامه‌ی +# درسی بسازد؛ `filename` خالی یعنی «این کتاب از مسیرِ PDF ingest نمی‌شود». +MATH_6 = Textbook( + subject='ریاضی', grade='ششم', grade_label='ششم دبستان', + filename='', page_offset=0, + chapters=_ch( + (1, 'فصل ۱: عدد و الگوهای عددی', 0, 0), + (2, 'فصل ۲: کسر', 0, 0), + (3, 'فصل ۳: اعداد اعشاری', 0, 0), + (4, 'فصل ۴: تقارن و مختصات', 0, 0), + (5, 'فصل ۵: اندازه گیری', 0, 0), + (6, 'فصل ۶: تناسب و درصد', 0, 0), + (7, 'فصل ۷: تقریب', 0, 0), + ), +) + +MATH_7 = Textbook( + subject='ریاضی', grade='هفتم', grade_label='هفتم', + filename='', page_offset=0, + chapters=_ch( + (1, 'فصل ۱: راهبردهای حل مسئله', 0, 0), + (2, 'فصل ۲: عددهای صحیح', 0, 0), + (3, 'فصل ۳: جبر و معادله', 0, 0), + (4, 'فصل ۴: هندسه و استدلال', 0, 0), + (5, 'فصل ۵: شمارنده‌ها و اعداد اول', 0, 0), + (6, 'فصل ۶: سطح و حجم', 0, 0), + (7, 'فصل ۷: توان و جذر', 0, 0), + (8, 'فصل ۸: بردار و مختصات', 0, 0), + (9, 'فصل ۹: آمار و احتمال', 0, 0), + ), +) + +TEXTBOOKS: dict[str, Textbook] = { + b.key: b for b in (MATH_3, MATH_6, MATH_7, MATH_8, MATH_9, MATH_10) +} + +# کتاب‌هایی که واقعاً فایلِ PDF دارند و `rag_ingest_book` رویشان کار می‌کند. +INGESTABLE = {b.key: b for b in TEXTBOOKS.values() if b.filename} + + +def get_textbook(subject: str, grade: str) -> Textbook | None: + return TEXTBOOKS.get(f'{(subject or "").strip()} {(grade or "").strip()}') + + +def chapter_titles(book: Textbook) -> dict[int, str]: + return {c.number: c.title for c in book.chapters}