Files
MathApp/rag/services/textbooks.py
Zareian c79be0a336 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>
2026-09-13 18:59:03 +03:30

303 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
نقشه‌ی فیزیکیِ فایل‌های 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}