Files
MathApp/content/management/commands/gen_lesson_figures.py
Zareian 293b916544 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>
2026-10-11 01:29:36 +03:30

289 lines
18 KiB
Python

"""
شکلِ ثابتِ درس‌ها را یک بار می‌سازد و در `Lesson.figures` می‌نویسد.
همان مسیرِ ارزانِ دستیار: همان مدلِ چت، همان کاتالوگِ <diagram>، همان اعتبارسنجِ
`rag.services.diagram.validate`. فرقش این است که نتیجه ذخیره می‌شود و بعد از تأییدِ چشمی با
`seed_content --export` به curriculum.json می‌رود.
سه چیز که هزینه و خطا را پایین می‌آورد:
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
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'
'قاعده‌ها:\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, 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('--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):
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()
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), **extra,
messages=[{'role': 'system', 'content': system}, {'role': 'user', 'content': user}])
u = getattr(r, 'usage', None)
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:
items = _json_list(ask(PROMPT + spec, text))
except (ValueError, KeyError) as e:
with lock:
stats['invalid'] += 1
self.stderr.write(f'درس {lesson.id}: خروجیِ نامعتبر ({e}); ذخیره نشد.')
return
figures, dropped = [], 0
for it in items[:MAX_FIGURES + 2]:
where = it.get('where') if isinstance(it, dict) 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 (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
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': 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'])
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}')