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

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}