- 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>
114 lines
6.8 KiB
Python
114 lines
6.8 KiB
Python
"""
|
||
شکستنِ جزیرههای بلند به جزیرههای حداکثر ۶ درسی (۲۰۲۶-۱۰-۱۲؛ نخست ۱۰ بود، به خواستِ مالک: «جزیرهها زود تموم میشن، صدها جزیره»).
|
||
|
||
چرا: `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
|