let a book be re-read without duplicating everything in it

ingestِ دوباره جایگزین نمی‌کرد: qdrant_point_id یک uuid4 تازه به ازای هر ردیف
است، پس خواندنِ دوباره‌ی یک صفحه نسخه‌ی تکراری کنارِ نسخه‌ی قدیمی می‌گذاشت و
جستجو نمی‌توانست تشخیص بدهد کدام کدام است.

--refresh {figures,unjudgeable,both,all} و --refresh-pages اضافه شدند.

سه تصمیمِ طراحی که عمدی‌اند:

۱. پاک‌سازی صفحه‌به‌صفحه است و درست پیش از خواندنِ همان صفحه، نه یک‌جا از اول.
   وسطِ تست یک crash همین را ثابت کرد: فقط یک صفحه خالی ماند (۲۷۹→۲۷۶) و بقیه‌ی
   کتاب دست‌نخورده. پاک‌سازیِ یک‌جا همان crash را به ۸۱ صفحه‌ی خالی تبدیل می‌کرد.

۲. اول Qdrant، بعد دیتابیس. شناسه‌ی نقطه فقط در ردیفِ DB است، پس ترتیبِ برعکس
   یعنی شکستِ Qdrant نقاط را برای همیشه بی‌صاحب می‌گذارد — نه قابلِ یافتن، نه
   قابلِ پاک کردن.

۳. order از Max گرفته می‌شود نه count: بازخوانی ردیف‌ها را از وسط پاک می‌کند و
   count عددی می‌داد که با orderهای موجود تصادم داشت.

تخمینِ هزینه حالا از اندازه‌گیریِ واقعیِ پنلِ صورتحساب می‌آید نه از جدولِ قیمت،
و وعده‌ی «با Batch API نصف» حذف شد چون GapGPT اصلاً /v1/batches ندارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 12:36:16 +03:30
parent b115ccd37a
commit fa347915c4
3 changed files with 277 additions and 23 deletions

View File

