103 lines
6.8 KiB
Python
103 lines
6.8 KiB
Python
"""
|
||
شکلِ ثابتِ درسها را یک بار میسازد و در `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})')
|