anchor a question to its lesson by page, not by a title that keeps changing

chunkها با تطبیقِ دقیقِ رشته به درس وصل می‌شدند (problem_type == lesson.title).
عنوانِ درس از یک استخراجِ قدیمی آمده بود و هر بازخوانیِ کتاب عنوانِ تازه می‌سازد،
پس بعد از بازخوانیِ امروز فقط ۵ تا ۸ درصدِ عنوان‌ها می‌خوردند — یعنی ۹۵٪ درس‌ها
بی‌هیچ خطایی هیچ نامزدی پیدا نمی‌کردند و ساختِ سوال صفر برمی‌گرداند.

لنگرِ پایدار صفحه است: هر Node دقیقاً یک بخشِ فهرستِ کتاب با بازه‌ی صفحه‌ی مشخص
است (تطبیق ۱۰۰٪: ۳۴/۳۴، ۲۸/۲۸، ۲۳/۲۳) و هر chunk هم source_page دارد. وقتی
عنوان نخورد، از روی بازه‌ی صفحه سراغ chunk می‌رویم — بدونِ هیچ فراخوانیِ مدلی.

نتیجه: هشتم ۰→۲۰۱، نهم ۰→۴۹، دهم ۰→۱۳۴.

رفتارِ قدیم با --strict-title حفظ شد، و یک محافظ گذاشته شد که یک chunk به دو
درس داده نشود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 12:36:45 +03:30
parent 96bda905db
commit d15a28799a
2 changed files with 125 additions and 0 deletions

View File

@@ -100,6 +100,10 @@ class Command(BaseCommand):
parser.set_defaults(verified_only=True)
parser.add_argument('--workers', type=int, default=4,
help='فراخوانیِ هم‌زمان. بیش از ۴ روی این پروکسی ۴۲۹ می‌گیرد.')
parser.add_argument('--strict-title', dest='by_page', action='store_false',
help='فقط chunkهایی که عنوانشان دقیقاً با عنوانِ درس یکی است '
'(رفتارِ قدیم؛ بعد از بازخوانیِ کتاب‌ها تقریباً هیچ‌چیز پیدا نمی‌کند).')
parser.set_defaults(by_page=True)
def handle(self, *args, **opts):
book = get_textbook(opts['subject'], opts['grade'])
@@ -117,6 +121,7 @@ class Command(BaseCommand):
)
collection = build_collection_name(book.subject, book.grade)
self._book = book
targets = self._targets(grade, collection, opts)
if not targets:
self.stdout.write(self.style.WARNING('هیچ درسی بدونِ سوال پیدا نشد — کاری لازم نیست.'))
@@ -192,6 +197,35 @@ class Command(BaseCommand):
))
# ------------------------------------------------------------------
def _page_span(self, lesson):
"""بازه‌ی صفحه‌ی PDF برای نودِ این درس، از فهرستِ خودِ کتاب."""
node = lesson.node
book = self._book
if book is None:
return None
for chapter in book.chapters:
if chapter.title != node.unit.title:
continue
for section in chapter.resolved_sections():
if section.title == node.title:
off = book.page_offset
return section.first_page + off, section.last_page + off
# نودی که بخشِ متناظر ندارد: کلِ فصل را بازه بگیر.
return book.pdf_pages(chapter)
return None
def _chunks_by_page(self, lesson, collection, need, opts, used):
span = self._page_span(lesson)
if span is None:
return []
first, last = span
qs = (LessonChunk.objects
.filter(collection=collection, source_page__gte=first, source_page__lte=last)
.exclude(problem='').exclude(pk__in=used))
if opts['verified_only']:
qs = qs.filter(solution_verdict=LessonChunk.VERDICT_AGREES)
return list(qs.order_by('source_page', 'order')[:need])
def _targets(self, grade, collection, opts):
"""[(lesson, chunk), ...] — فقط درس‌هایی که هنوز سوال کم دارند."""
lessons = (
@@ -204,6 +238,7 @@ class Command(BaseCommand):
lessons = lessons.filter(node__unit__order__in=opts['chapters'])
targets = []
used: set[int] = set() # یک chunk فقط به یک درس داده شود
for lesson in lessons:
have = lesson.questions.count()
need = opts['per_lesson'] - have
@@ -218,7 +253,21 @@ class Command(BaseCommand):
candidates = candidates.filter(
solution_verdict=LessonChunk.VERDICT_AGREES)
chunks = list(candidates.order_by('source_page', 'order')[:need])
# تطبیقِ عنوان شکننده است: عنوانِ درس از یک استخراجِ قدیمی آمده و
# هر بار که کتاب دوباره خوانده شود مدل عنوانِ تازه می‌سازد. بعد از
# بازخوانیِ ۱۴۰۵/۰۶/۲۹ فقط ۵ تا ۸ درصدِ عنوان‌ها می‌خوردند، یعنی
# ۹۵٪ درس‌ها بی‌سروصدا هیچ نامزدی پیدا نمی‌کردند.
#
# لنگرِ پایدار، صفحه است: هر Node دقیقاً یک بخشِ فهرستِ کتاب با
# بازه‌ی صفحه‌ی مشخص است، و هر chunk هم `source_page` دارد. پس
# chunkهای همان بازه به درس‌های همان نود می‌خورند — بدونِ هیچ
# فراخوانیِ مدلی.
if not chunks and opts['by_page']:
chunks = self._chunks_by_page(lesson, collection, need, opts, used)
for chunk in chunks:
used.add(chunk.pk)
targets.append((lesson, chunk))
if opts['limit'] and len(targets) >= opts['limit']:
return targets