تا پیش از این کلِ محتوای دیتابیس ردیفهای [DEMO] و [SEED] بود — دانشآموزِ
پایه نهم در دمو «جمع تا ۱۰» میدید. سینیور «یک بیسیکِ کامل با دیتای واقعی»
خواسته بود.
seed_curriculum_from_textbook (رایگان، هیچ فراخوانیِ پولی ندارد):
Grade ← پایهی کتاب
Unit ← فصلِ کتاب
Node ← درسِ داخلِ فصل، از فهرستِ خودِ کتاب
Lesson ← نوعِ مسئلهی استخراجشده در بازهی صفحهی همان درس
intro_text مستقیم از generalSolution کتاب میآید.
generate_questions_from_chunks سوالِ چهارگزینهای با گزینههای انحرافیِ
باورپذیر میسازد (هزینهدار، اعتبارسنجیِ سختگیرانه). سوال اینجا جدا شد
چون ساختنِ گزینهی انحرافیِ درست به مدل نیاز دارد.
purge_demo_data ردیفهای [SEED]/[DEMO] را گزارش و — فقط با --apply — پاک
میکند؛ بدونِ آن این ردیفها در دمو داخلِ API دیده میشوند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
10 KiB
Python
213 lines
10 KiB
Python
"""
|
||
ساختِ سوالهای چهارگزینهای از مسئلههای استخراجشدهی کتاب.
|
||
|
||
`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'])
|
||
])
|