- rag: MATH_4 / MATH_5 textbooks (TOC and page offsets read from the PDFs) - content: grades 4 and 5 in the seed files: 7 units / 28 nodes each, 204 + 194 questions (every node >= 4), all re-solved blind once - subscription: Plan.bazaar_sku (admin); POST /api/subscription/purchase accepts paymentMethod=bazaar for packs, rejects unknown methods (was ignored) - payment: idempotency key cannot switch provider; discount codes refused on Bazaar orders (Bazaar charges the fixed SKU price) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
11 KiB
Python
209 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-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},
|
||
)
|
||
|
||
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
|