Files
MathApp/content/node_split.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

114 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
"""
شکستنِ جزیره‌های بلند به جزیره‌های حداکثر ۶ درسی (۲۰۲۶-۱۰-۱۲؛ نخست ۱۰ بود، به خواستِ مالک: «جزیره‌ها زود تموم می‌شن، صدها جزیره»).
چرا: `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