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:
113
content/node_split.py
Normal file
113
content/node_split.py
Normal 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
|
||||
Reference in New Issue
Block a user