From d15a28799a7a34864e1aef168c546f664c9ed21a Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 20 Sep 2026 12:36:45 +0330 Subject: [PATCH] anchor a question to its lesson by page, not by a title that keeps changing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chunkها با تطبیقِ دقیقِ رشته به درس وصل می‌شدند (problem_type == lesson.title). عنوانِ درس از یک استخراجِ قدیمی آمده بود و هر بازخوانیِ کتاب عنوانِ تازه می‌سازد، پس بعد از بازخوانیِ امروز فقط ۵ تا ۸ درصدِ عنوان‌ها می‌خوردند — یعنی ۹۵٪ درس‌ها بی‌هیچ خطایی هیچ نامزدی پیدا نمی‌کردند و ساختِ سوال صفر برمی‌گرداند. لنگرِ پایدار صفحه است: هر Node دقیقاً یک بخشِ فهرستِ کتاب با بازه‌ی صفحه‌ی مشخص است (تطبیق ۱۰۰٪: ۳۴/۳۴، ۲۸/۲۸، ۲۳/۲۳) و هر chunk هم source_page دارد. وقتی عنوان نخورد، از روی بازه‌ی صفحه سراغ chunk می‌رویم — بدونِ هیچ فراخوانیِ مدلی. نتیجه: هشتم ۰→۲۰۱، نهم ۰→۴۹، دهم ۰→۱۳۴. رفتارِ قدیم با --strict-title حفظ شد، و یک محافظ گذاشته شد که یک chunk به دو درس داده نشود. Co-Authored-By: Claude Opus 5 --- .../generate_questions_from_chunks.py | 49 ++++++++++++ content/tests_question_io.py | 76 +++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/content/management/commands/generate_questions_from_chunks.py b/content/management/commands/generate_questions_from_chunks.py index c1b53d4..442749c 100644 --- a/content/management/commands/generate_questions_from_chunks.py +++ b/content/management/commands/generate_questions_from_chunks.py @@ -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 diff --git a/content/tests_question_io.py b/content/tests_question_io.py index 72a6ef1..591bf43 100644 --- a/content/tests_question_io.py +++ b/content/tests_question_io.py @@ -270,3 +270,79 @@ class HealthTrafficTests(TestCase): self.assertNotIn('09121234567', 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])