Files
MathApp/content/management/commands/gen_lesson_figures.py

103 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
شکلِ ثابتِ درس‌ها را یک بار می‌سازد و در `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 نمی‌آیند تعریف نکن. شکلِ هر قدم باید همان چیزی را نشان دهد که متنِ همان قدم می‌گوید '
'(نه قدمِ قبل یا بعد). بازه‌ی x نمودارِ تابع را کوچک بگیر (مثلاً دو واحد دور از رأس/نقطه‌های مهم) تا '
'مقدارِ y از حدود ۹ بیشتر نشود و منحنی له نشود؛ marks فقط برای یک منحنی بگذار تا برچسب‌ها روی هم نیفتند.\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 = 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)))
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)
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()
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 where == 'step' and isinstance(idx, int):
idx -= 1 # مدل ۱‌به‌بعد می‌نویسد؛ ذخیره از صفر
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
for item in spec.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
lesson.save(update_fields=['figures'])
self.stdout.write(f'توکنِ مصرفی: {spent} (ورودی {prompt_t}، خروجی {out_t})')