""" شکلِ ثابتِ درس‌ها را یک بار می‌سازد و در `Lesson.figures` می‌نویسد. همان مسیرِ ارزانِ دستیار: همان مدلِ چت، همان کاتالوگِ ، همان اعتبارسنجِ `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 import openai 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('--subject', 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['subject']: qs = qs.filter(node__unit__subject__title=o['subject']) 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)) # خطای شبکه/درگاه یک درس را رد می‌کند، نه کلِ اجرا را؛ در ledger ثبت نمی‌شود تا دفعه‌ی بعد دوباره برود. except (ValueError, KeyError, openai.OpenAIError) 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, openai.OpenAIError) 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}')