@@ -7,11 +7,13 @@ DB (LessonChunk، منبعِ حقیقت) + upsert به Qdrant (index).
"""
import logging
import os
import re
import tempfile
from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.db.models import Max
from ..models import IngestedPage, LessonChunk
from .chunking import chunk_and_summarize
@@ -30,11 +32,26 @@ logger = logging.getLogger(__name__)
MODEL_PRICE_PER_MTOK = {
'gpt-4o': (2.50, 10.00),
'gpt-4o-mini': (0.15, 0.60),
'gpt-4.1-mini': (0.40, 1.60),
'gemini-3.6-flash': (0.30, 2.50),
}
# هزینه‌ی واقعیِ هر صفحه — از پنلِ صورتحسابِ GapGPT اندازه‌گیری شده (۱۴۰۵/۰۶/۲۸،
# سه صفحه‌ی حسابان یازدهم)، نه از جدولِ بالا. قیمتِ GapGPT با قیمتِ رسمیِ
# OpenAI یکی نیست و gemini را مثلاً سه برابرِ gpt-4o حساب می‌کند، پس تخمینِ
# مبتنی بر جدول برای انتخابِ مدل گمراه‌کننده بود.
MEASURED_PAGE_COST_USD = {
'gpt-4o': 0.00766,
'gpt-4.1-mini': 0.00222,
'gemini-3.6-flash': 0.02135,
}
def estimate_page_cost(model: str) -> float:
"""هزینه‌ی تقریبیِ یک صفحه بر پایه‌ی مصرفِ اندازه‌گیری‌شده‌ی واقعی."""
"""هزینه‌ی تقریبیِ یک صفحه؛ اگر اندازه‌گیریِ واقعی داریم، همان."""
measured = MEASURED_PAGE_COST_USD.get(model)
if measured is not None:
return measured
price_in, price_out = MODEL_PRICE_PER_MTOK.get(model, (2.50, 10.00))
return 1425 / 1e6 * price_in + 400 / 1e6 * price_out
@@ -292,6 +309,68 @@ def _textbook_path(book: Textbook) -> Path:
return root / book.filename
# ارجاع به چیزی که در متن نیامده. «روبه‌رو»/«مقابل» هم هست چون کتاب‌های
# ریاضی مرتب «شکلِ روبه‌رو» و «جدولِ مقابل» می‌نویسند بی‌آنکه واژه‌ی «شکل» را
# در همان جمله بیاورند.
FIGURE_REFERENCE = re.compile(r'شکل|جدول|نمودار|تصویر|روبه‌رو|مقابل')
def pages_needing_refresh(collection: str, *, figures: bool = True,
unjudgeable: bool = True) -> set[int]:
"""
صفحه‌هایی که ارزشِ خواندنِ دوباره دارند.
figures : صفحه‌ای که دستِ‌کم یک chunkش به شکل/جدولِ نوشته‌نشده ارجاع دارد.
unjudgeable : صفحه‌ای که دستِ‌کم یک chunkش در بازبینی «قابلِ قضاوت نیست» شده.
⚠️ واحد «صفحه» است نه chunk، چون vision یک صفحه را یک‌جا می‌خواند. پس
بازخوانیِ یک صفحه chunkهای سالمِ همان صفحه را هم دور می‌ریزد و از نو
می‌سازد — نسخه‌ی تازه معمولاً بهتر است ولی `solution_verdict`ش پاک می‌شود
و باید دوباره verify شود. فرمان این عدد را قبل از خرج کردن نشان می‌دهد.
"""
pages: set[int] = set()
fields = ['source_page', 'problem', 'solution', 'solution_verdict']
for chunk in (LessonChunk.objects
.filter(collection=collection, source_page__isnull=False)
.only(*fields)):
if unjudgeable and chunk.solution_verdict == 'unjudgeable':
pages.add(chunk.source_page)
elif figures and FIGURE_REFERENCE.search(
f"{chunk.problem or ''} {chunk.solution or ''}"
):
pages.add(chunk.source_page)
return pages
def clear_page(collection: str, page: int) -> dict:
"""
پاک‌کردنِ کاملِ یک صفحه: نقاطِ Qdrant، ردیف‌های LessonChunk، و نشانه‌ی
IngestedPage — تا `_done_pages` دیگر آن را «انجام‌شده» نبیند.
⚠️ ترتیب عمدی است: اول Qdrant، بعد دیتابیس. `qdrant_point_id` یک uuid4 است
که فقط در ردیفِ DB نگهداری می‌شود، پس اگر اول ردیف‌ها را پاک کنیم و بعد
حذفِ Qdrant شکست بخورد، شناسه‌ها از بین رفته‌اند و آن نقاط برای همیشه
بی‌صاحب می‌مانند — نه قابلِ پیدا کردن، نه قابلِ پاک کردن. با این ترتیب،
شکستِ Qdrant یعنی استثنا بالا می‌رود و DB دست‌نخورده می‌ماند.
این تابع لازم است چون ingestِ دوباره **جایگزین نمی‌کند**: هر ردیفِ تازه
uuid4ِ تازه می‌گیرد، پس بدونِ پاک‌سازی، خواندنِ دوباره‌ی یک صفحه نسخه‌ی
تکراری کنارِ نسخه‌ی قدیمی می‌گذارد.
"""
chunks = list(
LessonChunk.objects
.filter(collection=collection, source_page=page)
.only('id', 'qdrant_point_id')
)
point_ids = [str(c.qdrant_point_id) for c in chunks]
if point_ids:
delete_points(collection, point_ids)
LessonChunk.objects.filter(pk__in=[c.pk for c in chunks]).delete()
marks, _ = IngestedPage.objects.filter(collection=collection, page=page).delete()
return {'page': page, 'chunks_deleted': len(chunks), 'points_deleted': len(point_ids),
'marks_deleted': marks}
def _done_pages(collection: str, chapter_title: str) -> set[int]:
"""
صفحه‌هایی که نباید دوباره پردازش شوند.
@@ -314,14 +393,22 @@ def _done_pages(collection: str, chapter_title: str) -> set[int]:
def ingest_book(*, subject: str, grade: str, chapters=None, dpi: int = 200,
max_pages: int | None = None, resume: bool = True,
progress=None) -> dict:
refresh_pages=None, progress=None) -> dict:
"""
یک کتابِ درسیِ کامل از data/textbooks/ را فصل‌به‌فصل ingest می‌کند.
chapters : فهرستِ شماره‌ی فصل‌ها؛ None یعنی همه‌ی فصل‌های کتاب.
max_pages : سقفِ تعدادِ صفحه‌ی پردازش‌شده در این اجرا (برای کنترلِ هزینه).
resume : صفحه‌هایی که قبلاً chunk ساخته‌اند دوباره پردازش نشوند.
progress : callback(dict) برای گزارشِ زنده به فرمانِ مدیریتی.
chapters : فهرستِ شماره‌ی فصل‌ها؛ None یعنی همه‌ی فصل‌های کتاب.
max_pages : سقفِ تعدادِ صفحه‌ی پردازش‌شده در این اجرا (برای کنترلِ هزینه).
resume : صفحه‌هایی که قبلاً chunk ساخته‌اند دوباره پردازش نشوند.
refresh_pages: شماره‌ی صفحه‌های PDF که باید **بازخوانی** شوند — دادهٔ قبلیِ
هر کدام درست پیش از خواندنِ دوباره‌اش پاک می‌شود. برای
جبرانِ صفحاتی که پیش از قاعده‌ی self-contained خوانده شده‌اند.
progress : callback(dict) برای گزارشِ زنده به فرمانِ مدیریتی.
⚠️ پاک‌سازی عمداً صفحه‌به‌صفحه و بلافاصله قبل از پردازشِ همان صفحه انجام
می‌شود، نه یک‌جا در ابتدا: اگر اجرا وسطِ کار قطع شود (Ctrl-C، تمام شدنِ
اعتبار، سقفِ حساب)، فقط همان یک صفحه بدونِ داده می‌ماند و بقیه دست‌نخورده‌اند.
پاک‌سازیِ یک‌جا یعنی یک قطعیِ ساده، ۲۶۹ صفحه را خالی جا می‌گذارد.
خروجی: خلاصه‌ی اجرا؛ هیچ استثنایی برای خطای یک صفحه بالا نمی‌آید — صفحه‌ی
خطادار در `failed_pages` گزارش می‌شود و اجرای بعدی دوباره سراغش می‌رود.
@@ -357,20 +444,29 @@ def ingest_book(*, subject: str, grade: str, chapters=None, dpi: int = 200,
progress(event)
totals = {'pages_done': 0, 'pages_skipped': 0, 'chunks_created': 0,
'prompt_tokens': 0, 'completion_tokens': 0}
'chunks_replaced': 0, 'prompt_tokens': 0, 'completion_tokens': 0}
failed_pages: list[int] = []
budget = max_pages
refresh = {int(p) for p in (refresh_pages or ())}
for chapter in wanted:
first_pdf, last_pdf = book.pdf_pages(chapter)
already = _done_pages(collection, chapter.title) if resume else set()
# صفحه‌ای که برای بازخوانی علامت خورده «انجام‌شده» حساب نمی‌شود، وگرنه
# منطقِ ازسرگیری ردش می‌کرد و بازخوانی بی‌سروصدا هیچ کاری نمی‌کرد.
already -= refresh
emit(kind='chapter', chapter=chapter.number, title=chapter.title,
pages=(first_pdf, last_pdf), already=len(already))
# order در هر فصل ادامه‌دار می‌ماند تا اجرای دوم شماره‌ها را از نو نزند.
order = LessonChunk.objects.filter(
collection=collection, chapter_title=chapter.title,
).count()
# از بیشینه گرفته می‌شود نه از تعداد: بازخوانی ردیف‌هایی را از وسط پاک
# می‌کند و آن‌وقت count() عددی می‌داد که با orderهای موجود تصادم داشت.
order = (
LessonChunk.objects
.filter(collection=collection, chapter_title=chapter.title)
.aggregate(m=Max('order'))['m'] or 0
)
for page in range(first_pdf, last_pdf + 1):
if page in already:
@@ -380,6 +476,29 @@ def ingest_book(*, subject: str, grade: str, chapters=None, dpi: int = 200,
emit(kind='budget_reached')
return _book_result(book, collection, totals, failed_pages, partial=True)
# پاک‌سازی فقط وقتی که واقعاً می‌خواهیم همین صفحه را بخوانیم —
# بعد از چکِ بودجه، تا رسیدن به سقف صفحه‌ای را خالی جا نگذارد.
if page in refresh:
try:
removed = clear_page(collection, page)
except Exception as exc: # noqa: BLE001
# پاک‌سازی نشد یعنی خواندنِ دوباره نسخه‌ی تکراری می‌سازد؛
# پس این صفحه را اصلاً نمی‌خوانیم و دست‌نخورده رد می‌شویم.
logger.exception('rag.ingest_book: پاک‌سازیِ صفحه %s شکست خورد', page)
failed_pages.append(page)
emit(kind='page_failed', page=page,
error=f'پاک‌سازی برای بازخوانی شکست خورد: {exc}')
# بودجه اینجا هم کم می‌شود، مثلِ هر صفحه‌ی خطادارِ دیگر.
# فراخوانیِ vision نرفته، ولی اگر Qdrant به‌کل قطع باشد
# بدونِ این، حلقه کلِ فصل را بی‌ثمر طی می‌کند و
# `--max-pages` — که کاربر برای مهارِ همین اجرا گذاشته —
# هیچ اثری نمی‌گذارد.
if budget is not None:
budget -= 1
continue
totals['chunks_replaced'] += removed['chunks_deleted']
emit(kind='page_cleared', **removed)
try:
images = pdf_service.extract_page_images(
str(path), first_page=page, last_page=page, dpi=dpi,
@@ -472,6 +591,7 @@ def _book_result(book: Textbook, collection: str, totals: dict,
'pages_processed': totals['pages_done'],
'pages_skipped': totals['pages_skipped'],
'chunks_created': totals['chunks_created'],
'chunks_replaced': totals.get('chunks_replaced', 0),
'failed_pages': failed_pages,
'prompt_tokens': totals['prompt_tokens'],
'completion_tokens': totals['completion_tokens'],