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:
@@ -13,7 +13,8 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from rag.services.clients import RagNotConfigured
|
||||
from rag.services.config import build_collection_name, rag_is_configured
|
||||
from rag.services.ingest import _done_pages, estimate_page_cost, ingest_book
|
||||
from rag.services.ingest import (_done_pages, estimate_page_cost, ingest_book,
|
||||
pages_needing_refresh)
|
||||
from rag.services.textbooks import TEXTBOOKS, get_textbook
|
||||
from rag.models import IngestedPage, LessonChunk
|
||||
|
||||
@@ -33,6 +34,18 @@ class Command(BaseCommand):
|
||||
help='صفحههای قبلاً ثبتشده را هم دوباره پردازش کن (نقطهی تکراری میسازد).')
|
||||
parser.add_argument('--plan', action='store_true',
|
||||
help='فقط نشان بده چه چیزی پردازش میشود؛ هیچ فراخوانیِ پولی نکن.')
|
||||
parser.add_argument('--refresh', choices=['figures', 'unjudgeable', 'both', 'all'],
|
||||
default=None,
|
||||
help='صفحههای معیوب را دوباره بخوان: دادهٔ قبلیِ هر صفحه '
|
||||
'درست پیش از خواندنِ دوباره پاک میشود. '
|
||||
'figures=ارجاع به شکل/جدولِ نوشتهنشده، '
|
||||
'unjudgeable=رأیِ «قابلِ قضاوت نیست»، both=هر دو، '
|
||||
'all=همهی صفحههای فصلهای انتخابشده.')
|
||||
parser.add_argument('--refresh-pages', default='',
|
||||
help='بهجای انتخابِ خودکار، همین صفحههای PDF را بازخوانی کن '
|
||||
'(مثلاً: 12,15,20-24).')
|
||||
parser.add_argument('--yes', action='store_true',
|
||||
help='بدونِ پرسشِ تأیید ادامه بده (بازخوانی دادهٔ موجود را پاک میکند).')
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
subject, grade = opts['subject'], opts['grade']
|
||||
@@ -54,8 +67,14 @@ class Command(BaseCommand):
|
||||
if not wanted:
|
||||
raise CommandError('هیچ فصلی با شمارههای دادهشده پیدا نشد.')
|
||||
|
||||
refresh = self._resolve_refresh(opts, collection, book, wanted)
|
||||
|
||||
if opts['plan']:
|
||||
return self._plan(book, collection, wanted)
|
||||
return self._plan(book, collection, wanted, refresh)
|
||||
|
||||
if refresh and not opts['yes'] and not self._confirm(collection, refresh):
|
||||
self.stdout.write('لغو شد؛ هیچچیزی پاک یا خرج نشد.')
|
||||
return
|
||||
|
||||
if not rag_is_configured():
|
||||
raise CommandError(
|
||||
@@ -70,7 +89,8 @@ class Command(BaseCommand):
|
||||
subject=book.subject, grade=book.grade,
|
||||
chapters=[c.number for c in wanted],
|
||||
dpi=opts['dpi'], max_pages=opts['max_pages'],
|
||||
resume=not opts['no_resume'], progress=self._progress,
|
||||
resume=not opts['no_resume'], refresh_pages=refresh,
|
||||
progress=self._progress,
|
||||
)
|
||||
except (RagNotConfigured, FileNotFoundError, ValueError) as exc:
|
||||
raise CommandError(str(exc)) from exc
|
||||
@@ -82,6 +102,12 @@ class Command(BaseCommand):
|
||||
f"{result['pages_skipped']} صفحه از قبل موجود بود، "
|
||||
f"{result['chunks_created']} chunk ساخته شد."
|
||||
))
|
||||
if result.get('chunks_replaced'):
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"بازخوانی: {result['chunks_replaced']} chunkِ قدیمی پاک و با نسخهی تازه "
|
||||
f"جایگزین شد. رأیِ بازبینیِ اینها پاک شده — "
|
||||
f"«python manage.py rag_verify_solutions» را دوباره اجرا کنید."
|
||||
))
|
||||
self.stdout.write(
|
||||
f"مصرف: {result['prompt_tokens']:,} توکن ورودی + "
|
||||
f"{result['completion_tokens']:,} خروجی → ≈ ${result['cost_usd']:.2f} "
|
||||
@@ -94,34 +120,105 @@ class Command(BaseCommand):
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _plan(self, book, collection, wanted):
|
||||
@staticmethod
|
||||
def _parse_pages(spec: str) -> set[int]:
|
||||
"""«12,15,20-24» → {12, 15, 20, 21, 22, 23, 24}"""
|
||||
pages: set[int] = set()
|
||||
for part in (p.strip() for p in spec.split(',') if p.strip()):
|
||||
if '-' in part:
|
||||
first, _, last = part.partition('-')
|
||||
try:
|
||||
pages.update(range(int(first), int(last) + 1))
|
||||
except ValueError as exc:
|
||||
raise CommandError(f'بازهی نامعتبر در --refresh-pages: {part!r}') from exc
|
||||
else:
|
||||
try:
|
||||
pages.add(int(part))
|
||||
except ValueError as exc:
|
||||
raise CommandError(f'شمارهی نامعتبر در --refresh-pages: {part!r}') from exc
|
||||
return pages
|
||||
|
||||
def _resolve_refresh(self, opts, collection, book, wanted) -> set[int]:
|
||||
"""کدام صفحهها بازخوانی شوند — محدود به فصلهای انتخابشده."""
|
||||
if opts['refresh_pages']:
|
||||
pages = self._parse_pages(opts['refresh_pages'])
|
||||
elif opts['refresh'] == 'all':
|
||||
# همهی صفحههای فصلهای این اجرا. انتخابِ هدفمند به سیگنالهایی
|
||||
# تکیه میکند که اندازهگیری نشان داد نویزیاند (regexِ «شکل» هم
|
||||
# «به شکل مخروطی» را میگیرد، و ۷۷٪ از رأیهای unjudgeable از همان
|
||||
# پیشفیلترِ گشاد آمده)، و اختلافِ هزینهاش ناچیز است.
|
||||
pages = set()
|
||||
for chapter in wanted:
|
||||
first, last = book.pdf_pages(chapter)
|
||||
pages.update(range(first, last + 1))
|
||||
elif opts['refresh']:
|
||||
mode = opts['refresh']
|
||||
pages = pages_needing_refresh(
|
||||
collection,
|
||||
figures=mode in ('figures', 'both'),
|
||||
unjudgeable=mode in ('unjudgeable', 'both'),
|
||||
)
|
||||
else:
|
||||
return set()
|
||||
|
||||
# فقط صفحههایی که داخلِ فصلهای همین اجرا هستند؛ وگرنه --chapters
|
||||
# بیاثر میشد و یک اجرای محدود کلِ کتاب را پاک میکرد.
|
||||
in_scope: set[int] = set()
|
||||
for chapter in wanted:
|
||||
first, last = book.pdf_pages(chapter)
|
||||
in_scope.update(p for p in pages if first <= p <= last)
|
||||
return in_scope
|
||||
|
||||
def _confirm(self, collection, refresh) -> bool:
|
||||
chunks = LessonChunk.objects.filter(
|
||||
collection=collection, source_page__in=refresh)
|
||||
verified = chunks.filter(solution_verdict='agrees').count()
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'\n⚠️ بازخوانی {len(refresh)} صفحه را پاک و دوباره میسازد: '
|
||||
f'{chunks.count()} chunk، که {verified} تایشان رأیِ «agrees» دارند '
|
||||
f'و رأیشان از بین میرود.'
|
||||
))
|
||||
answer = input('ادامه بدهم؟ [yes/no] ').strip().lower()
|
||||
return answer in ('y', 'yes', 'بله')
|
||||
|
||||
def _plan(self, book, collection, wanted, refresh=frozenset()):
|
||||
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||
f'نقشهی «{book.key}» → collection «{collection}» (فایل: {book.filename})'
|
||||
))
|
||||
total_pages = total_left = 0
|
||||
total_pages = total_left = total_refresh = 0
|
||||
for chapter in wanted:
|
||||
first, last = book.pdf_pages(chapter)
|
||||
pages = last - first + 1
|
||||
done = len(_done_pages(collection, chapter.title))
|
||||
left = max(pages - done, 0)
|
||||
again = sum(1 for p in refresh if first <= p <= last)
|
||||
left = max(pages - done, 0) + again
|
||||
total_pages += pages
|
||||
total_left += left
|
||||
total_refresh += again
|
||||
self.stdout.write(
|
||||
f' فصل {chapter.number}: {chapter.title}\n'
|
||||
f' صفحهی کتاب {chapter.first_page}–{chapter.last_page}'
|
||||
f' → صفحهی PDF {first}–{last} ({pages} صفحه، {done} انجامشده، {left} مانده)'
|
||||
f' → صفحهی PDF {first}–{last} ({pages} صفحه، {done} انجامشده، {left} مانده'
|
||||
+ (f'، {again} بازخوانی)' if again else ')')
|
||||
)
|
||||
self.stdout.write('')
|
||||
self.stdout.write(f'جمع: {total_pages} صفحه، {total_left} صفحه مانده.')
|
||||
if total_refresh:
|
||||
chunks = LessonChunk.objects.filter(
|
||||
collection=collection, source_page__in=refresh)
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'از آن، {total_refresh} صفحه بازخوانی است: {chunks.count()} chunkِ موجود '
|
||||
f'پاک و از نو ساخته میشود ({chunks.filter(solution_verdict="agrees").count()} '
|
||||
f'تایشان رأیِ «agrees» دارند و باید دوباره verify شوند).'
|
||||
))
|
||||
from django.conf import settings
|
||||
|
||||
model = settings.OPENAI_VISION_MODEL
|
||||
per_page = estimate_page_cost(model)
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f'هر صفحه یک فراخوانیِ vision دارد ({model}، ~${per_page:.4f} در صفحه، '
|
||||
f'اندازهگیریشده روی ۱۱۱ صفحهی واقعی): '
|
||||
f'حدود ${total_left * per_page:.2f} برای صفحههای مانده '
|
||||
f'(با Batch API نصف: ${total_left * per_page / 2:.2f}).'
|
||||
f'از پنلِ صورتحساب اندازهگیری شده): '
|
||||
f'حدود ${total_left * per_page:.2f} برای صفحههای مانده.'
|
||||
))
|
||||
|
||||
def _progress(self, event):
|
||||
@@ -138,5 +235,9 @@ class Command(BaseCommand):
|
||||
self.stdout.write(self.style.ERROR(
|
||||
f" صفحه {event['page']}: خطا — {event['error'][:120]}"
|
||||
))
|
||||
elif kind == 'page_cleared':
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f" صفحه {event['page']}: {event['chunks_deleted']} chunkِ قدیمی پاک شد"
|
||||
))
|
||||
elif kind == 'budget_reached':
|
||||
self.stdout.write(self.style.WARNING(' سقفِ --max-pages پر شد؛ توقف.'))
|
||||
|
||||
Reference in New Issue
Block a user