Lesson.figures: fixed diagrams per lesson (stored once, served with the lesson); gen_lesson_figures; 3 calculus-12 lessons

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 16:26:10 +03:30
parent 1d7eb829e2
commit ce629e32e4
9 changed files with 399 additions and 3 deletions

View File

@@ -0,0 +1,93 @@
"""
شکلِ ثابتِ درس‌ها را یک بار می‌سازد و در `Lesson.figures` می‌نویسد.
همان مسیرِ ارزانِ دستیار: همان مدلِ چت، همان کاتالوگِ <diagram>، همان
اعتبارسنجِ `rag.services.diagram.validate`. فرقش این است که نتیجه ذخیره می‌شود و
بعد از تأییدِ چشمی با `seed_content --export` به curriculum.json می‌رود.
فقط درس‌هایی که شکل لازم دارند شکل می‌گیرند؛ مدل می‌تواند `[]` بدهد.
آزموده نشده: درستیِ هندسی/ریاضیِ شکل؛ باید با چشم دید.
python manage.py gen_lesson_figures --grade 12 --limit 4 [--redo] [--dry-run]
"""
import json
import re
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
PROMPT = (
'تو برای یک درسِ ریاضی (اپِ آموزشی) شکل‌های آموزشی می‌سازی. متنِ درس: «توضیح»، «مثال» و '
'«قدم‌ها» (شماره از صفر). فقط جاهایی شکل بگذار که دیدنش مفهوم را روشن می‌کند (نمودار، '
'انتقال، تقارن، محور، جدول، هندسه)؛ هر شکل باید دقیقاً همان چیزی را نشان دهد که آن بخش '
'می‌گوید، با همان اعداد. بخشِ بی‌نیاز شکل نمی‌گیرد؛ اگر هیچ‌جا لازم نبود []. حداکثر ۵ شکل.\n'
'خروجی فقط یک آرایه‌ی JSON: [{"where":"intro"|"example"|"step","index":0,"diagram":{...}}] '
'(index فقط برای step). بدونِ توضیح و بدونِ ```.\n'
'مثال‌های کاتالوگ فقط قالب‌اند: نقطه، تابع یا آیتمی را که این درس لازم ندارد کپی نکن؛ فقط '
'چیزهایی بگذار که همین شکل نشان می‌دهد (برای نمودارِ تابع معمولاً فقط items با type=function). نمودارِ خط‌شکسته یا تکه‌ای از نقطه‌ها را '
'با type=segment بین نقطه‌های متوالی بکش، نه type=line (آن خطِ بی‌پایان است). نقطه‌هایی که '
'در items نمی‌آیند تعریف نکن.\n\n'
)
class Command(BaseCommand):
help = 'ساختِ شکلِ ثابتِ درس‌ها (یک بار، ذخیره در Lesson.figures).'
def add_arguments(self, p):
p.add_argument('--grade', type=int, required=True)
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')
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['lesson']:
qs = qs.filter(pk=o['lesson'])
if not o['redo']:
qs = qs.filter(figures=[])
lessons = list(qs[:o['limit']])
if not lessons:
raise CommandError('درسی پیدا نشد.')
model = chat_model_for('')
client = clients.get_openai_client()
spent = 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 [])))
r = client.chat.completions.create(
model=model, **completion_kwargs(model),
messages=[{'role': 'system', 'content': PROMPT + diagram_mod.DIAGRAM_SPEC},
{'role': 'user', 'content': body}])
u = getattr(r, 'usage', None)
spent += (getattr(u, 'total_tokens', 0) or 0)
raw = re.sub(r'^```(?:json)?|```$', '', (r.choices[0].message.content or '').strip()).strip()
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:
self.stderr.write(f'درس {lesson.id}: خروجیِ نامعتبر ({e}); ذخیره نشد.')
continue
figures, dropped = [], 0
for it in items:
where = it.get('where') if isinstance(it, dict) else None
spec = diagram_mod.validate(it.get('diagram')) if where else None
idx = it.get('index')
if (spec 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
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
lesson.save(update_fields=['figures'])
self.stdout.write(f'توکنِ مصرفی: {spent}')