Files
MathApp/rag/services/textbooks.py
Zareian b115ccd37a stop losing the pages that matter most to a stray backslash
مدلِ vision در متنِ ریاضی LaTeX می‌نویسد (\frac، \sqrt، \vec) و بک‌اسلشِ تنها
در JSON نامعتبر است، پس json.loads کلِ صفحه را دور می‌ریخت — دقیقاً صفحه‌های
پرفرمول، یعنی باارزش‌ترین‌ها. روی حسابان یازدهم/دوازدهم چهار صفحه هرچه تلاش
شد «خطادار» ماندند تا همین‌جا پیدا شد.

⚠️ نسخه‌ی اولِ تعمیر خودش تله داشت: \f، \t، \b، \r و \n از نظرِ JSON معتبرند،
پس «سالم» رد می‌شدند و \frac به formfeed+«rac» و \theta به tab+«heta» تبدیل
می‌شد — خرابیِ بی‌صدا به‌جای خطای بلند. قاعده‌ی تفکیک: دستورِ LaTeX همیشه با
حرفِ ASCII ادامه می‌یابد، ولی escapeِ واقعی با رقم، فارسی یا پایانِ رشته.

کنارش: پایه‌های یازدهم و دوازدهم به persona و تشخیصِ پایه اضافه شدند («دوازدهم»
پیش از «دهم» بررسی می‌شود چون زیررشته‌اش را دارد)، سقفِ max_tokens از ۳۰۰۰ به
۶۰۰۰ رفت چون قاعده‌ی self-contained خروجی را بلندتر کرده، و حسابان یازدهم و
دوازدهم با نگاشتِ فصل‌به‌صفحه ثبت شدند.

«حسابان» به MATH_SUBJECTS اضافه شد: بدونِ آن retrieval این کتاب‌ها را «درسِ
متنی» می‌دید و به‌جای chapter روی lesson_number فیلتر می‌کرد — بی‌هیچ خطایی
همیشه صفر نتیجه.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 12:35:46 +03:30

382 lines
18 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),
),
)
# ── حسابان ─────────────────────────────────────────────────────────────
# «حسابان» کتابِ رشته‌ی ریاضی‌فیزیک است، نه «ریاضی» عمومی. subject عمداً
# «حسابان» است تا collection جدا بماند: هم‌نامیِ آن با «ریاضی یازدهم»
# (کتابِ تجربی) دو کتابِ متفاوت را سرِ یک collection می‌انداخت.
# ⚠️ هر subjectِ تازه‌ی ریاضی‌وار باید به known_collections.MATH_SUBJECTS هم
# اضافه شود، وگرنه retrieval آن را «درسِ متنی» می‌بیند و به‌جای `chapter`
# روی `lesson_number` فیلتر می‌کند — یعنی بی‌سروصدا هیچ‌وقت چیزی برنمی‌گرداند.
#
# بازه‌ها از فهرستِ خودِ کتاب (PDF صفحه‌ی ۵) درآمده‌اند و page_offset با
# تطبیقِ صفحه‌ی ۱ کتاب روی صفحه‌ی ۷ PDF تأیید شده است.
CALCULUS_11 = Textbook(
subject='حسابان', grade='یازدهم', grade_label='یازدهم',
filename='حسابان یازدهم.pdf', page_offset=6,
chapters=_ch(
(1, 'فصل ۱: جبر و معادله', 1, 36, (
(1, 'مجموع جمله‌های دنباله‌های حسابی و هندسی', 2),
(2, 'معادله‌های درجه دوم', 7),
(3, 'معادله‌های گویا و گنگ', 17),
(4, 'قدر مطلق و ویژگی‌های آن', 23),
(5, 'آشنایی با هندسه تحلیلی', 29),
)),
(2, 'فصل ۲: تابع', 37, 70, (
(1, 'آشنایی بیشتر با تابع', 38),
(2, 'انواع توابع', 44),
(3, 'وارون تابع', 54),
(4, 'اعمال روی توابع', 63),
)),
(3, 'فصل ۳: توابع نمایی و لگاریتمی', 71, 90, (
(1, 'تابع نمایی', 72),
(2, 'تابع لگاریتمی و لگاریتم', 80),
(3, 'ویژگی‌های لگاریتم و حل معادله‌های لگاریتمی', 86),
)),
(4, 'فصل ۴: مثلثات', 91, 112, (
(1, 'رادیان', 92),
(2, 'نسبت‌های مثلثاتی برخی زوایا', 98),
(3, 'توابع مثلثاتی', 105),
(4, 'روابط مثلثاتی مجموع و تفاضل زوایا', 110),
)),
(5, 'فصل ۵: حد و پیوستگی', 113, 151, (
(1, 'مفهوم حد و فرایندهای حدی', 114),
(2, 'حدهای یک‌طرفه (حد چپ و حد راست)', 123),
(3, 'قضایای حد', 130),
(4, 'محاسبه حد توابع کسری', 141),
(5, 'پیوستگی', 145),
)),
),
)
CALCULUS_12 = Textbook(
subject='حسابان', grade='دوازدهم', grade_label='دوازدهم',
filename='حسابان دوازدهم.pdf', page_offset=6,
chapters=_ch(
(1, 'فصل ۱: تابع', 1, 22, (
(1, 'تبدیل نمودار توابع', 2),
(2, 'تابع درجه سوم، توابع یکنوا و بخش‌پذیری و تقسیم', 13),
)),
(2, 'فصل ۲: مثلثات', 23, 44, (
(1, 'تناوب و تانژانت', 24),
(2, 'معادله‌های مثلثاتی', 35),
)),
(3, 'فصل ۳: حدهای نامتناهی ـ حد در بی‌نهایت', 45, 70, (
(1, 'حدهای نامتناهی', 46),
(2, 'حد در بی‌نهایت', 59),
)),
(4, 'فصل ۴: مشتق', 71, 110, (
(1, 'آشنایی با مفهوم مشتق', 72),
(2, 'مشتق‌پذیری و پیوستگی', 84),
(3, 'آهنگ متوسط تغییر و آهنگ لحظه‌ای تغییر', 102),
)),
(5, 'فصل ۵: کاربردهای مشتق', 111, 146, (
(1, 'اکسترمم‌های یک تابع و توابع صعودی و نزولی', 112),
(2, 'جهت تقعر نمودار یک تابع و نقطه‌ی عطف آن', 127),
(3, 'رسم نمودار تابع', 137),
)),
),
)
TEXTBOOKS: dict[str, Textbook] = {
b.key: b for b in (MATH_3, MATH_6, MATH_7, MATH_8, MATH_9, MATH_10,
CALCULUS_11, CALCULUS_12)
}
# کتاب‌هایی که واقعاً فایلِ 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}