Seed: grade 3 rebuilt (every lesson 5+ questions, wrong figures/lesson text fixed), Persian digits and plain fractions across all grades, apply_fixups gains lesson/question_set, ledger save chunked for SQLite

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 21:27:22 +03:30
parent 5fbbd371fe
commit f57deb9c1d
9 changed files with 35779 additions and 11725 deletions

View File

@@ -1,13 +1,21 @@
"""
اصلاحِ سوال‌های شکل‌دارِ از قبل منتشرشده (seed هیچ‌وقت حذف یا بازنویسی نمی‌کند، پس اصلاح یک ابزارِ جدا می‌خواهد).
اصلاحِ محتوای از قبل منتشرشده. seed هیچ‌وقت سوال را حذف یا بازنویسی نمی‌کند و مثال، گام‌ها و شکل‌های درس را
فقط وقتی خالی‌اند پر می‌کند؛ پس اصلاحِ چیزی که روی سرور هست این ابزارِ جدا را می‌خواهد.
python tools/figures/apply_fixups.py fixups.json # فقط فایل‌های seed محلی
python tools/figures/apply_fixups.py fixups.json # فقط فایل‌های seed محلی
venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump)
fixups.json: {"delete": [[path, prompt], ...], "blank_title": [[path, prompt], ...]}
delete سوال را کامل پاک می‌کند (پاسخ‌های ثبت‌شده‌اش هم می‌رود؛ فقط برای سوالِ تازه‌منتشرشده)
blank_title عنوانِ زیرِ شکل را خالی می‌کند (عنوان گاهی جوابِ سوال را لو می‌داد)
سوال با (path, prompt) شناخته می‌شود؛ هر کدام پیدا نشود فهرست می‌شود، بی‌صدا رد نمی‌شود.
fixups.json (هر کلید اختیاری است):
"delete": [[path, prompt], ...] سوال را کامل پاک می‌کند. پاسخ‌های ثبت‌شده‌اش هم پاک می‌شود (CASCADE)،
پس فقط برای سوالِ تکراری یا غلط، و پیش از کاربرِ واقعی.
"blank_title": [[path, prompt], ...] عنوانِ زیرِ شکل را خالی می‌کند (عنوان گاهی جواب را لو می‌داد).
"lesson": [[path, {"intro"|"example"|"steps"|"figures": مقدار}], ...]
همان فیلدهای داده‌شده‌ی درس را بازنویسی می‌کند.
"question_set": [[path, prompt, {"prompt"|"choices"|"correct"|"figure": مقدار}], ...]
سوال را در جای خودش اصلاح می‌کند. گزینه‌ها به ترتیبِ id عوض می‌شوند و
ردیفشان می‌ماند، چون پاسخ‌های ثبت‌شده به ردیفِ گزینه وصل‌اند؛ تعدادِ
گزینه‌ها باید با سوالِ فعلی یکی باشد.
path برای سوال و درس [پایه، فصل، جزیره، درس] است. هر موردی پیدا نشود یا نخواند فهرست می‌شود، بی‌صدا رد نمی‌شود.
"""
import json
import os
@@ -16,64 +24,148 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, ROOT)
LESSON_FIELDS = {'intro': ('introText', 'intro_text'), 'example': ('example', 'example'),
'steps': ('steps', 'steps'), 'figures': ('figures', 'figures')}
QUESTION_FIELDS = {'prompt', 'choices', 'correct', 'figure'}
def key(path, prompt):
return (tuple(path), prompt.strip())
def _check_fields(fields, allowed, what):
bad = set(fields) - set(allowed)
if bad:
raise SystemExit(f'{what}: فیلدِ ناشناخته {sorted(bad)}')
def apply_seed(fx):
p = os.path.join(ROOT, 'content', 'seed', 'questions.json')
data = json.load(open(p))
dele = {key(*x) for x in fx.get('delete', [])}
blank = {key(*x) for x in fx.get('blank_title', [])}
seen_d, seen_b, out = set(), set(), []
qset = {key(path, prompt): f for path, prompt, f in fx.get('question_set', [])}
seen, out, problems = set(), [], []
for q in data['questions']:
k = key(q['path'], q['prompt'])
if k in dele:
seen_d.add(k)
seen.add(('delete', k))
continue
if k in blank and q.get('figure'):
q['figure']['title'] = ''
seen_b.add(k)
seen.add(('blank_title', k))
if k in qset:
f = qset[k]
_check_fields(f, QUESTION_FIELDS, 'question_set')
if 'choices' in f and len(f['choices']) != len(q['choices']):
problems.append(('question_set: تعدادِ گزینه فرق دارد', k))
else:
q.update(f)
seen.add(('question_set', k))
out.append(q)
data['questions'] = out
json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1)
return dele - seen_d, blank - seen_b
cp = os.path.join(ROOT, 'content', 'seed', 'curriculum.json')
cur = json.load(open(cp))
lessons = {}
for g in cur['grades']:
for u in g['units']:
for n in u['nodes']:
for l in n['lessons']:
lessons[(g['title'], u['title'], n['title'], l['title'])] = l
for path, f in fx.get('lesson', []):
_check_fields(f, LESSON_FIELDS, 'lesson')
l = lessons.get(tuple(path))
if l is None:
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
continue
for name, value in f.items():
l[LESSON_FIELDS[name][0]] = value
seen.add(('lesson', tuple(path)))
json.dump(cur, open(cp, 'w'), ensure_ascii=False, indent=1)
return _missing(fx, seen) + problems
def _missing(fx, seen):
miss = []
for op in ('delete', 'blank_title'):
miss += [(f'{op}: پیدا نشد', key(*x)) for x in fx.get(op, []) if (op, key(*x)) not in seen]
miss += [('question_set: پیدا نشد', key(p, pr)) for p, pr, _ in fx.get('question_set', [])
if ('question_set', key(p, pr)) not in seen]
return miss
def apply_db(fx):
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django
django.setup()
from content.models import Question
from django.db import transaction
from content.models import Lesson, Question
lessons = {}
from content.models import Lesson
for l in Lesson.objects.select_related('node__unit__grade'):
lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l.pk
miss_d, miss_b = set(), set()
for path, prompt in fx.get('delete', []):
lid = lessons.get(tuple(path))
qs = Question.objects.filter(lesson_id=lid, prompt=prompt.strip()) if lid else Question.objects.none()
if not qs.exists():
miss_d.add(key(path, prompt))
else:
qs.delete()
for path, prompt in fx.get('blank_title', []):
lid = lessons.get(tuple(path))
qs = list(Question.objects.filter(lesson_id=lid, prompt=prompt.strip())) if lid else []
if not qs:
miss_b.add(key(path, prompt))
for q in qs:
if q.figure:
q.figure = {**q.figure, 'title': ''}
q.save(update_fields=['figure'])
return miss_d, miss_b
lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l
problems = []
def find(path, prompt):
l = lessons.get(tuple(path))
return list(Question.objects.filter(lesson=l, prompt=prompt.strip())) if l else []
with transaction.atomic():
for path, prompt in fx.get('delete', []):
qs = find(path, prompt)
if not qs:
problems.append(('delete: پیدا نشد', key(path, prompt)))
for q in qs:
q.delete()
for path, prompt in fx.get('blank_title', []):
qs = find(path, prompt)
if not qs:
problems.append(('blank_title: پیدا نشد', key(path, prompt)))
for q in qs:
if q.figure:
q.figure = {**q.figure, 'title': ''}
q.save(update_fields=['figure'])
for path, prompt, f in fx.get('question_set', []):
_check_fields(f, QUESTION_FIELDS, 'question_set')
qs = find(path, prompt)
if not qs:
problems.append(('question_set: پیدا نشد', key(path, prompt)))
for q in qs:
choices = list(q.choices.order_by('id'))
if 'choices' in f and len(f['choices']) != len(choices):
problems.append(('question_set: تعدادِ گزینه فرق دارد', key(path, prompt)))
continue
correct = f.get('correct')
for i, c in enumerate(choices):
if 'choices' in f:
c.value = f['choices'][i]
if correct is not None:
c.is_correct = (i == correct)
c.save(update_fields=['value', 'is_correct'])
if 'prompt' in f:
q.prompt = f['prompt']
if 'figure' in f:
q.figure = f['figure']
q.save(update_fields=['prompt', 'figure'])
for path, f in fx.get('lesson', []):
_check_fields(f, LESSON_FIELDS, 'lesson')
l = lessons.get(tuple(path))
if l is None:
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
continue
for name, value in f.items():
setattr(l, LESSON_FIELDS[name][1], value)
l.save(update_fields=[LESSON_FIELDS[n][1] for n in f])
return problems
if __name__ == '__main__':
args = [a for a in sys.argv[1:] if a != '--db']
fx = json.load(open(args[0]))
miss_d, miss_b = (apply_db if '--db' in sys.argv else apply_seed)(fx)
print(f'deleted/blanked ok; not found: delete={len(miss_d)} blank_title={len(miss_b)}')
for k in list(miss_d)[:5] + list(miss_b)[:5]:
print(' missing:', k[1][:60])
problems = (apply_db if '--db' in sys.argv else apply_seed)(fx)
counts = {k: len(v) for k, v in fx.items()}
print(f'applied {counts}; problems: {len(problems)}')
for what, k in problems[:20]:
print(f' {what}: {" › ".join(k[0])} | {k[1][:60]}')
sys.exit(1 if problems else 0)