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

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