Files
MathApp/content/curriculum_io.py
Zareian 0917ab487f diagrams for every grade, sectioned prompt, repair of real model slips; fill lesson texts and thin islands
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>
2026-09-28 23:22:13 +03:30

214 lines
9.3 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.
"""
وارد/خارج کردنِ کلِ برنامه‌ی درسی (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}