Files
MathApp/content/management/commands/generate_questions_from_chunks.py
Zareian 78130e7194 build the real curriculum from the textbook structure
تا پیش از این کلِ محتوای دیتابیس ردیف‌های [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>
2026-09-13 19:00:10 +03:30

213 lines
10 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.
"""
ساختِ سوال‌های چهارگزینه‌ای از مسئله‌های استخراج‌شده‌ی کتاب.
`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'])
])