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>
|
نسخهی رسمی و بهروزِ کتابها: <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 قابلِ بازسازی باشه.
|
دوبارهی 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
|
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
||||||
subject = models.CharField(max_length=100)
|
subject = models.CharField(max_length=100)
|
||||||
lesson_number = models.IntegerField()
|
lesson_number = models.IntegerField()
|
||||||
@@ -46,6 +48,11 @@ class LessonChunk(models.Model):
|
|||||||
problem = models.TextField(blank=True)
|
problem = models.TextField(blank=True)
|
||||||
solution = 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)
|
collection = models.CharField(max_length=200, db_index=True)
|
||||||
qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False)
|
qdrant_point_id = models.UUIDField(default=uuid.uuid4, editable=False)
|
||||||
|
|
||||||
@@ -61,6 +68,7 @@ class LessonChunk(models.Model):
|
|||||||
]
|
]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['subject', 'lesson_number']),
|
models.Index(fields=['subject', 'lesson_number']),
|
||||||
|
models.Index(fields=['collection', 'chapter_title', 'source_page']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -77,3 +85,32 @@ class LessonChunkImage(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"image-chunk{self.chunk_id}"
|
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:
|
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 {}
|
overrides = getattr(settings, 'RAG_GRADE_TITLE_OVERRIDES', {}) or {}
|
||||||
if grade_id in overrides:
|
if grade_id in overrides:
|
||||||
return str(overrides[grade_id])
|
return str(overrides[grade_id])
|
||||||
@@ -27,9 +34,11 @@ def resolve_grade_title(grade_id, fallback: str = '') -> str:
|
|||||||
try:
|
try:
|
||||||
from content.models import Grade
|
from content.models import Grade
|
||||||
|
|
||||||
|
from .lesson_context import normalize_grade_title
|
||||||
|
|
||||||
grade = Grade.objects.filter(pk=grade_id).only('title').first()
|
grade = Grade.objects.filter(pk=grade_id).only('title').first()
|
||||||
if grade is not None:
|
if grade is not None:
|
||||||
return grade.title
|
return normalize_grade_title(grade.title)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@@ -8,19 +8,36 @@ DB (LessonChunk، منبعِ حقیقت) + upsert به Qdrant (index).
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from ..models import LessonChunk
|
from ..models import IngestedPage, LessonChunk
|
||||||
from .chunking import chunk_and_summarize
|
from .chunking import chunk_and_summarize
|
||||||
from .config import build_collection_name, resolve_grade_title
|
from .config import build_collection_name, resolve_grade_title
|
||||||
from .embedding import embed_text
|
from .embedding import embed_text
|
||||||
from .known_collections import is_math_subject, resolve_math_chapter
|
from .known_collections import is_math_subject, resolve_math_chapter
|
||||||
from . import pdf as pdf_service
|
from . import pdf as pdf_service
|
||||||
from .qdrant_store import ensure_collection, upsert_points
|
from .qdrant_store import ensure_collection, upsert_points
|
||||||
|
from .textbooks import Textbook, get_textbook
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def _payload(chunk: LessonChunk) -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -29,17 +46,44 @@ def _payload(chunk: LessonChunk) -> dict:
|
|||||||
⚠️ کلیدِ `chapter` (رشتهی کاملِ فصل) حتماً باید نوشته بشه: جستجوی ریاضی در
|
⚠️ کلیدِ `chapter` (رشتهی کاملِ فصل) حتماً باید نوشته بشه: جستجوی ریاضی در
|
||||||
retrieval.py دقیقاً روی همین کلید فیلتر میکنه (نه `chapter_number`)، پس
|
retrieval.py دقیقاً روی همین کلید فیلتر میکنه (نه `chapter_number`)، پس
|
||||||
بدونش هر چیزی که ingest کنیم برای همیشه نامرئی میمونه — بدونِ هیچ خطایی.
|
بدونش هر چیزی که 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 = {
|
# منشأ را از `problem_type` تشخیص میدهیم، نه از پر بودنِ راهحل: صفحهای
|
||||||
'lesson_id': chunk.lesson_id,
|
# که 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,
|
'lesson_number': chunk.lesson_number,
|
||||||
'chapter_number': chunk.chapter,
|
'chapter_number': chunk.chapter,
|
||||||
'chunk_order': chunk.order,
|
'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 (
|
for key, value in (
|
||||||
('chapter', chunk.chapter_title),
|
('chapter', chunk.chapter_title),
|
||||||
('grade', chunk.grade_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,
|
def _persist_and_upsert(rows: list[dict], *, lesson_id, grade_id, subject,
|
||||||
lesson_number, chapter, title, collection) -> list[LessonChunk]:
|
lesson_number, chapter, title, collection) -> list[LessonChunk]:
|
||||||
ensure_collection(collection)
|
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():
|
with transaction.atomic():
|
||||||
for row in rows:
|
for row, vector in zip(rows, vectors):
|
||||||
vector = embed_text(row['summary'])
|
created.append(LessonChunk.objects.create(
|
||||||
chunk = LessonChunk.objects.create(
|
|
||||||
lesson_id=lesson_id,
|
lesson_id=lesson_id,
|
||||||
grade_id=grade_id,
|
grade_id=grade_id,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
@@ -75,14 +122,30 @@ def _persist_and_upsert(rows: list[dict], *, lesson_id, grade_id, subject,
|
|||||||
summary=row['summary'],
|
summary=row['summary'],
|
||||||
embedding_vector=vector,
|
embedding_vector=vector,
|
||||||
collection=collection,
|
collection=collection,
|
||||||
|
source_page=row.get('source_page'),
|
||||||
# فیلدهای ساختاری فقط از مسیرِ PDF/vision میآن؛ ingest_text
|
# فیلدهای ساختاری فقط از مسیرِ PDF/vision میآن؛ ingest_text
|
||||||
# اونها رو نداره و خالی میمونن (payload هم اونوقت نمینویسدشون).
|
# اونها رو نداره و خالی میمونن (payload هم اونوقت نمینویسدشون).
|
||||||
**row.get('structured', {}),
|
**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
|
return created
|
||||||
|
|
||||||
|
|
||||||
@@ -189,3 +252,208 @@ def ingest_pdf(*, lesson_id: int, grade_id, subject: str, lesson_number: int,
|
|||||||
'chunks_created': len(created),
|
'chunks_created': len(created),
|
||||||
'chunk_ids': [c.id for c in 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 جدید اضافه/تغییر داد، فقط همین فایل بهروز بشه.
|
اگه تیمِ دیگه collection جدید اضافه/تغییر داد، فقط همین فایل بهروز بشه.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .textbooks import TEXTBOOKS, chapter_titles
|
||||||
|
|
||||||
# --- درسهای متنی: payload کلیدِ `lesson_number` (رشته) + `summary` دارد ---
|
# --- درسهای متنی: payload کلیدِ `lesson_number` (رشته) + `summary` دارد ---
|
||||||
# نامِ collection = f"{subject} {grade}".lower()
|
# نامِ collection = f"{subject} {grade}".lower()
|
||||||
TEXT_SUBJECT_LESSON_FILTER_KEY = 'lesson_number'
|
TEXT_SUBJECT_LESSON_FILTER_KEY = 'lesson_number'
|
||||||
@@ -22,6 +24,11 @@ MATH_SUBJECTS = {'ریاضی'}
|
|||||||
|
|
||||||
# فصول ریاضی به تفکیکِ (subject, grade) — کلیدِ داخلی هم شمارهی فصل و هم
|
# فصول ریاضی به تفکیکِ (subject, grade) — کلیدِ داخلی هم شمارهی فصل و هم
|
||||||
# رشتهی کامل رو میپذیره.
|
# رشتهی کامل رو میپذیره.
|
||||||
|
#
|
||||||
|
# پایههای سوم/ششم/هفتم را تیمِ دیگر آپلود کرده بود و این جدول از روی دادهٔ
|
||||||
|
# زندهی Qdrant درآمده. پایههای نهم/دهم را خودمان ingest میکنیم، پس عنوانِ
|
||||||
|
# فصلشان از rag/services/textbooks.py میآید تا رشتهای که ingest مینویسد و
|
||||||
|
# رشتهای که retrieval رویش فیلتر میکند نتوانند از هم جدا بیفتند.
|
||||||
MATH_CHAPTERS = {
|
MATH_CHAPTERS = {
|
||||||
('ریاضی', 'سوم'): {
|
('ریاضی', 'سوم'): {
|
||||||
1: 'فصل ۱: الگوها',
|
1: 'فصل ۱: الگوها',
|
||||||
@@ -55,6 +62,10 @@ MATH_CHAPTERS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MATH_CHAPTERS.update({
|
||||||
|
(b.subject, b.grade): chapter_titles(b) for b in TEXTBOOKS.values()
|
||||||
|
})
|
||||||
|
|
||||||
# درسهای علوم/اجتماعی — فقط برای مرجع/اعتبارسنجی (lesson_number → عنوان).
|
# درسهای علوم/اجتماعی — فقط برای مرجع/اعتبارسنجی (lesson_number → عنوان).
|
||||||
TEXT_LESSONS = {
|
TEXT_LESSONS = {
|
||||||
('علوم تجربی', 'سوم'): {
|
('علوم تجربی', 'سوم'): {
|
||||||
@@ -72,7 +83,7 @@ TEXT_LESSONS = {
|
|||||||
KNOWN_COLLECTIONS = {
|
KNOWN_COLLECTIONS = {
|
||||||
'ریاضی سوم', 'ریاضی ششم', 'ریاضی هفتم',
|
'ریاضی سوم', 'ریاضی ششم', 'ریاضی هفتم',
|
||||||
'علوم تجربی سوم', 'مطالعات اجتماعی ششم', 'مطالعات اجتماعی چهارم',
|
'علوم تجربی سوم', 'مطالعات اجتماعی ششم', 'مطالعات اجتماعی چهارم',
|
||||||
}
|
} | {b.key for b in TEXTBOOKS.values()}
|
||||||
|
|
||||||
|
|
||||||
def is_math_subject(subject: str) -> bool:
|
def is_math_subject(subject: str) -> bool:
|
||||||
|
|||||||
@@ -18,13 +18,20 @@ from . import clients
|
|||||||
from .clients import RagNotConfigured
|
from .clients import RagNotConfigured
|
||||||
|
|
||||||
|
|
||||||
def extract_page_images(pdf_path: str, first_page: int = 1, dpi: int = 200):
|
def extract_page_images(pdf_path: str, first_page: int = 1, dpi: int = 200,
|
||||||
"""لیستی از تصاویرِ PIL برای صفحاتِ PDF از first_page به بعد."""
|
last_page: int | None = None):
|
||||||
|
"""
|
||||||
|
لیستی از تصاویرِ PIL برای صفحاتِ PDF در بازهی [first_page, last_page].
|
||||||
|
|
||||||
|
`last_page` برای کتابِ کامل ضروری است: بدونِ آن pdf2image کلِ کتاب را
|
||||||
|
یکجا به تصویر تبدیل میکند (۱۵۰+ صفحه در حافظه) و امکانِ ingest فصلبهفصل
|
||||||
|
هم از بین میرود.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from pdf2image import convert_from_path
|
from pdf2image import convert_from_path
|
||||||
except ModuleNotFoundError as exc:
|
except ModuleNotFoundError as exc:
|
||||||
raise RagNotConfigured('pdf2image نصب نیست یا poppler موجود نیست.') from 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]:
|
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:
|
except ModuleNotFoundError as exc:
|
||||||
raise RagNotConfigured('pymupdf (fitz) نصب نیست.') from exc
|
raise RagNotConfigured('pymupdf (fitz) نصب نیست.') from exc
|
||||||
|
|
||||||
start_pages = {'پنجم دبستان': 11, 'ششم دبستان': 9}
|
# صفحهی شروعِ محتوای واقعی؛ هرچه اینجا نباشد پیشفرضِ ۷ میگیرد.
|
||||||
grades = ['هفتم', 'اول دبستان', 'دوم دبستان', 'سوم دبستان',
|
start_pages = {'پنجم دبستان': 11, 'ششم دبستان': 9, 'نهم': 9, 'دهم': 7}
|
||||||
'چهارم دبستان', 'پنجم دبستان', 'ششم دبستان']
|
# ترتیب مهم است: «دهم» زیررشتهی «نهم» نیست ولی «هفتم»/«هشتم» در متنِ
|
||||||
|
# شناسنامهی کتابِ نهم هم میآیند («در ادامهٔ پایههای هفتم و هشتم»)، پس
|
||||||
|
# پایههای بالاتر باید زودتر بررسی شوند.
|
||||||
|
grades = ['دهم', 'نهم', 'هشتم', 'هفتم', 'اول دبستان', 'دوم دبستان',
|
||||||
|
'سوم دبستان', 'چهارم دبستان', 'پنجم دبستان', 'ششم دبستان']
|
||||||
|
|
||||||
doc = fitz.open(pdf_path)
|
doc = fitz.open(pdf_path)
|
||||||
try:
|
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:
|
def extract_problem_types(image, grade: str, chapter_name: str) -> dict:
|
||||||
"""
|
"""
|
||||||
یک عکسِ صفحهی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision.
|
یک عکسِ صفحهی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision.
|
||||||
خروجی همون شکلیست که uploadِ ریاضیِ تیمِ دیگه هم تولید میکرد.
|
خروجی همون شکلیست که uploadِ ریاضیِ تیمِ دیگه هم تولید میکرد.
|
||||||
"""
|
"""
|
||||||
prompt = f"""
|
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.
|
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),
|
For each type produce: type, keywords (3-7, Persian), generalSolution (step-by-step),
|
||||||
@@ -83,6 +119,10 @@ Rules:
|
|||||||
- grade = "{grade}"
|
- grade = "{grade}"
|
||||||
- chapter = "{chapter_name}"
|
- chapter = "{chapter_name}"
|
||||||
- exampleFromImage must be a real problem from the image (not invented).
|
- 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.
|
- Output ONLY the JSON object. No markdown, no backticks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -110,7 +150,17 @@ Rules:
|
|||||||
raw = raw[3:].strip()
|
raw = raw[3:].strip()
|
||||||
if raw.endswith('```'):
|
if raw.endswith('```'):
|
||||||
raw = raw[:-3].strip()
|
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:
|
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