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

@@ -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()
parsed = json.loads(raw)
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
# مدل در متنِ ریاضی LaTeX می‌نویسد (\frac، \sqrt، \vec) و بک‌اسلشِ تنها
# در JSON نامعتبر است، پس json.loads کلِ صفحه را دور می‌ریخت. این دقیقاً
# صفحه‌های پرفرمول — باارزش‌ترین‌ها — را بی‌سروصدا حذف می‌کرد؛ روی حسابان
# یازدهم/دوازدهم چهار صفحه هرچه تلاش شد «خطادار» ماندند تا همین‌جا پیدا شد.
parsed = json.loads(repair_json_escapes(raw))
# مصرفِ واقعیِ همین فراخوانی را همراهِ نتیجه برمی‌گردانیم تا ingest بتواند
# هزینه‌ی واقعی را گزارش کند. تا پیش از این فقط تخمینِ سرانگشتی داشتیم و
# آن تخمین ۲.۳ برابرِ واقعیت بود.