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:
2026-09-13 18:59:03 +03:30
parent 52d9694583
commit c79be0a336
11 changed files with 1139 additions and 35 deletions

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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:

View File

@@ -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
View 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}