Files
MathApp/rag/services/pdf.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

177 lines
7.9 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 کتابِ درسی + تحلیلِ عکسِ صفحه با مدلِ vision.
برداشتِ پاک‌سازی‌شده از mathapp/services/read_pdf.py و info_question.py مخزنِ
Rag_backend. به‌جای نوشتنِ نتیجه در یک فایلِ JSON روی دیسک، دیکشنری
برمی‌گردونه تا لایه‌ی ingest تصمیم بگیره.
pymupdf (fitz) و pdf2image اختیاری‌ان؛ نبودشون فقط این ماژول رو از کار
می‌ندازه (RagNotConfigured)، نه بقیه‌ی پروژه رو.
"""
import base64
import json
from io import BytesIO
from django.conf import settings
from . import clients
from .clients import RagNotConfigured
def extract_page_images(pdf_path: str, first_page: int = 1, dpi: int = 200,
last_page: int | None = None):
"""
لیستی از تصاویرِ PIL برای صفحاتِ PDF در بازه‌ی [first_page, last_page].
`last_page` برای کتابِ کامل ضروری است: بدونِ آن pdf2image کلِ کتاب را
یک‌جا به تصویر تبدیل می‌کند (۱۵۰+ صفحه در حافظه) و امکانِ ingest فصل‌به‌فصل
هم از بین می‌رود.
"""
try:
from pdf2image import convert_from_path
except ModuleNotFoundError as exc:
raise RagNotConfigured('pdf2image نصب نیست یا poppler موجود نیست.') from exc
return convert_from_path(pdf_path, dpi=dpi, first_page=first_page, last_page=last_page)
def detect_grade_and_start_page(pdf_path: str) -> tuple[int, str | None]:
"""
از متنِ صفحه‌ی اولِ PDF، پایه‌ی تحصیلی و صفحه‌ای که محتوای واقعی از اونجا
شروع می‌شه رو حدس می‌زنه (مثلِ نسخه‌ی Rag_backend).
"""
try:
import fitz # PyMuPDF
except ModuleNotFoundError as exc:
raise RagNotConfigured('pymupdf (fitz) نصب نیست.') from exc
# صفحه‌ی شروعِ محتوای واقعی؛ هرچه اینجا نباشد پیش‌فرضِ ۷ می‌گیرد.
start_pages = {'پنجم دبستان': 11, 'ششم دبستان': 9, 'نهم': 9, 'دهم': 7}
# ترتیب مهم است: «دهم» زیررشته‌ی «نهم» نیست ولی «هفتم»/«هشتم» در متنِ
# شناسنامه‌ی کتابِ نهم هم می‌آیند («در ادامهٔ پایه‌های هفتم و هشتم»)، پس
# پایه‌های بالاتر باید زودتر بررسی شوند.
grades = ['دهم', 'نهم', 'هشتم', 'هفتم', 'اول دبستان', 'دوم دبستان',
'سوم دبستان', 'چهارم دبستان', 'پنجم دبستان', 'ششم دبستان']
doc = fitz.open(pdf_path)
try:
text = doc[0].get_text()
finally:
doc.close()
grade = next((g for g in grades if g in text), None)
return start_pages.get(grade, 7), grade
_VISION_JSON_TEMPLATE = """
{
"grade": "",
"chapter": "",
"problemTypes": [
{"type": "", "keywords": [], "generalSolution": "",
"exampleFromImage": {"problem": "", "solution": ""}}
]
}
"""
# پایه → توصیفِ معلمِ متناسب. پرامپت قبلاً «معلمِ ابتدایی، بچه‌های ۸ تا ۱۲
# ساله» را هاردکد کرده بود؛ برای پایه‌های نهم/دهم این هم لحنِ توضیح را بچگانه
# می‌کرد و هم مدل را به ساده‌سازیِ بیش از حدِ مفاهیم (اتحاد، مثلثات، تابع)
# سوق می‌داد.
_TEACHER_BY_GRADE = {
'اول دبستان': 'a specialist elementary math teacher for children aged 6 to 7',
'دوم دبستان': 'a specialist elementary math teacher for children aged 7 to 8',
'سوم دبستان': 'a specialist elementary math teacher for children aged 8 to 9',
'چهارم دبستان': 'a specialist elementary math teacher for children aged 9 to 10',
'پنجم دبستان': 'a specialist elementary math teacher for children aged 10 to 11',
'ششم دبستان': 'a specialist elementary math teacher for children aged 11 to 12',
'هفتم': 'a specialist middle-school math teacher for students aged 12 to 13',
'هشتم': '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',
}
def teacher_persona(grade: str) -> str:
return _TEACHER_BY_GRADE.get(
(grade or '').strip(),
'a specialist school math teacher',
)
def extract_problem_types(image, grade: str, chapter_name: str) -> dict:
"""
یک عکسِ صفحه‌ی کتاب → ساختارِ JSONِ انواعِ مسئله (فارسی)، با مدلِ vision.
خروجی همون شکلی‌ست که uploadِ ریاضیِ تیمِ دیگه هم تولید می‌کرد.
"""
prompt = f"""
You are {teacher_persona(grade)}.
Analyze the uploaded image and extract every unique math problem type for the given chapter.
For each type produce: type, keywords (3-7, Persian), generalSolution (step-by-step),
exampleFromImage (one real problem from the image + full solution).
Output must follow EXACTLY this JSON structure:
{_VISION_JSON_TEMPLATE}
Rules:
- Everything in Persian (Farsi).
- grade = "{grade}"
- chapter = "{chapter_name}"
- exampleFromImage must be a real problem from the image (not invented).
- Keep the mathematical notation of the textbook; do not simplify the concept
to a lower grade level than "{grade}".
- If the page has no math problem at all (cover, foreword, table of contents,
a page of pure text), return an empty "problemTypes" list instead of inventing one.
- Output ONLY the JSON object. No markdown, no backticks.
"""
buffered = BytesIO()
image.save(buffered, format='JPEG')
img_b64 = base64.b64encode(buffered.getvalue()).decode()
client = clients.get_openai_client()
response = client.chat.completions.create(
model=settings.OPENAI_VISION_MODEL,
messages=[
{'role': 'system', 'content': 'You process educational visual content and respond ONLY with valid JSON.'},
{'role': 'user', 'content': [
{'type': 'text', 'text': prompt},
{'type': 'image_url', 'image_url': {'url': f'data:image/jpeg;base64,{img_b64}', 'detail': 'high'}},
]},
],
max_tokens=3000,
temperature=0.1,
)
raw = (response.choices[0].message.content or '').strip()
if raw.startswith('```json'):
raw = raw[len('```json'):].strip()
if raw.startswith('```'):
raw = raw[3:].strip()
if raw.endswith('```'):
raw = raw[:-3].strip()
parsed = json.loads(raw)
# مصرفِ واقعیِ همین فراخوانی را همراهِ نتیجه برمی‌گردانیم تا ingest بتواند
# هزینه‌ی واقعی را گزارش کند. تا پیش از این فقط تخمینِ سرانگشتی داشتیم و
# آن تخمین ۲.۳ برابرِ واقعیت بود.
usage = getattr(response, 'usage', None)
parsed['_usage'] = {
'promptTokens': getattr(usage, 'prompt_tokens', 0) or 0,
'completionTokens': getattr(usage, 'completion_tokens', 0) or 0,
}
return parsed
def problem_type_to_text(problem_type: dict) -> str:
"""یک problemType → متنِ قابلِ embedding (مثلِ doc_text در Rag_backend)."""
example = problem_type.get('exampleFromImage', {}) or {}
keywords = ' '.join(problem_type.get('keywords', []) or [])
return (
f"Type: {problem_type.get('type', '')}\n"
f"Keywords: {keywords}\n"
f"General solution: {problem_type.get('generalSolution', '')}\n"
f"Example problem: {example.get('problem', '')}\n"
f"Example solution: {example.get('solution', '')}"
).strip()