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>
206 lines
11 KiB
Python
206 lines
11 KiB
Python
"""
|
||
ساختِ برنامهی درسیِ واقعی (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
|