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:
@@ -100,6 +100,10 @@ class Command(BaseCommand):
|
|||||||
parser.set_defaults(verified_only=True)
|
parser.set_defaults(verified_only=True)
|
||||||
parser.add_argument('--workers', type=int, default=4,
|
parser.add_argument('--workers', type=int, default=4,
|
||||||
help='فراخوانیِ همزمان. بیش از ۴ روی این پروکسی ۴۲۹ میگیرد.')
|
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):
|
def handle(self, *args, **opts):
|
||||||
book = get_textbook(opts['subject'], opts['grade'])
|
book = get_textbook(opts['subject'], opts['grade'])
|
||||||
@@ -117,6 +121,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
collection = build_collection_name(book.subject, book.grade)
|
collection = build_collection_name(book.subject, book.grade)
|
||||||
|
self._book = book
|
||||||
targets = self._targets(grade, collection, opts)
|
targets = self._targets(grade, collection, opts)
|
||||||
if not targets:
|
if not targets:
|
||||||
self.stdout.write(self.style.WARNING('هیچ درسی بدونِ سوال پیدا نشد — کاری لازم نیست.'))
|
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):
|
def _targets(self, grade, collection, opts):
|
||||||
"""[(lesson, chunk), ...] — فقط درسهایی که هنوز سوال کم دارند."""
|
"""[(lesson, chunk), ...] — فقط درسهایی که هنوز سوال کم دارند."""
|
||||||
lessons = (
|
lessons = (
|
||||||
@@ -204,6 +238,7 @@ class Command(BaseCommand):
|
|||||||
lessons = lessons.filter(node__unit__order__in=opts['chapters'])
|
lessons = lessons.filter(node__unit__order__in=opts['chapters'])
|
||||||
|
|
||||||
targets = []
|
targets = []
|
||||||
|
used: set[int] = set() # یک chunk فقط به یک درس داده شود
|
||||||
for lesson in lessons:
|
for lesson in lessons:
|
||||||
have = lesson.questions.count()
|
have = lesson.questions.count()
|
||||||
need = opts['per_lesson'] - have
|
need = opts['per_lesson'] - have
|
||||||
@@ -218,7 +253,21 @@ class Command(BaseCommand):
|
|||||||
candidates = candidates.filter(
|
candidates = candidates.filter(
|
||||||
solution_verdict=LessonChunk.VERDICT_AGREES)
|
solution_verdict=LessonChunk.VERDICT_AGREES)
|
||||||
chunks = list(candidates.order_by('source_page', 'order')[:need])
|
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:
|
for chunk in chunks:
|
||||||
|
used.add(chunk.pk)
|
||||||
targets.append((lesson, chunk))
|
targets.append((lesson, chunk))
|
||||||
if opts['limit'] and len(targets) >= opts['limit']:
|
if opts['limit'] and len(targets) >= opts['limit']:
|
||||||
return targets
|
return targets
|
||||||
|
|||||||
@@ -270,3 +270,79 @@ class HealthTrafficTests(TestCase):
|
|||||||
self.assertNotIn('09121234567', raw)
|
self.assertNotIn('09121234567', raw)
|
||||||
self.assertNotIn('علی', raw)
|
self.assertNotIn('علی', raw)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionGenerationPageBridgeTests(TestCase):
|
||||||
|
"""
|
||||||
|
پلِ بینِ chunkهای کتاب و درسهای مسیرِ بازی.
|
||||||
|
|
||||||
|
تطبیق از روی عنوان شکننده است: عنوانِ درس از یک استخراجِ قدیمی میآید و هر
|
||||||
|
بازخوانیِ کتاب عنوانِ تازه میسازد. بعد از بازخوانیِ ۱۴۰۵/۰۶/۲۹ فقط ۵ تا ۸
|
||||||
|
درصدِ عنوانها میخوردند و ۹۵٪ درسها **بیهیچ خطایی** بدونِ نامزد میماندند.
|
||||||
|
لنگرِ پایدار شمارهی صفحه است: هر Node یک بخشِ فهرستِ کتاب با بازهی مشخص.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from content.models import Grade, Lesson, Node, Unit
|
||||||
|
from rag.models import LessonChunk
|
||||||
|
from rag.services.textbooks import TEXTBOOKS
|
||||||
|
|
||||||
|
self.book = TEXTBOOKS['ریاضی نهم']
|
||||||
|
chapter = self.book.chapters[0]
|
||||||
|
section = chapter.resolved_sections()[0]
|
||||||
|
self.section = section
|
||||||
|
|
||||||
|
grade = Grade.objects.create(title='پایه نهم')
|
||||||
|
unit = Unit.objects.create(grade=grade, title=chapter.title, order=chapter.number)
|
||||||
|
node = Node.objects.create(unit=unit, title=section.title, order=1)
|
||||||
|
# عنوانِ درس عمداً با هیچ chunkی یکی نیست — همان حالتی که بعد از
|
||||||
|
# بازخوانی پیش آمد.
|
||||||
|
self.lesson = Lesson.objects.create(
|
||||||
|
node=node, title='عنوانی که دیگر وجود ندارد', intro_text='x')
|
||||||
|
|
||||||
|
self.chunk = LessonChunk.objects.create(
|
||||||
|
collection='ریاضی نهم', subject='ریاضی', lesson_number=1,
|
||||||
|
chapter=chapter.number, chapter_title=chapter.title, order=1,
|
||||||
|
title=chapter.title, original_text='x', summary='x',
|
||||||
|
problem_type='نوعِ تازهی استخراجشده',
|
||||||
|
problem='۲ + ۲ چند است؟', solution='۴',
|
||||||
|
source_page=section.first_page + self.book.page_offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _targets(self, **over):
|
||||||
|
from content.management.commands.generate_questions_from_chunks import Command
|
||||||
|
from content.models import Grade
|
||||||
|
|
||||||
|
opts = {'chapters': None, 'per_lesson': 1, 'limit': None,
|
||||||
|
'verified_only': False, 'by_page': True}
|
||||||
|
opts.update(over)
|
||||||
|
cmd = Command()
|
||||||
|
cmd._book = self.book
|
||||||
|
return cmd._targets(Grade.objects.get(title='پایه نهم'), 'ریاضی نهم', opts)
|
||||||
|
|
||||||
|
def test_a_chunk_in_the_nodes_page_range_is_used_even_if_the_title_differs(self):
|
||||||
|
targets = self._targets()
|
||||||
|
self.assertEqual([(l.pk, c.pk) for l, c in targets],
|
||||||
|
[(self.lesson.pk, self.chunk.pk)])
|
||||||
|
|
||||||
|
def test_strict_title_mode_finds_nothing(self):
|
||||||
|
# رفتارِ قدیم، نگه داشته شده تا بشود مقایسه کرد.
|
||||||
|
self.assertEqual(self._targets(by_page=False), [])
|
||||||
|
|
||||||
|
def test_a_chunk_outside_the_page_range_is_not_borrowed(self):
|
||||||
|
self.chunk.source_page = self.section.last_page + self.book.page_offset + 50
|
||||||
|
self.chunk.save(update_fields=['source_page'])
|
||||||
|
self.assertEqual(self._targets(), [])
|
||||||
|
|
||||||
|
def test_unverified_chunks_are_skipped_by_default(self):
|
||||||
|
# ساختنِ سوال از راهحلی که تأیید نشده، خطای کتاب را به دانشآموز میبرد.
|
||||||
|
self.assertEqual(self._targets(verified_only=True), [])
|
||||||
|
|
||||||
|
def test_one_chunk_is_never_given_to_two_lessons(self):
|
||||||
|
from content.models import Lesson
|
||||||
|
|
||||||
|
second = Lesson.objects.create(
|
||||||
|
node=self.lesson.node, title='درسِ دوم', intro_text='x')
|
||||||
|
targets = self._targets()
|
||||||
|
self.assertEqual(len(targets), 1)
|
||||||
|
self.assertNotIn(second.pk, [l.pk for l, _ in targets])
|
||||||
|
|||||||
Reference in New Issue
Block a user