Lesson content complete: examples/steps for all 3,778 lessons, 1,775 lessons with fixed figures, 38 quiz fixes

- curriculum.json: example/steps for every real lesson, figures (2,224) hand-written and visually checked
- questions.json: 3,703 questions (24 added, 14 keys fixed)
- gen_lesson_figures: strict check, thin-figure filter, recheck, --max-usd; API figures limited to plane/fraction/numberline/solid

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 01:29:36 +03:30
parent 4821df3914
commit 293b916544
3 changed files with 97270 additions and 5268 deletions

View File

@@ -1,16 +1,28 @@
"""
شکلِ ثابتِ درس‌ها را یک بار می‌سازد و در `Lesson.figures` می‌نویسد.
همان مسیرِ ارزانِ دستیار: همان مدلِ چت، همان کاتالوگِ <diagram>، همان
اعتبارسنجِ `rag.services.diagram.validate`. فرقش این است که نتیجه ذخیره می‌شود و
بعد از تأییدِ چشمی با `seed_content --export` به curriculum.json می‌رود.
فقط درس‌هایی که شکل لازم دارند شکل می‌گیرند؛ مدل می‌تواند `[]` بدهد.
آزموده نشده: درستیِ هندسی/ریاضیِ شکل؛ باید با چشم دید.
همان مسیرِ ارزانِ دستیار: همان مدلِ چت، همان کاتالوگِ <diagram>، همان اعتبارسنجِ
`rag.services.diagram.validate`. فرقش این است که نتیجه ذخیره می‌شود و بعد از تأییدِ چشمی با
`seed_content --export` به curriculum.json می‌رود.
python manage.py gen_lesson_figures --grade 12 --limit 4 [--redo] [--dry-run]
سه چیز که هزینه و خطا را پایین می‌آورد:
1. **پیش‌صافیِ رایگان:** اگر متنِ درس به هیچ بخشِ کاتالوگ نمی‌خورد (حسابِ عددیِ ساده)، مدل صدا
زده نمی‌شود و درس «بی‌نیازِ شکل» ثبت می‌شود.
2. **فقط بخش‌های مربوطِ کاتالوگ** (نه کلِ آن، ~۳٬۵۰۰ توکن) کنارِ درس می‌رود.
3. **گامِ وارسیِ مستقل:** هر شکلی که مدل ساخت، مدلِ دوم با متنِ درس مقایسه می‌کند؛ شکلی که
همان چیزِ متن را نشان نمی‌دهد (عدد/برچسبِ ناهمخوان، قدمِ اشتباه) ذخیره نمی‌شود.
سقفِ هزینه با فرمولِ رسمیِ gpt-4.1-mini محاسبه می‌شود (۰٫۴۰ و ۱٫۶۰ دلار در هر میلیون توکن). صورتحسابِ
واقعیِ GapGPT در سنجش‌های قبلی چند برابر کمتر بود، پس سقف محافظه‌کارانه است. آزموده نشده: درستیِ
هندسی/ریاضیِ شکل؛ وارسیِ مدلِ دوم جایگزینِ دیدنِ چشمی نیست.
python manage.py gen_lesson_figures --grade 12 --limit 20 --max-usd 2 --ledger fig_ledger.json
"""
import json
import re
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
@@ -18,85 +30,259 @@ from content.models import Lesson
from rag.services import clients, diagram as diagram_mod
from rag.services.generation import chat_model_for, completion_kwargs
USD_PER_M_IN = 0.40
USD_PER_M_OUT = 1.60
MAX_FIGURES = 3
PROMPT = (
'تو برای یک درسِ ریاضی (اپِ آموزشی) شکل‌های آموزشی می‌سازی. متنِ درس: «توضیح»، «مثال» و '
'«قدم‌ها» (شماره‌ی قدم‌ها از ۱، همان شماره‌ای که کاربر می‌بیند). فقط جاهایی شکل بگذار که دیدنش مفهوم را روشن می‌کند (نمودار، '
'انتقال، تقارن، محور، جدول، هندسه)؛ هر شکل باید دقیقاً همان چیزی را نشان دهد که آن بخش '
'می‌گوید، با همان اعداد. بخشِ بی‌نیاز شکل نمی‌گیرد؛ اگر هیچ‌جا لازم نبود []. حداکثر ۵ شکل.\n'
'خروجی فقط یک آرایه‌ی JSON: [{"where":"intro"|"example"|"step","index":0,"diagram":{...}}] '
'(index فقط برای step و همان شماره‌ی ۱‌به‌بعدِ قدم). بدونِ توضیح و بدونِ ```.\n'
'مثال‌های کاتالوگ فقط قالب‌اند: نقطه، تابع یا آیتمی را که این درس لازم ندارد کپی نکن؛ فقط '
'چیزهایی بگذار که همین شکل نشان می‌دهد (برای نمودارِ تابع معمولاً فقط items با type=function). نمودارِ خط‌شکسته یا تکه‌ای از نقطه‌ها را '
'با type=segment بین نقطه‌های متوالی بکش، نه type=line (آن خطِ بی‌پایان است). نقطه‌هایی که '
'در items نمی‌آیند تعریف نکن. شکلِ هر قدم باید همان چیزی را نشان دهد که متنِ همان قدم می‌گوید '
'(نه قدمِ قبل یا بعد). بازه‌ی x نمودارِ تابع را کوچک بگیر (مثلاً دو واحد دور از رأس/نقطه‌های مهم) تا '
'مقدارِ y از حدود ۹ بیشتر نشود و منحنی له نشود؛ marks فقط برای یک منحنی بگذار تا برچسب‌ها روی هم نیفتند.\n\n'
'تو برای یک درسِ ریاضی (اپِ آموزشی) شکل‌های آموزشی می‌سازی. متنِ درس: «توضیح»، «مثال» و «قدم‌ها» '
'(شماره‌ی قدم‌ها از ۱، همان شماره‌ای که کاربر می‌بیند).\n'
'قاعده‌ها:\n'
f'- فقط جایی شکل بگذار که دیدنش مفهوم یا مسئله را روشن می‌کند؛ حداکثر {MAX_FIGURES} شکل. '
'شکلِ تزیینی یا تکراری نساز. اگر هیچ‌جا لازم نبود []. ترجیح با «example» و «step» است؛ '
'«intro» فقط وقتی متنِ توضیح خودش عدد یا نمادِ مشخص می‌دهد.\n'
'- هر شکل باید دقیقاً همان را نشان دهد که آن بخش می‌گوید، با همان اعداد و نام‌ها؛ چیزی که در '
'متن نیست اختراع نکن. برای بخشی که در متن نیست (مثال/قدم) شکل نساز.\n'
'- نمودارِ خط‌شکسته یا تکه‌ای را با type=segment بین نقطه‌های متوالی بکش، نه type=line. '
'برای نمودارِ تابع بازه‌ی x را کوچک بگیر (مقدارِ y از حدود ۹ بیشتر نشود) و فقط items لازم را بگذار؛ '
'marks فقط برای یک منحنی. نقطه‌هایی که در items نمی‌آیند تعریف نکن.\n'
'- شکلِ فقط‌نقطه (چند نقطه‌ی بی‌ارتباط) نساز؛ نقطه را روی نمودار یا شکلِ مربوطش بگذار. ضابطه‌ی تابع را '
'عیناً از متن بردار (ضریب و انتقال را حذف نکن). برای مثلثِ متساوی‌الاضلاع/متساوی‌الساقین و مانندش '
'طول‌ها را با نوعش سازگار بده. نوعِ pattern/groups را فقط وقتی بگذار که خودِ متن شکل یا شمارشِ مشخصی می‌دهد.\n'
'- شکلِ هر قدم همان را نشان دهد که متنِ همان قدم می‌گوید (نه قدمِ قبل یا بعد).\n'
'- مثال‌های کاتالوگ فقط قالب‌اند؛ نقطه، تابع یا آیتمی را که این درس لازم ندارد کپی نکن.\n'
'خروجی فقط یک آرایه‌ی JSON: [{"where":"intro"|"example"|"step","index":1,"diagram":{...}}] '
'(index فقط برای step و همان شماره‌ی ۱‌به‌بعدِ قدم). بدونِ توضیح و بدونِ ```.\n\n'
)
CHECK = (
'بازبینِ سخت‌گیرِ ریاضی هستی. یک درس و شکل‌هایی که برایش ساخته‌اند (هر شکل یک JSON) داده می‌شود. '
'برای هر شکل ابتدا در یک جمله بنویس «shows»: دقیقاً چه چیزی می‌کشد (ضابطه‌ی هر تابع، مختصات نقطه‌ها، '
'طول ضلع‌ها و زاویه‌ها، بازه‌ها، عددهای جدول، موقعیت و برچسب‌ها). بعد با متنِ همان بخشِ درس مقایسه کن. '
'ok=false بده اگر: ضابطه یا عددی با متن نمی‌خواند (مثلاً ضریب یا انتقال گم شده)؛ شکل از نظر ریاضی/هندسی '
'ناسازگار است (مثلاً مثلثِ متساوی‌الاضلاع با ضلع‌های نابرابر)؛ موقعیتِ نقطه‌ها با برچسبشان نمی‌خواند؛ '
'شکل مفهومِ اصلیِ آن بخش را نشان نمی‌دهد یا فقط نقطه‌های پراکنده/برچسبِ بی‌معنی است؛ چیزی اختراع شده؛ '
'شکلِ قدم به همان قدم مربوط نیست. هر شکِ جدی → ok=false. سلیقه‌ای ایراد نگیر. '
'خروجی فقط JSON: {"verdicts":[{"i":0,"shows":"...","ok":true|false,"why":"یک جمله"}]} به ترتیبِ شکل‌ها.'
)
def _lesson_text(lesson: Lesson) -> str:
steps = '\n'.join(f'قدم {i}: {s}' for i, s in enumerate(lesson.steps or [], 1))
return f'عنوان: {lesson.title}\nتوضیح: {lesson.intro_text}\nمثال: {lesson.example}\n{steps}'
def _spec_for(text: str) -> str:
"""فقط بخش‌هایی از کاتالوگ که کلمه‌هایشان در متن هست؛ بی‌بخش → '' (مدل صدا زده نمی‌شود)."""
parts = [section for pattern, section in diagram_mod.SECTIONS if pattern.search(text)]
return '\n'.join([diagram_mod._HEAD, *parts]) if parts else ''
_SUBSTANTIVE = {'function', 'segment', 'line', 'ray', 'polygon', 'triangle', 'quad', 'regularPolygon', 'circle',
'angle', 'exteriorAngle', 'arc', 'vector'}
def _is_thin(diagram: dict) -> bool:
"""
شکلِ صفحه‌ای که هیچ منحنی، پاره‌خط یا شکلی ندارد و فقط نقطه‌ی پراکنده است چیزی نشان نمی‌دهد
(مثلاً چند نقطه‌ی A و B و C بی‌ارتباط). نوعِ تازه‌ای که این فهرست نمی‌شناسد «محتوادار» حساب می‌شود.
"""
if diagram.get('kind') != 'plane':
return False
items = diagram.get('items') or []
if any(i.get('type') in _SUBSTANTIVE or i.get('type') not in ('point', 'label', None) for i in items):
return False
return True
def _json_list(raw: str):
raw = re.sub(r'^```(?:json)?|```$', '', (raw or '').strip()).strip()
start = raw.index('[')
items, _ = json.JSONDecoder().raw_decode(raw[start:])
if not isinstance(items, list):
raise ValueError('not a list')
return items
class Command(BaseCommand):
help = 'ساختِ شکلِ ثابتِ درس‌ها (یک بار، ذخیره در Lesson.figures).'
def add_arguments(self, p):
p.add_argument('--grade', type=int, required=True)
p.add_argument('--grade', type=int, help='بدونِ آن همه‌ی پایه‌ها')
p.add_argument('--limit', type=int, default=4)
p.add_argument('--lesson', type=int, help='فقط همین شناسه')
p.add_argument('--redo', action='store_true', help='درس‌هایی که شکل دارند را هم بساز')
p.add_argument('--redo', action='store_true', help='درس‌هایی که شکل دارند هم')
p.add_argument('--dry-run', action='store_true')
p.add_argument('--workers', type=int, default=4)
p.add_argument('--max-usd', type=float, default=2.0, help='سقفِ هزینه (فرمولِ رسمی)؛ رسید، می‌ایستد')
p.add_argument('--ledger', help='JSON از درس‌های بررسی‌شده ({id: نتیجه}) تا دوباره پول نگیرند')
p.add_argument('--quiet', action='store_true')
p.add_argument('--recheck', action='store_true',
help='شکل‌های ذخیره‌شده را دوباره با وارسیِ سخت‌گیر بسنج و ناموفق‌ها را پاک کن (بی‌ساخت)')
p.add_argument('--no-check', action='store_true', help='گامِ وارسیِ مستقل را رد کن (فقط برای آزمایش)')
def handle(self, *a, **o):
qs = Lesson.objects.filter(node__unit__grade__number=o['grade']).order_by(
'node__unit__order', 'node__order', 'id')
if o['recheck']:
return self._recheck(o)
qs = Lesson.objects.exclude(example='').order_by(
'node__unit__grade__number', 'node__unit__order', 'node__order', 'id')
if o['grade']:
qs = qs.filter(node__unit__grade__number=o['grade'])
if o['lesson']:
qs = qs.filter(pk=o['lesson'])
if not o['redo']:
qs = qs.filter(figures=[])
ledger_path = Path(o['ledger']) if o['ledger'] else None
ledger = json.loads(ledger_path.read_text()) if ledger_path and ledger_path.exists() else {}
if not o['redo']:
qs = qs.exclude(pk__in=[int(k) for k in ledger])
lessons = list(qs[:o['limit']])
if not lessons:
raise CommandError('درسی پیدا نشد.')
model = chat_model_for('')
client = clients.get_openai_client()
spent = prompt_t = out_t = 0
for lesson in lessons:
body = (f'عنوان: {lesson.title}\nتوضیح: {lesson.intro_text}\nمثال: {lesson.example}\n'
+ '\n'.join(f'قدم {i}: {s}' for i, s in enumerate(lesson.steps or [], 1)))
lock = threading.Lock()
usage = {'in': 0, 'out': 0}
stats = {'no_trigger': 0, 'none': 0, 'saved': 0, 'figures': 0, 'rejected': 0, 'invalid': 0, 'stopped': 0}
def spent():
return usage['in'] / 1e6 * USD_PER_M_IN + usage['out'] / 1e6 * USD_PER_M_OUT
def ask(system, user, **extra):
r = client.chat.completions.create(
model=model, **completion_kwargs(model),
messages=[{'role': 'system', 'content': PROMPT + diagram_mod.DIAGRAM_SPEC},
{'role': 'user', 'content': body}])
model=model, **completion_kwargs(model), **extra,
messages=[{'role': 'system', 'content': system}, {'role': 'user', 'content': user}])
u = getattr(r, 'usage', None)
spent += (getattr(u, 'total_tokens', 0) or 0)
prompt_t += (getattr(u, 'prompt_tokens', 0) or 0)
out_t += (getattr(u, 'completion_tokens', 0) or 0)
raw = re.sub(r'^```(?:json)?|```$', '', (r.choices[0].message.content or '').strip()).strip()
with lock:
usage['in'] += getattr(u, 'prompt_tokens', 0) or 0
usage['out'] += getattr(u, 'completion_tokens', 0) or 0
return r.choices[0].message.content or ''
def record(lesson, outcome):
with lock:
if ledger_path and not o['dry_run']:
ledger[str(lesson.id)] = outcome
ledger_path.write_text(json.dumps(ledger, ensure_ascii=False))
def work(lesson):
text = _lesson_text(lesson)
spec = _spec_for(text)
if not spec:
with lock:
stats['no_trigger'] += 1
record(lesson, 'no_trigger')
return
if spent() >= o['max_usd']:
with lock:
stats['stopped'] += 1
return
try:
start = raw.index('[')
items, _ = json.JSONDecoder().raw_decode(raw[start:])
if not isinstance(items, list):
raise ValueError('not a list')
except ValueError as e:
items = _json_list(ask(PROMPT + spec, text))
except (ValueError, KeyError) as e:
with lock:
stats['invalid'] += 1
self.stderr.write(f'درس {lesson.id}: خروجیِ نامعتبر ({e}); ذخیره نشد.')
continue
return
figures, dropped = [], 0
for it in items:
for it in items[:MAX_FIGURES + 2]:
where = it.get('where') if isinstance(it, dict) else None
spec = diagram_mod.validate(it.get('diagram')) if where else None
s = diagram_mod.validate(it.get('diagram')) if where else None
idx = it.get('index')
if where == 'step' and isinstance(idx, int):
idx -= 1 # مدل ۱‌به‌بعد می‌نویسد؛ ذخیره از صفر
if (spec is None or where not in ('intro', 'example', 'step')
if (s is None or where not in ('intro', 'example', 'step')
or (where == 'step' and not (isinstance(idx, int) and 0 <= idx < len(lesson.steps or [])))
or (where == 'example' and not lesson.example)):
dropped += 1
continue
for item in spec.get('items', []):
if _is_thin(s):
dropped += 1
continue
for item in s.get('items', []):
if item.get('type') == 'segment':
item.pop('ticks', None) # علامتِ «ضلع مساوی» روی نمودارِ تابع فقط شلوغی است
figures.append({'where': where, **({'index': idx} if where == 'step' else {}), 'diagram': spec})
self.stdout.write(f'درس {lesson.id} «{lesson.title}»: {len(figures)} شکل، {dropped} ردشده')
for f in figures:
self.stdout.write(' ' + json.dumps(f, ensure_ascii=False))
if figures and not o['dry_run']:
lesson.figures = figures
figures.append({'where': where, **({'index': idx} if where == 'step' else {}), 'diagram': s})
figures = figures[:MAX_FIGURES]
if not figures:
with lock:
stats['none'] += 1
record(lesson, 'none' if not items else 'invalid_all')
return
kept, notes = figures, []
if not o['no_check']:
try:
verdicts = json.loads(ask(CHECK, text + '\n\nشکل‌ها:\n' + '\n'.join(
f'{i}: ' + json.dumps(f, ensure_ascii=False) for i, f in enumerate(figures)),
response_format={'type': 'json_object'})).get('verdicts', [])
ok = {v.get('i'): v for v in verdicts if isinstance(v, dict)}
kept = [f for i, f in enumerate(figures) if ok.get(i, {}).get('ok') is True]
notes = [f"{i}: {v.get('why', '')}" for i, v in ok.items() if v.get('ok') is not True]
except (ValueError, KeyError, AttributeError) as e:
kept, notes = [], [f'وارسی ناموفق: {e}']
with lock:
stats['rejected'] += len(figures) - len(kept)
stats['figures'] += len(kept)
if kept:
stats['saved'] += 1
self.stdout.write(f'درس {lesson.id} «{lesson.title}»: {len(kept)} شکل از {len(figures)} '
f'({dropped} ردشده در اعتبارسنج)' + (f' | رد: {"; ".join(notes)}' if notes else ''))
if not o['quiet']:
for f in kept:
self.stdout.write(' ' + json.dumps(f, ensure_ascii=False))
if kept and not o['dry_run']:
lesson.figures = kept
lesson.save(update_fields=['figures'])
self.stdout.write(f'توکنِ مصرفی: {spent} (ورودی {prompt_t}، خروجی {out_t})')
record(lesson, f'figures:{len(kept)}' if kept else 'rejected')
with ThreadPoolExecutor(max_workers=max(1, o['workers'])) as ex:
list(ex.map(work, lessons))
self.stdout.write(f'{stats}\nتوکن: ورودی {usage["in"]}، خروجی {usage["out"]}؛ هزینه‌ی فرمولی ≈ ${spent():.3f}')
def _recheck(self, o):
qs = Lesson.objects.exclude(figures=[]).order_by('node__unit__grade__number', 'id')
if o['grade']:
qs = qs.filter(node__unit__grade__number=o['grade'])
lessons = list(qs[:o['limit']])
model = chat_model_for('')
client = clients.get_openai_client()
lock = threading.Lock()
usage = {'in': 0, 'out': 0}
stats = {'lessons': 0, 'kept': 0, 'dropped': 0, 'errors': 0}
def work(lesson):
figures = [f for f in lesson.figures if not _is_thin(f['diagram'])]
thin = len(lesson.figures) - len(figures)
text = _lesson_text(lesson)
try:
r = client.chat.completions.create(
model=model, **completion_kwargs(model), response_format={'type': 'json_object'},
messages=[{'role': 'system', 'content': CHECK}, {'role': 'user', 'content': text + '\n\nشکل‌ها:\n' + '\n'.join(
f'{i}: ' + json.dumps(f, ensure_ascii=False) for i, f in enumerate(figures))}])
u = getattr(r, 'usage', None)
ok = {v.get('i'): v for v in json.loads(r.choices[0].message.content or '{}').get('verdicts', [])
if isinstance(v, dict)} if figures else {}
except (ValueError, KeyError, AttributeError) as e:
with lock:
stats['errors'] += 1
self.stderr.write(f'درس {lesson.id}: وارسی ناموفق ({e}); دست نخورد.')
return
kept = [f for i, f in enumerate(figures) if ok.get(i, {}).get('ok') is True]
with lock:
usage['in'] += getattr(u, 'prompt_tokens', 0) or 0
usage['out'] += getattr(u, 'completion_tokens', 0) or 0
stats['lessons'] += 1
stats['kept'] += len(kept)
stats['dropped'] += len(lesson.figures) - len(kept)
why = '; '.join(f"{i}: {v.get('why', '')}" for i, v in ok.items() if v.get('ok') is not True)
if len(kept) != len(lesson.figures) and not o['quiet']:
self.stdout.write(f'درس {lesson.id} «{lesson.title}»: {len(kept)}/{len(lesson.figures)}'
f' (بی‌محتوا {thin}) | {why}')
if len(kept) != len(lesson.figures) and not o['dry_run']:
lesson.figures = kept
lesson.save(update_fields=['figures'])
with ThreadPoolExecutor(max_workers=max(1, o['workers'])) as ex:
list(ex.map(work, lessons))
cost = usage['in'] / 1e6 * USD_PER_M_IN + usage['out'] / 1e6 * USD_PER_M_OUT
self.stdout.write(f'{stats}\nتوکن: ورودی {usage["in"]}، خروجی {usage["out"]}؛ هزینه‌ی فرمولی ≈ ${cost:.3f}')