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>
This commit is contained in:
2026-09-20 12:35:46 +03:30
parent 8eedbdeac0
commit b115ccd37a
3 changed files with 156 additions and 7 deletions

View File

@@ -20,7 +20,11 @@ TEXT_SUBJECT_LESSON_FILTER_KEY = 'lesson_number'
MATH_LESSON_FILTER_KEY = 'chapter'
# subjectهایی که ساختارِ «ریاضی» دارند (فیلتر با chapter، متن از generalSolution)
MATH_SUBJECTS = {'ریاضی'}
# «حسابان» کتابِ رشته‌ی ریاضی‌فیزیک در پایه‌های یازدهم/دوازدهم است و دقیقاً
# همان شکلِ payload را دارد. اگر اینجا نباشد، retrieval آن را درسِ متنی حساب
# می‌کند و به‌جای `chapter` روی `lesson_number` فیلتر می‌زند — بدونِ هیچ خطایی
# همیشه صفر نتیجه.
MATH_SUBJECTS = {'ریاضی', 'حسابان'}
# فصول ریاضی به تفکیکِ (subject, grade) — کلیدِ داخلی هم شماره‌ی فصل و هم
# رشته‌ی کامل رو می‌پذیره.

View File

@@ -45,12 +45,17 @@ def detect_grade_and_start_page(pdf_path: str) -> tuple[int, str | None]:
raise RagNotConfigured('pymupdf (fitz) نصب نیست.') from exc
# صفحه‌ی شروعِ محتوای واقعی؛ هرچه اینجا نباشد پیش‌فرضِ ۷ می‌گیرد.
start_pages = {'پنجم دبستان': 11, 'ششم دبستان': 9, 'نهم': 9, 'دهم': 7}
start_pages = {'پنجم دبستان': 11, 'ششم دبستان': 9, 'نهم': 9, 'دهم': 7,
'یازدهم': 7, 'دوازدهم': 7}
# ترتیب مهم است: «دهم» زیررشته‌ی «نهم» نیست ولی «هفتم»/«هشتم» در متنِ
# شناسنامه‌ی کتابِ نهم هم می‌آیند («در ادامهٔ پایه‌های هفتم و هشتم»)، پس
# پایه‌های بالاتر باید زودتر بررسی شوند.
grades = ['دهم', 'نهم', 'هشتم', 'هفتم', 'اول دبستان', 'دوم دبستان',
'سوم دبستان', 'چهارم دبستان', 'پنجم دبستان', 'ششم دبستان']
# ترتیب از بالا به پایین است و «دوازدهم»/«یازدهم» باید پیش از «دهم» بیایند:
# هر دو رشته‌ی «دهم» را در خود دارند، پس با ترتیبِ برعکس کتابِ دوازدهم
# «دهم» تشخیص داده می‌شد و صفحه‌ی شروع و لحنِ معلمش غلط می‌افتاد.
grades = ['دوازدهم', 'یازدهم', 'دهم', 'نهم', 'هشتم', 'هفتم', 'اول دبستان',
'دوم دبستان', 'سوم دبستان', 'چهارم دبستان', 'پنجم دبستان',
'ششم دبستان']
doc = fitz.open(pdf_path)
try:
@@ -89,6 +94,9 @@ _TEACHER_BY_GRADE = {
'هشتم': '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',
'یازدهم': 'a specialist high-school math teacher for students aged 16 to 17',
'دوازدهم': 'a specialist high-school math teacher for students aged 17 to 18, '
'preparing them for the university entrance exam',
}
@@ -99,6 +107,53 @@ def teacher_persona(grade: str) -> str:
)
# بک‌اسلش‌هایی که همیشه escapeِ واقعی‌اند و نباید دست بخورند.
_JSON_SAFE_ESCAPES = set('"\\/u')
# این‌ها هم escapeِ معتبرِ JSON‌اند و هم آغازِ دستورهای پرکاربردِ LaTeX:
# \frac \times \theta \beta \rightarrow \nabla. تفکیک از روی نویسه‌ی بعدی.
_JSON_AMBIGUOUS_ESCAPES = set('bfnrt')
def repair_json_escapes(raw: str) -> str:
"""
بک‌اسلشِ نامعتبر در رشته‌های JSON را دوبرابر می‌کند تا قابلِ تجزیه شود.
مدل در متنِ ریاضی LaTeX تولید می‌کند و `\sqrt{2}` از نظرِ JSON خطاست، پس
json.loads کلِ صفحه را دور می‌ریخت — دقیقاً صفحه‌های پرفرمول، یعنی
باارزش‌ترین‌ها.
⚠️ نکته‌ای که نسخه‌ی اولِ همین تابع را غلط کرد: `\f`، `\t`، `\b`، `\r` و
`\n` از نظرِ JSON معتبرند، پس «سالم» رد می‌شدند و `\frac` به formfeed+«rac»
و `\theta` به tab+«heta» تبدیل می‌شد — خرابیِ بی‌صدا به‌جای خطای بلند.
قاعده‌ی تفکیک: دستورِ LaTeX همیشه با حرفِ ASCII ادامه پیدا می‌کند
(`\frac`, `\times`)، ولی escapeِ واقعی با رقم، فاصله، فارسی یا پایانِ
رشته می‌آید (`\n۲.`، `\nحالت`). پس فقط وقتی control character حساب
می‌شود که نویسه‌ی بعدی حرفِ ASCII نباشد.
"""
out: list[str] = []
i, n = 0, len(raw)
while i < n:
ch = raw[i]
if ch != '\\':
out.append(ch)
i += 1
continue
nxt = raw[i + 1] if i + 1 < n else ''
after = raw[i + 2] if i + 2 < n else ''
looks_like_latex = after.isascii() and after.isalpha()
keep = nxt in _JSON_SAFE_ESCAPES or (
nxt in _JSON_AMBIGUOUS_ESCAPES and not looks_like_latex
)
if keep:
out.append(ch)
out.append(nxt)
i += 2
else:
out.append('\\\\')
i += 1
return ''.join(out)
def extract_problem_types(image, grade: str, chapter_name: str) -> dict:
"""
یک عکسِ صفحه‌ی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision.
@@ -159,7 +214,11 @@ Rules:
{'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{img_b64}', 'detail': 'high'}},
]},
],
max_tokens=3000,
# ۳۰۰۰ برای صفحه‌ی متراکم کم است: خروجی وسطِ JSON قطع می‌شود،
# json.loads خطا می‌دهد و کلِ صفحه در failed_pages می‌افتد. قاعده‌ی
# self-contained هم خروجی را بلندتر کرده (توصیفِ شکل داخلِ متن)، پس
# سقف بالا رفت. هزینه‌ی واقعی به توکنِ تولیدشده است نه به این سقف.
max_tokens=6000,
temperature=0.1,
)
raw = (response.choices[0].message.content or '').strip()
@@ -170,7 +229,14 @@ Rules:
if raw.endswith('```'):
raw = raw[:-3].strip()
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
# مدل در متنِ ریاضی LaTeX می‌نویسد (\frac، \sqrt، \vec) و بک‌اسلشِ تنها
# در JSON نامعتبر است، پس json.loads کلِ صفحه را دور می‌ریخت. این دقیقاً
# صفحه‌های پرفرمول — باارزش‌ترین‌ها — را بی‌سروصدا حذف می‌کرد؛ روی حسابان
# یازدهم/دوازدهم چهار صفحه هرچه تلاش شد «خطادار» ماندند تا همین‌جا پیدا شد.
parsed = json.loads(repair_json_escapes(raw))
# مصرفِ واقعیِ همین فراخوانی را همراهِ نتیجه برمی‌گردانیم تا ingest بتواند
# هزینه‌ی واقعی را گزارش کند. تا پیش از این فقط تخمینِ سرانگشتی داشتیم و
# آن تخمین ۲.۳ برابرِ واقعیت بود.

View File

@@ -286,8 +286,87 @@ MATH_7 = Textbook(
),
)
# ── حسابان ─────────────────────────────────────────────────────────────
# «حسابان» کتابِ رشته‌ی ریاضی‌فیزیک است، نه «ریاضی» عمومی. 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)
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` رویشان کار می‌کند.