Files
MathApp/content/management/commands/seed_curriculum_from_textbook.py
Zareian 8cb1ecb85e Subjects per grade (math, calculus, geometry, stats, discrete), 5 new textbooks, 202 new islands, long islands split, hand-written figures and lesson texts for the new books
- content.Subject + Unit.subject, /api/home?subject=, tutor grounded in the chosen book
- 5 new books ingested; ~1150 hand-written questions, islands named by hand
- node_split: islands over 7 lessons split into parts of <=6 (lesson ids and progress kept)
- Lesson.figures hand-authored and visually checked for 653 lessons of the new books;
  gen_lesson_figures got --subject and survives network errors
- seed files re-exported

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 05:35:18 +03:30

215 lines
12 KiB
Python
Raw Permalink 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, Subject, 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-4', '#FFB300'),
'پنجم': ('grade-5', '#FF7043'),
'ششم': ('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},
)
# کتاب باید در جدولِ Subject باشد (migrationِ 0015 پنج‌تایی را می‌سازد؛ کتابِ تازه از ادمین).
subject = Subject.objects.filter(title=book.subject).first()
if subject is None:
raise CommandError(
f'کتابِ «{book.subject}» در جدولِ Subject نیست؛ از ادمین (محتوا ← کتاب‌ها) بسازید.'
)
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, 'subject': subject},
)
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