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:
@@ -8,15 +8,52 @@
|
||||
نسخهی رسمی و بهروزِ کتابها: <https://irtextbook.ir>
|
||||
(از خارج ایران در دسترس نیست؛ دانلود باید از داخل ایران انجام شود.)
|
||||
|
||||
## نامگذاری پیشنهادی
|
||||
## اولویت (بهروزشده ۱۴۰۵/۰۶/۲۱)
|
||||
|
||||
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` ثبت میشود، پس
|
||||
جستجو بلافاصله میتواند شمارهی فصل را به رشتهی دقیق تبدیل کند.
|
||||
|
||||
## بعدش چه اتفاقی میافتد
|
||||
|
||||
|
||||
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 پر شد؛ توقف.'))
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
28
rag/migrations/0004_ingestedpage.py
Normal file
28
rag/migrations/0004_ingestedpage.py
Normal file
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
302
rag/services/textbooks.py
Normal file
302
rag/services/textbooks.py
Normal file
@@ -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}
|
||||
Reference in New Issue
Block a user