Files
MathApp/content/management/commands/seed_curriculum_from_textbook.py
Zareian 82edd0ecb0 bring حسابان 11 and 12 into the game path
Both books were already complete on Qdrant and answering /api/ai/ask, but
had no Grade/Unit/Node/Lesson rows, so none of their problems could ever
reach a quiz. The owner made the product call, so the curriculum is now
seeded from the books' own tables of contents (free, no API credit), and
559 four-option questions were authored from the verified chunks.

Coverage: all 21 nodes of grade 11 hold a full four-question quiz, and 10
of 12 in grade 12. The two that do not are both in فصل ۵: کاربردهای مشتق
and are short only because the weekly model limit landed mid-run — their
(lesson, problem) pairs are already exported and waiting.

Three supporting changes:

- generate_questions_from_chunks grows --export DIR, which writes the
  (lesson, verified problem) pairs as JSON batches and makes no paid call
  at all. The subagent route was the documented way to author questions
  but had no tool behind it; now it does, and it emits the same PROMPT
  the paid path uses so the two branches cannot drift apart in quality.

- _targets now skips as many chunks as a lesson already has questions
  for. Re-running with a higher --per-lesson used to hand back the very
  same chunk, producing a near-duplicate question — silently, because the
  prompt gets rewritten each time, so neither the run nor question_io's
  validation could catch it.

- GRADE_STYLE gains یازدهم and دوازدهم. Without them both grades fell to
  the grey fallback; they are deliberately cool colours, separate from the
  six general grades, because حسابان is a ریاضی‌فیزیک-only book.

458 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 03:35:26 +03:30

