""" ساختِ سوال‌های چهارگزینه‌ای از مسئله‌های استخراج‌شده‌ی کتاب. `seed_curriculum_from_textbook` اسکلت (Grade/Unit/Node/Lesson) را از دادهٔ موجود و بدونِ هزینه می‌سازد؛ این فرمان تکه‌ی گران را اضافه می‌کند: برای هر درس یک سوال با سه گزینه‌ی انحرافیِ **باورپذیر**. چرا مدل لازم است و مکانیکی نمی‌شود: گزینه‌ی انحرافیِ خوب باید همان اشتباهی باشد که دانش‌آموز واقعاً می‌کند (علامتِ توان را جابه‌جا کند، مخرج مشترک نگیرد). عددِ تصادفی یا «جوابِ درست ± ۱» در دمو فوراً تابلو می‌شود و کلِ سنجشِ کیفیت را بی‌معنا می‌کند. python manage.py generate_questions_from_chunks --grade نهم --limit 5 --dry-run python manage.py generate_questions_from_chunks --grade نهم --limit 20 python manage.py generate_questions_from_chunks --grade نهم """ import json from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.db import transaction from content.models import Choice, Grade, Lesson, Question from rag.models import LessonChunk from rag.services import clients from rag.services.config import build_collection_name, rag_is_configured from rag.services.textbooks import TEXTBOOKS, get_textbook PROMPT = """ تو یک معلمِ ریاضیِ پایه‌ی {grade} هستی. از روی «مسئله» و «راه‌حل»ِ زیر — که مستقیماً از کتابِ درسی درآمده — یک سوالِ چهارگزینه‌ای بساز. قواعد: - صورتِ سوال باید کوتاه، خودکفا و به فارسی باشد؛ دانش‌آموز بدونِ دیدنِ کتاب بتواند جواب دهد. - دقیقاً چهار گزینه، و دقیقاً یکی درست. - سه گزینه‌ی نادرست باید **خطاهای رایجِ همین مبحث** باشند (مثلاً اشتباه در علامتِ توان، جا انداختنِ یک مرحله، مخرجِ مشترک نگرفتن) — نه عددِ تصادفی و نه گزینه‌ی آشکارا مسخره. - هر گزینه حداکثر ۴۰ نویسه. - اگر از روی این متن نمی‌شود سوالِ سالمی ساخت، دقیقاً {{"skip": true}} برگردان. نوعِ مسئله: {problem_type} مسئله: {problem} راه‌حل: {solution} فقط این JSON را برگردان، بدونِ markdown و بدونِ توضیح: {{"prompt": "صورتِ سوال", "options": ["الف", "ب", "ج", "د"], "correctIndex": 0}} """ class Command(BaseCommand): help = 'ساختِ سوالِ چهارگزینه‌ای از chunkهای کتاب (هزینه دارد).' def add_arguments(self, parser): parser.add_argument('--subject', default='ریاضی') parser.add_argument('--grade', required=True) parser.add_argument('--chapters', nargs='*', type=int, default=None) parser.add_argument('--limit', type=int, default=None, help='سقفِ تعدادِ سوال در این اجرا (کنترلِ هزینه).') parser.add_argument('--per-lesson', type=int, default=1, help='چند سوال برای هر درس.') parser.add_argument('--dry-run', action='store_true', help='فقط نشان بده چند سوال ساخته می‌شود؛ هیچ فراخوانیِ پولی نکن.') def handle(self, *args, **opts): book = get_textbook(opts['subject'], opts['grade']) if book is None: raise CommandError( f'کتابی برای «{opts["subject"]} {opts["grade"]}» ثبت نشده. ' 'موجود: ' + '، '.join(sorted(TEXTBOOKS)) ) grade = Grade.objects.filter(title=f'پایه {book.grade}').first() if grade is None: raise CommandError( f'پایه‌ی «پایه {book.grade}» در دیتابیس نیست. ' f'اول: python manage.py seed_curriculum_from_textbook --grade {book.grade}' ) collection = build_collection_name(book.subject, book.grade) targets = self._targets(grade, collection, opts) if not targets: self.stdout.write(self.style.WARNING('هیچ درسی بدونِ سوال پیدا نشد — کاری لازم نیست.')) return self.stdout.write(self.style.MIGRATE_HEADING( f'\n{len(targets)} سوال برای پایه {book.grade}\n' )) for lesson, chunk in targets[:10]: self.stdout.write(f' • {lesson.node.unit.title} / {lesson.title}') if len(targets) > 10: self.stdout.write(f' … و {len(targets) - 10} مورد دیگر') # ~۹۰۰ توکن ورودی و ~۱۵۰ خروجی برای هر سوال با gpt-4o-mini. cost = len(targets) * (900 / 1e6 * 0.15 + 150 / 1e6 * 0.60) self.stdout.write(self.style.WARNING( f'\nبرآوردِ هزینه با {settings.OPENAI_CHAT_MODEL}: ≈ ${cost:.3f}' )) if opts['dry_run']: self.stdout.write(self.style.WARNING('--dry-run: چیزی ساخته نشد.')) return if not rag_is_configured(): raise CommandError('RAG پیکربندی نشده (کلیدها خالی‌اند یا RAG_FORCE_MOCK روشن است).') created = skipped = failed = 0 for lesson, chunk in targets: try: data = self._ask_model(chunk, book.grade) except Exception as exc: # noqa: BLE001 failed += 1 self.stdout.write(self.style.ERROR(f' ✗ {lesson.title}: {str(exc)[:90]}')) continue if data is None: skipped += 1 self.stdout.write(f' — {lesson.title}: مدل گفت از این متن سوال درنمی‌آید') continue self._persist(lesson, data) created += 1 self.stdout.write(self.style.SUCCESS(f' ✓ {lesson.title}: {data["prompt"][:60]}')) self.stdout.write(self.style.SUCCESS( f'\n{created} سوال ساخته شد، {skipped} رد شد، {failed} خطا.' )) # ------------------------------------------------------------------ def _targets(self, grade, collection, opts): """[(lesson, chunk), ...] — فقط درس‌هایی که هنوز سوال کم دارند.""" lessons = ( Lesson.objects .filter(node__unit__grade=grade) .select_related('node__unit') .order_by('node__unit__order', 'node__order', 'id') ) if opts['chapters']: lessons = lessons.filter(node__unit__order__in=opts['chapters']) targets = [] for lesson in lessons: have = lesson.questions.count() need = opts['per_lesson'] - have if need <= 0: continue chunks = list( LessonChunk.objects .filter(collection=collection, problem_type=lesson.title) .exclude(problem='') .order_by('source_page', 'order')[:need] ) for chunk in chunks: targets.append((lesson, chunk)) if opts['limit'] and len(targets) >= opts['limit']: return targets return targets def _ask_model(self, chunk, grade): prompt = PROMPT.format( grade=grade, problem_type=chunk.problem_type, problem=chunk.problem, solution=chunk.solution, ) client = clients.get_openai_client() completion = client.chat.completions.create( model=settings.OPENAI_CHAT_MODEL, messages=[ {'role': 'system', 'content': 'You always answer with a single valid JSON object.'}, {'role': 'user', 'content': prompt}, ], temperature=0.4, max_tokens=400, ) raw = (completion.choices[0].message.content or '').strip() for fence in ('```json', '```'): if raw.startswith(fence): raw = raw[len(fence):].strip() if raw.endswith('```'): raw = raw[:-3].strip() data = json.loads(raw) if data.get('skip'): return None # ⚠️ گزینه‌های خالی را حذف نمی‌کنیم و بعد اعتبارسنجی می‌کنیم: اگر مدل # پنج گزینه بدهد که یکی‌شان خالی است، حذفِ آن یکی چهارتا باقی می‌گذارد # ولی correctIndex همچنان به فهرستِ اصلی اشاره می‌کند — یعنی سوال # می‌سازیم و گزینه‌ی *غلط* را درست علامت می‌زنیم. پس اول ساختار را # همان‌طور که آمده می‌سنجیم. raw_options = data.get('options') index = data.get('correctIndex') if not isinstance(raw_options, list) or len(raw_options) != 4: raise ValueError(f'دقیقاً چهار گزینه لازم است: {raw_options!r}') if not isinstance(index, int) or isinstance(index, bool) or not 0 <= index < 4: raise ValueError(f'correctIndex نامعتبر: {index!r}') options = [str(o).strip()[:100] for o in raw_options] if not all(options) or len(set(options)) != 4: raise ValueError(f'گزینه‌ی خالی یا تکراری: {options}') if not (data.get('prompt') or '').strip(): raise ValueError('صورتِ سوال خالی بود') return {'prompt': data['prompt'].strip(), 'options': options, 'correctIndex': index} @transaction.atomic def _persist(self, lesson, data): question = Question.objects.create( lesson=lesson, type='multiple_choice', prompt=data['prompt'], ) Choice.objects.bulk_create([ Choice(question=question, value=value, is_correct=(i == data['correctIndex'])) for i, value in enumerate(data['options']) ])