Diagrams: 14 kinds drawn by the app (plane with constructed points, graphs with computed roots/extrema/tangents, number lines with intervals, solids, charts, tables, Venn, mapping, trees, fractions, grids, groups, clock). The catalog is split by topic and only the matching sections go next to a question (200-1073 tokens instead of 1854). parse() repairs slips seen on 96 real book problems: Persian digits in JSON numbers, 2*pi, // comments, bare items, Venn with Persian set names. Measured: 45/48 valid on the second round, 0/8 on plain arithmetic; visual review of 83: 64 right, 3 weak, 16 wrong. Content: 82 new questions so no island has fewer than four; fill_lesson_texts writes Unit.blurb (58) and Lesson.example/steps (590) from verified book problems, never the lesson's own quiz problem. seed_content only fills empty fields, so admin edits survive a deploy. Tedi unlocks at 500 xp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
9.3 KiB
Python
214 lines
9.3 KiB
Python
"""
|
||
وارد/خارج کردنِ کلِ برنامهی درسی (Grade → Unit → Node → Lesson) بهصورتِ فایل.
|
||
|
||
چرا جدا از question_io: سوالها برگهای درختاند، ولی خودِ درخت هم باید به
|
||
سرور برسد و تا امروز تنها راهش اجرای چند دستور روی همان سرور بود
|
||
(rag_import_collection، سپس seed_curriculum_from_textbook) — که هر دو به
|
||
Qdrant و به آدمی با دسترسیِ SSH وابستهاند.
|
||
|
||
با این فایل، برنامهی درسی یکبار و لوکال ساخته میشود، خروجی گرفته میشود، و
|
||
از پنلِ ادمینِ سرور بالا میرود. سرور نه به Qdrant نیاز دارد نه به ترمینال.
|
||
|
||
قالب، همان ساختارِ تودرتوی خودِ درسها است تا خواندنش برای آدم آسان باشد:
|
||
|
||
{
|
||
"grades": [
|
||
{
|
||
"title": "پایه نهم", "icon": "grade-9", "color": "#FF9800",
|
||
"units": [
|
||
{
|
||
"title": "فصل ۴: توان و ریشه", "order": 4,
|
||
"nodes": [
|
||
{
|
||
"title": "توان صحیح", "order": 1,
|
||
"lessons": [
|
||
{"title": "ضرب توانها", "introText": "..."}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
همسانسازی با عنوان انجام میشود، نه با id: idها بینِ دو دیتابیس یکی نیستند و
|
||
تکیه بر آنها یعنی وارد کردن روی سرور محتوای بیربط را بازنویسی میکند.
|
||
"""
|
||
import json
|
||
|
||
from django.db import transaction
|
||
|
||
from .models import Grade, Lesson, Node, Unit
|
||
|
||
|
||
class CurriculumError(Exception):
|
||
"""خطای قابلِ نمایش به کاربر (نه باگ)."""
|
||
|
||
|
||
def _text(value, field: str, where: str) -> str:
|
||
if not isinstance(value, str) or not value.strip():
|
||
raise CurriculumError(f'{where}: «{field}» خالی یا نامعتبر است.')
|
||
return value.strip()
|
||
|
||
|
||
def parse(raw: bytes) -> list[dict]:
|
||
try:
|
||
data = json.loads(raw.decode('utf-8'))
|
||
except UnicodeDecodeError:
|
||
raise CurriculumError('فایل باید UTF-8 باشد.')
|
||
except json.JSONDecodeError as exc:
|
||
raise CurriculumError(f'JSON نامعتبر است: {exc.msg} (خط {exc.lineno})')
|
||
|
||
grades = data.get('grades') if isinstance(data, dict) else data
|
||
if not isinstance(grades, list) or not grades:
|
||
raise CurriculumError('فایل باید فهرستی از پایهها زیرِ کلیدِ «grades» داشته باشد.')
|
||
|
||
cleaned = []
|
||
for gi, grade in enumerate(grades):
|
||
gwhere = f'پایه {gi + 1}'
|
||
if not isinstance(grade, dict):
|
||
raise CurriculumError(f'{gwhere}: باید شیء باشد.')
|
||
units = grade.get('units') or []
|
||
if not isinstance(units, list):
|
||
raise CurriculumError(f'{gwhere}: «units» باید فهرست باشد.')
|
||
|
||
clean_units = []
|
||
for ui, unit in enumerate(units):
|
||
uwhere = f'{gwhere} › فصل {ui + 1}'
|
||
nodes = unit.get('nodes') or []
|
||
if not isinstance(nodes, list):
|
||
raise CurriculumError(f'{uwhere}: «nodes» باید فهرست باشد.')
|
||
|
||
clean_nodes = []
|
||
for ni, node in enumerate(nodes):
|
||
nwhere = f'{uwhere} › درس {ni + 1}'
|
||
lessons = node.get('lessons') or []
|
||
if not isinstance(lessons, list):
|
||
raise CurriculumError(f'{nwhere}: «lessons» باید فهرست باشد.')
|
||
clean_nodes.append({
|
||
'title': _text(node.get('title'), 'title', nwhere),
|
||
'order': int(node.get('order') or ni + 1),
|
||
'lessons': [
|
||
{
|
||
'title': _text(lesson.get('title'), 'title', f'{nwhere} › نوع {li + 1}'),
|
||
'intro_text': (lesson.get('introText') or lesson.get('intro_text') or ''),
|
||
'example': str(lesson.get('example') or ''),
|
||
'steps': [str(x) for x in (lesson.get('steps') or []) if str(x).strip()][:12],
|
||
}
|
||
for li, lesson in enumerate(lessons)
|
||
],
|
||
})
|
||
|
||
clean_units.append({
|
||
'title': _text(unit.get('title'), 'title', uwhere),
|
||
'blurb': str(unit.get('blurb') or '')[:120],
|
||
'order': int(unit.get('order') or ui + 1),
|
||
'nodes': clean_nodes,
|
||
})
|
||
|
||
cleaned.append({
|
||
'title': _text(grade.get('title'), 'title', gwhere),
|
||
'icon': (grade.get('icon') or ''),
|
||
'color': (grade.get('color') or ''),
|
||
'units': clean_units,
|
||
})
|
||
return cleaned
|
||
|
||
|
||
@transaction.atomic
|
||
def import_curriculum(grades: list[dict]) -> dict:
|
||
"""
|
||
idempotent: اجرای دوبارهی همان فایل چیزی را دو بار نمیسازد.
|
||
|
||
چیزی را هم پاک نمیکند — درسی که در فایل نیست دستنخورده میماند. پاککردن
|
||
عمداً اینجا نیست: یک فایلِ ناقص نباید محتوای سرور را از بین ببرد.
|
||
"""
|
||
counts = {'grades': 0, 'units': 0, 'nodes': 0, 'lessons': 0, 'updated': 0}
|
||
|
||
for grade_row in grades:
|
||
grade, created = Grade.objects.get_or_create(
|
||
title=grade_row['title'],
|
||
defaults={'icon': grade_row['icon'], 'color': grade_row['color']},
|
||
)
|
||
counts['grades'] += 1 if created else 0
|
||
|
||
for unit_row in grade_row['units']:
|
||
unit, created = Unit.objects.get_or_create(
|
||
grade=grade, title=unit_row['title'],
|
||
defaults={'order': unit_row['order']},
|
||
)
|
||
counts['units'] += 1 if created else 0
|
||
# متنِ ادمین برنده است: فقط خانهی خالی از فایل پر میشود
|
||
if unit_row.get('blurb') and not unit.blurb:
|
||
unit.blurb = unit_row['blurb']
|
||
unit.save(update_fields=['blurb'])
|
||
|
||
for node_row in unit_row['nodes']:
|
||
node, created = Node.objects.get_or_create(
|
||
unit=unit, title=node_row['title'],
|
||
defaults={'order': node_row['order']},
|
||
)
|
||
counts['nodes'] += 1 if created else 0
|
||
|
||
for lesson_row in node_row['lessons']:
|
||
lesson, created = Lesson.objects.get_or_create(
|
||
node=node, title=lesson_row['title'],
|
||
defaults={'intro_text': lesson_row['intro_text']},
|
||
)
|
||
changed = []
|
||
if created:
|
||
counts['lessons'] += 1
|
||
elif lesson_row['intro_text'] and lesson.intro_text != lesson_row['intro_text']:
|
||
lesson.intro_text = lesson_row['intro_text']
|
||
changed.append('intro_text')
|
||
# مثال و قدمها فقط وقتی از فایل میآیند که در دیتابیس خالیاند؛ آنچه
|
||
# کسی از پنلِ ادمین نوشته با دیپلوی بازنویسی نمیشود.
|
||
if lesson_row.get('example') and not lesson.example:
|
||
lesson.example = lesson_row['example']
|
||
changed.append('example')
|
||
if lesson_row.get('steps') and not lesson.steps:
|
||
lesson.steps = lesson_row['steps']
|
||
changed.append('steps')
|
||
if changed:
|
||
lesson.save(update_fields=changed)
|
||
if not created:
|
||
counts['updated'] += 1
|
||
|
||
return counts
|
||
|
||
|
||
def export_curriculum(grades_queryset) -> dict:
|
||
"""خروجی دقیقاً همان قالبیست که import میخواند."""
|
||
out = []
|
||
for grade in grades_queryset.prefetch_related('units__nodes__lessons'):
|
||
out.append({
|
||
'title': grade.title,
|
||
'icon': grade.icon,
|
||
'color': grade.color,
|
||
'units': [
|
||
{
|
||
'title': unit.title,
|
||
'order': unit.order,
|
||
**({'blurb': unit.blurb} if unit.blurb else {}),
|
||
'nodes': [
|
||
{
|
||
'title': node.title,
|
||
'order': node.order,
|
||
'lessons': [
|
||
{
|
||
'title': lesson.title, 'introText': lesson.intro_text,
|
||
**({'example': lesson.example} if lesson.example else {}),
|
||
**({'steps': lesson.steps} if lesson.steps else {}),
|
||
}
|
||
for lesson in node.lessons.all()
|
||
],
|
||
}
|
||
for node in unit.nodes.all()
|
||
],
|
||
}
|
||
for unit in grade.units.all()
|
||
],
|
||
})
|
||
return {'grades': out}
|