diff --git a/rag/services/known_collections.py b/rag/services/known_collections.py index 4a6519c..bf2a96a 100644 --- a/rag/services/known_collections.py +++ b/rag/services/known_collections.py @@ -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) — کلیدِ داخلی هم شماره‌ی فصل و هم # رشته‌ی کامل رو می‌پذیره. diff --git a/rag/services/pdf.py b/rag/services/pdf.py index a8580ac..55c3cc0 100644 --- a/rag/services/pdf.py +++ b/rag/services/pdf.py @@ -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 بتواند # هزینه‌ی واقعی را گزارش کند. تا پیش از این فقط تخمینِ سرانگشتی داشتیم و # آن تخمین ۲.۳ برابرِ واقعیت بود. diff --git a/rag/services/textbooks.py b/rag/services/textbooks.py index c7307af..f00158e 100644 --- a/rag/services/textbooks.py +++ b/rag/services/textbooks.py @@ -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` رویشان کار می‌کند.