Files
MathApp/content/management/commands/seed_curriculum_from_textbook.py
Zareian 78130e7194 build the real curriculum from the textbook structure
تا پیش از این کلِ محتوای دیتابیس ردیف‌های [DEMO] و [SEED] بود — دانش‌آموزِ
پایه نهم در دمو «جمع تا ۱۰» می‌دید. سینیور «یک بیسیکِ کامل با دیتای واقعی»
خواسته بود.

seed_curriculum_from_textbook (رایگان، هیچ فراخوانیِ پولی ندارد):
    Grade  ← پایه‌ی کتاب
    Unit   ← فصلِ کتاب
    Node   ← درسِ داخلِ فصل، از فهرستِ خودِ کتاب
    Lesson ← نوعِ مسئله‌ی استخراج‌شده در بازه‌ی صفحه‌ی همان درس
intro_text مستقیم از generalSolution کتاب می‌آید.

generate_questions_from_chunks سوالِ چهارگزینه‌ای با گزینه‌های انحرافیِ
باورپذیر می‌سازد (هزینه‌دار، اعتبارسنجیِ سخت‌گیرانه). سوال اینجا جدا شد
چون ساختنِ گزینه‌ی انحرافیِ درست به مدل نیاز دارد.

purge_demo_data ردیف‌های [SEED]/[DEMO] را گزارش و — فقط با --apply — پاک
می‌کند؛ بدونِ آن این ردیف‌ها در دمو داخلِ API دیده می‌شوند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:00:10 +03:30

202 lines
10 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'),
}
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