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