206 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ساختِ برنامه‌ی درسیِ واقعی (Grade → Unit → Node → Lesson) از روی ساختارِ
کتابِ درسی و chunkهای ingest‌شده.
چرا لازم بود: تا پیش از این کلِ محتوای دیتابیس ردیف‌های «[DEMO] پایهٔ نمونه»
و «[SEED] پایه سوم» بود — یعنی دانش‌آموزِ پایه نهم در دمو «جمع تا ۱۰» می‌دید.
سینیور خواست «یک بیسیکِ کامل با دیتای واقعی کار کند».
نگاشت:
Grade ← پایه‌ی کتاب («پایه نهم»)
Unit ← فصلِ کتاب («فصل ۴: توان و ریشه»)
Node ← درسِ داخلِ فصل، از فهرستِ کتاب («توان صحیح»)
Lesson ← نوعِ مسئله‌ی استخراج‌شده در بازه‌ی صفحه‌ی همان درس
سوال‌ها اینجا ساخته نمی‌شوند: ساختنِ گزینه‌های انحرافیِ درست به مدل نیاز دارد
(`generate_questions_from_chunks`). این فرمان هیچ فراخوانیِ پولی ندارد.
python manage.py seed_curriculum_from_textbook --grade نهم --dry-run
python manage.py seed_curriculum_from_textbook --grade نهم
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from content.models import Grade, Lesson, Node, Unit
from rag.models import LessonChunk
from rag.services.config import build_collection_name
from rag.services.textbooks import TEXTBOOKS, get_textbook
GRADE_STYLE = {
'ششم': ('grade-6', '#4CAF50'),
'هفتم': ('grade-7', '#2196F3'),
'هشتم': ('grade-8', '#9C27B0'),
'نهم': ('grade-9', '#FF9800'),
'دهم': ('grade-10', '#E91E63'),
# حسابان: رشته‌ی ریاضی‌فیزیک، پس عمداً رنگِ سرد و جدا از شش پایه‌ی عمومی
# تا در نقشه‌ی بازی به‌چشم یک مسیرِ دیگر بیاید، نه ادامه‌ی همان‌ها.
'یازدهم': ('grade-11', '#00BCD4'),
'دوازدهم': ('grade-12', '#3F51B5'),
}
class Command(BaseCommand):
help = 'ساختِ Grade/Unit/Node/Lesson از ساختارِ کتاب و chunkهای ingest‌شده (بدونِ هزینه).'
def add_arguments(self, parser):
parser.add_argument('--subject', default='ریاضی')
parser.add_argument('--grade', required=True, help='مثلاً نهم')
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--min-chunks', type=int, default=1,
help='درسی که کمتر از این تعداد chunk دارد ساخته نشود.')
parser.add_argument('--group-size', type=int, default=8,
help='وقتی chunkها شماره‌ی صفحه ندارند، هر چند نوع مسئله '
'زیرِ یک گره جمع شود.')
def handle(self, *args, **opts):
subject, grade = opts['subject'], opts['grade']
book = get_textbook(subject, grade)
if book is None:
raise CommandError(
f'کتابی برای «{subject} {grade}» ثبت نشده. موجود: ' + '، '.join(sorted(TEXTBOOKS))
)
collection = build_collection_name(book.subject, book.grade)
if not LessonChunk.objects.filter(collection=collection).exists():
raise CommandError(
f'هیچ chunkی در «{collection}» نیست. اول rag_ingest_book را اجرا کنید.'
)
plan = self._build_plan(book, collection, opts['min_chunks'], opts['group_size'])
self._print_plan(book, plan)
if opts['dry_run']:
self.stdout.write(self.style.WARNING('\n--dry-run: چیزی نوشته نشد.'))
return
with transaction.atomic():
counts = self._apply(book, plan)
self.stdout.write(self.style.SUCCESS(
f"\nساخته/به‌روزرسانی شد: {counts['units']} فصل، {counts['nodes']} درس، "
f"{counts['lessons']} نوعِ مسئله."
))
self.stdout.write(self.style.HTTP_INFO(
'قدمِ بعد: python manage.py generate_questions_from_chunks '
f'--grade {book.grade} (این یکی هزینه دارد)'
))
# ------------------------------------------------------------------
def _build_plan(self, book, collection, min_chunks, group_size):
"""[(chapter, [(node_title, node_order, {نوع: [chunk]}), ...]), ...]"""
plan = []
for chapter in book.chapters:
groups = self._sectioned(book, collection, chapter, min_chunks)
if not groups:
# chunkهای واردشده از Qdrantِ تیمِ دیگر شماره‌ی صفحه ندارند، پس
# نمی‌شود آن‌ها را به درس‌های فهرستِ کتاب نسبت داد. به‌جای جا
# انداختنِ کلِ فصل، انواعِ مسئله را دسته‌دسته زیرِ گره می‌بریم.
groups = self._grouped(collection, chapter, min_chunks, group_size)
if groups:
plan.append((chapter, groups))
return plan
def _sectioned(self, book, collection, chapter, min_chunks):
"""درس‌بندیِ واقعی از فهرستِ کتاب — فقط وقتی chunkها شماره‌ی صفحه دارند."""
groups = []
sections = chapter.resolved_sections()
for position, section in enumerate(sections):
# اولین درس تا ابتدای خودِ فصل عقب کشیده می‌شود: صفحه‌ی بازکننده‌ی
# فصل (که معمولاً قبل از «درس اول» است) در هیچ بازه‌ای نمی‌افتاد و
# chunkهایش بی‌صدا از برنامه‌ی درسی حذف می‌شدند.
start_page = chapter.first_page if position == 0 else section.first_page
first = start_page + book.page_offset
last = section.last_page + book.page_offset
chunks = list(
LessonChunk.objects
.filter(collection=collection, chapter=chapter.number,
source_page__gte=first, source_page__lte=last)
.order_by('source_page', 'order')
)
by_type = self._by_type(chunks, min_chunks)
if by_type:
groups.append((section.title, section.number, by_type))
return groups
def _grouped(self, collection, chapter, min_chunks, group_size):
"""
جایگزین برای فصلی که chunkهایش شماره‌ی صفحه ندارند (دادهٔ واردشده از
Qdrantِ تیمِ دیگر).
⚠️ این گروه‌بندی **مالِ ماست، نه ترتیبِ کتاب**: نقاطِ آن collection نه
شماره‌ی صفحه دارند و نه ترتیب، و scroll هم بر اساسِ UUID برمی‌گرداند
یعنی عملاً تصادفی. پس بر اساسِ نامِ نوعِ مسئله مرتب می‌کنیم تا دستِ‌کم
قطعی و تکرارپذیر باشد، و گره را «گروه» می‌نامیم نه «بخش» تا وانمود
نکند ترتیبِ آموزشیِ کتاب است.
"""
chunks = list(
LessonChunk.objects
.filter(collection=collection, chapter=chapter.number, source_page__isnull=True)
.order_by('order', 'id')
)
by_type = self._by_type(chunks, min_chunks)
if not by_type:
return []
# «فصل ۲: کسر» → «کسر»، تا عنوانِ گره در نقشه‌ی مهارت تکراری نشود.
topic = chapter.title.split(':', 1)[-1].strip() or chapter.title
items = sorted(by_type.items(), key=lambda kv: kv[0])
parts = [items[i:i + group_size] for i in range(0, len(items), group_size)]
return [
(topic if len(parts) == 1 else f'{topic} — گروه {index}', index, dict(part))
for index, part in enumerate(parts, start=1)
]
@staticmethod
def _by_type(chunks, min_chunks):
"""هر نوعِ مسئله یک Lesson؛ یک نوع که در چند جا آمده یکی می‌شود."""
by_type: dict[str, list[LessonChunk]] = {}
for chunk in chunks:
key = (chunk.problem_type or '').strip() or 'تمرین'
by_type.setdefault(key, []).append(chunk)
return {k: v for k, v in by_type.items() if len(v) >= min_chunks}
def _print_plan(self, book, plan):
self.stdout.write(self.style.MIGRATE_HEADING(
f'\nپایه {book.grade} — {len(plan)} فصل با محتوای موجود\n'
))
for chapter, groups in plan:
total = sum(len(t) for _title, _order, t in groups)
self.stdout.write(f' {chapter.title} ({len(groups)} درس، {total} نوع مسئله)')
for title, order, by_type in groups:
self.stdout.write(f' درس {order}: {title} — {len(by_type)} نوع')
def _apply(self, book, plan):
icon, color = GRADE_STYLE.get(book.grade, ('grade', '#607D8B'))
grade_title = f'پایه {book.grade}'
grade, _ = Grade.objects.update_or_create(
title=grade_title, defaults={'icon': icon, 'color': color},
)
counts = {'units': 0, 'nodes': 0, 'lessons': 0}
for chapter, groups in plan:
unit, _ = Unit.objects.update_or_create(
grade=grade, title=chapter.title,
defaults={'order': chapter.number},
)
counts['units'] += 1
for node_title, node_order, by_type in groups:
node, _ = Node.objects.update_or_create(
unit=unit, title=node_title,
defaults={'order': node_order},
)
counts['nodes'] += 1
for order, (problem_type, chunks) in enumerate(by_type.items(), start=1):
# متنِ مقدمه از generalSolutionِ خودِ کتاب می‌آید — محتوای
# واقعیِ آموزشی، بدونِ هیچ فراخوانیِ مدلی.
intro = next(
(c.general_solution for c in chunks if c.general_solution), ''
) or f'{problem_type} — {node_title}'
Lesson.objects.update_or_create(
node=node, title=problem_type,
defaults={'intro_text': intro, 'mascot_animation': 'explain'},
)
counts['lessons'] += 1
return counts