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>
This commit is contained in:
2026-10-12 05:35:18 +03:30
parent 3d51b3b83a
commit 8cb1ecb85e
32 changed files with 81218 additions and 4486 deletions

113
content/node_split.py Normal file
View File

@@ -0,0 +1,113 @@
"""
شکستنِ جزیره‌های بلند به جزیره‌های حداکثر ۶ درسی (۲۰۲۶-۱۰-۱۲؛ نخست ۱۰ بود، به خواستِ مالک: «جزیره‌ها زود تموم می‌شن، صدها جزیره»).
چرا: `seed_curriculum_from_textbook` هر «درسِ فهرستِ کتاب» را یک جزیره می‌کند. حسابانِ دوازدهم
فقط ۱۲ جزیره دارد با میانگینِ ۲۷ و حداکثرِ ۴۳ نوعِ مسئله، در حالی‌که پایه‌های دیگر ۲۸ تا ۵۳ جزیره‌ی
۸ تا ۱۰ درسی دارند. حجمِ سوال یکی بود، ولی دانش‌آموز «تعداد جزیره» را می‌بیند و هر جزیره‌ی بلند فقط
یک آزمونِ چهارسوالی می‌دهد (سینیور درست می‌گفت: کم است).
مهم‌ترین قیدها:
- شناسه‌ی درس‌ها (Lesson.id) و سوال‌ها دست نمی‌خورد؛ فقط `Lesson.node` عوض می‌شود. پس
UserLessonCompletion/UserAnswer همه‌جا درست می‌ماند.
- جزیره‌ی اصلی بخشِ اول می‌ماند (همان id)، بخش‌های بعدی جزیره‌ی تازه‌اند.
- کسی که جزیره‌ی اصلی را با آزمون تمام کرده بود، همان را برای بخش‌های تازه هم تمام‌شده دارد
(ردیفِ UserNodeCompletion/NodeProgress با xp و سکه‌ی صفر و زمانِ اصلی؛ XP دوبار شمرده نشود و
لیدربورد/گزارشِ هفتگی تغییر نکند). بدونِ این، پیشرفتِ کاربرانِ فعلی عقب می‌رفت.
- ایدمپوتنت: جزیره‌ای که از قبل کوتاه است (≤ max_lessons) دست نمی‌خورد، پس اجرای دوباره
چیزی را دوباره نمی‌شکند.
نسنجیده: اجرا روی دیتابیسی که کاربرِ واقعی در وسطِ آزمونِ یک جزیره‌ی بلند دارد (آزمون ۴ سوالِ
ثابت با seed `nodeId:userId` است؛ شناسه‌ی جزیره‌ی بخشِ اول همان است، پس آزمونِ نیمه‌کاره‌اش
فقط ممکن است با درس‌های کمتر ساخته شود).
"""
import math
_FA = str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹')
def part_count(n_lessons: int, max_lessons: int, part_cap: int) -> int:
"""جزیره‌ی تا max_lessons درس دست نمی‌خورد؛ بلندتر به کمترین تعدادی تکه می‌شود که هیچ‌کدام از part_cap بزرگ‌تر نشود."""
if n_lessons <= max_lessons:
return 1
return max(2, math.ceil(n_lessons / part_cap))
def balanced_chunks(items: list, parts: int) -> list[list]:
"""parts تکه‌ی پشتِ‌هم با اندازه‌های حداکثر یک واحد اختلاف (اولی‌ها بزرگ‌تر)."""
size, extra = divmod(len(items), parts)
out, start = [], 0
for i in range(parts):
end = start + size + (1 if i < extra else 0)
out.append(items[start:end])
start = end
return out
def part_title(base: str, index: int) -> str:
return f'{base} ({str(index).translate(_FA)})'
def split_long_nodes(*, Node, Lesson, UserNodeCompletion, NodeProgress, units, max_lessons=7, part_cap=6,
dry_run=False) -> dict:
"""
units: queryset از Unit؛ مدل‌ها پارامتر می‌شوند تا هم migration (مدل‌های تاریخی) و هم دستورِ مدیریتی
از همین کد استفاده کنند.
"""
counts = {'nodes_split': 0, 'nodes_created': 0, 'lessons_moved': 0, 'completions_cloned': 0}
for unit in units:
nodes = list(Node.objects.filter(unit=unit).order_by('order', 'id'))
plan = []
for node in nodes:
lessons = list(Lesson.objects.filter(node=node).order_by('id').values_list('id', flat=True))
parts = part_count(len(lessons), max_lessons, part_cap)
plan.append((node, balanced_chunks(lessons, parts) if parts > 1 else None))
if not any(chunks for _node, chunks in plan):
continue
if dry_run:
for node, chunks in plan:
if chunks:
counts['nodes_split'] += 1
counts['nodes_created'] += len(chunks) - 1
counts['lessons_moved'] += sum(len(c) for c in chunks[1:])
continue
order = 0
for node, chunks in plan:
if not chunks:
order += 1
if node.order != order:
Node.objects.filter(pk=node.pk).update(order=order)
continue
base = node.title
counts['nodes_split'] += 1
completions = list(UserNodeCompletion.objects.filter(node=node))
progresses = list(NodeProgress.objects.filter(node=node, status='completed'))
for index, lesson_ids in enumerate(chunks, start=1):
order += 1
title = part_title(base, index)
if index == 1:
Node.objects.filter(pk=node.pk).update(title=title, order=order)
continue
new = Node.objects.create(unit=unit, title=title, order=order, icon=node.icon, color=node.color)
counts['nodes_created'] += 1
counts['lessons_moved'] += Lesson.objects.filter(pk__in=lesson_ids).update(node=new)
clones = UserNodeCompletion.objects.bulk_create([
UserNodeCompletion(
user_id=c.user_id, node=new, correct_count=c.correct_count,
total_questions=c.total_questions, stars=c.stars, xp_earned=0, coins_earned=0,
idempotency_key=None,
) for c in completions
])
# created_at از auto_now_add است و در bulk_create نادیده گرفته نمی‌شود؛ بعداً به زمانِ اصلی برمی‌گردد.
for clone, original in zip(clones, completions):
UserNodeCompletion.objects.filter(pk=clone.pk).update(created_at=original.created_at)
counts['completions_cloned'] += len(clones)
NodeProgress.objects.bulk_create([
NodeProgress(
user_id=p.user_id, node=new, status='completed',
lessons_completed_count=len(lesson_ids), started_at=p.started_at,
completed_at=p.completed_at, last_activity_at=p.last_activity_at,
) for p in progresses
])
# بخشِ اول فقط درس‌های خودش را نگه می‌دارد: درس‌های بخش‌های بعدی بالا جابه‌جا شدند.
return counts