- 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>
289 lines
18 KiB
Python
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}')
|