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>
This commit is contained in:
2026-09-13 19:00:10 +03:30
parent c79be0a336
commit 78130e7194
3 changed files with 500 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,87 @@
"""
پاک‌کردنِ ردیف‌های نمونه‌ی «[SEED]» و «[DEMO]» از دیتابیسِ محلی.
چرا لازم است: این ردیف‌ها در APIهای واقعی دیده می‌شوند — «[DEMO] پایهٔ نمونه»
در `/api/grades`، «[SEED] ماهانه» در پلن‌ها، «[SEED] کاربر اصلی» در لیدربورد —
و در دمو کنارِ محتوای واقعیِ کتاب بد به نظر می‌رسند.
برگشت‌پذیر است: `python manage.py seed_test_data` دوباره می‌سازدشان.
python manage.py purge_demo_data # فقط نشان می‌دهد
python manage.py purge_demo_data --apply # واقعاً پاک می‌کند
"""
from django.core.management.base import BaseCommand
from django.db import transaction
PREFIXES = ('[SEED]', '[DEMO]')
class Command(BaseCommand):
help = 'پاک‌کردنِ ردیف‌های نمونه‌ی [SEED]/[DEMO] (برگشت‌پذیر با seed_test_data).'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true',
help='بدونِ این فقط گزارش می‌دهد و چیزی پاک نمی‌کند.')
def handle(self, *args, **opts):
from accounts.models import User
from content.models import Grade
from progress.models import Character
from subscription.models import Plan
# (مدل، نامِ فیلدِ متنی) — همه‌ی جاهایی که seed_test_data برچسب می‌زند.
targets = [
(Grade, 'title'), (Plan, 'title'), (Character, 'name'), (User, 'name'),
]
found = []
for model, field in targets:
queryset = model.objects.none()
for prefix in PREFIXES:
queryset = queryset | model.objects.filter(**{f'{field}__startswith': prefix})
rows = list(queryset.distinct())
if rows:
found.append((model, field, rows))
if not found:
self.stdout.write(self.style.SUCCESS('هیچ ردیفِ نمونه‌ای پیدا نشد.'))
return
self.stdout.write(self.style.MIGRATE_HEADING('\nردیف‌های نمونه\n'))
total = 0
for model, field, rows in found:
self.stdout.write(f' {model.__name__} — {len(rows)} ردیف')
for row in rows[:8]:
self.stdout.write(f' #{row.pk} {getattr(row, field)}')
if len(rows) > 8:
self.stdout.write(f' … و {len(rows) - 8} مورد دیگر')
total += len(rows)
# هشدارِ لازم: بعضی از این‌ها تنها دادهٔ آن endpoint هستند.
empties = [
name for model, name in ((Plan, 'subscription/plans'), (Character, 'characters'))
if model.objects.count() == len([r for m, _f, rows in found if m is model for r in rows])
]
if empties:
self.stdout.write(self.style.WARNING(
'\n⚠ بعد از پاک‌کردن این endpointها خالی می‌شوند: ' + '، '.join(empties)
+ '\n (پلن و کاراکتر را ادمین در جنگو-ادمین می‌سازد؛ اگر برای دمو '
'لازم‌اند، اول واقعی‌شان را بساز بعد این را اجرا کن.)'
))
if not opts['apply']:
self.stdout.write(self.style.WARNING(
f'\n{total} ردیف. چیزی پاک نشد — برای پاک‌کردن --apply بدهید.'
))
return
# حذفِ آبشاری: Grade کلِ زیردرختِ Unit/Node/Lesson/Question را می‌برد،
# و User سشن‌ها و پاسخ‌هایش را. همه‌شان دادهٔ نمونه‌اند.
with transaction.atomic():
for model, _field, rows in found:
model.objects.filter(pk__in=[r.pk for r in rows]).delete()
self.stdout.write(self.style.SUCCESS(f'\n{total} ردیفِ نمونه پاک شد.'))
self.stdout.write(self.style.HTTP_INFO(
'برای برگرداندن: python manage.py seed_test_data'
))

View File

@@ -0,0 +1,201 @@
"""
ساختِ برنامه‌ی درسیِ واقعی (Grade → Unit → Node → Lesson) از روی ساختارِ
کتابِ درسی و chunkهای ingest‌شده.
چرا لازم بود: تا پیش از این کلِ محتوای دیتابیس ردیف‌های «[DEMO] پایهٔ نمونه»
و «[SEED] پایه سوم» بود — یعنی دانش‌آموزِ پایه نهم در دمو «جمع تا ۱۰» می‌دید.
سینیور خواست «یک بیسیکِ کامل با دیتای واقعی کار کند».
نگاشت:
Grade ← پایه‌ی کتاب («پایه نهم»)
Unit ← فصلِ کتاب («فصل ۴: توان و ریشه»)
Node ← درسِ داخلِ فصل، از فهرستِ کتاب («توان صحیح»)
Lesson ← نوعِ مسئله‌ی استخراج‌شده در بازه‌ی صفحه‌ی همان درس
سوال‌ها اینجا ساخته نمی‌شوند: ساختنِ گزینه‌های انحرافیِ درست به مدل نیاز دارد
(`generate_questions_from_chunks`). این فرمان هیچ فراخوانیِ پولی ندارد.
python manage.py seed_curriculum_from_textbook --grade نهم --dry-run
python manage.py seed_curriculum_from_textbook --grade نهم
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from content.models import Grade, Lesson, Node, Unit
from rag.models import LessonChunk
from rag.services.config import build_collection_name
from rag.services.textbooks import TEXTBOOKS, get_textbook
GRADE_STYLE = {
'ششم': ('grade-6', '#4CAF50'),
'هفتم': ('grade-7', '#2196F3'),
'هشتم': ('grade-8', '#9C27B0'),
'نهم': ('grade-9', '#FF9800'),
'دهم': ('grade-10', '#E91E63'),
}
class Command(BaseCommand):
help = 'ساختِ Grade/Unit/Node/Lesson از ساختارِ کتاب و chunkهای ingest‌شده (بدونِ هزینه).'
def add_arguments(self, parser):
parser.add_argument('--subject', default='ریاضی')
parser.add_argument('--grade', required=True, help='مثلاً نهم')
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--min-chunks', type=int, default=1,
help='درسی که کمتر از این تعداد chunk دارد ساخته نشود.')
parser.add_argument('--group-size', type=int, default=8,
help='وقتی chunkها شماره‌ی صفحه ندارند، هر چند نوع مسئله '
'زیرِ یک گره جمع شود.')
def handle(self, *args, **opts):
subject, grade = opts['subject'], opts['grade']
book = get_textbook(subject, grade)
if book is None:
raise CommandError(
f'کتابی برای «{subject} {grade}» ثبت نشده. موجود: ' + '، '.join(sorted(TEXTBOOKS))
)
collection = build_collection_name(book.subject, book.grade)
if not LessonChunk.objects.filter(collection=collection).exists():
raise CommandError(
f'هیچ chunkی در «{collection}» نیست. اول rag_ingest_book را اجرا کنید.'
)
plan = self._build_plan(book, collection, opts['min_chunks'], opts['group_size'])
self._print_plan(book, plan)
if opts['dry_run']:
self.stdout.write(self.style.WARNING('\n--dry-run: چیزی نوشته نشد.'))
return
with transaction.atomic():
counts = self._apply(book, plan)
self.stdout.write(self.style.SUCCESS(
f"\nساخته/به‌روزرسانی شد: {counts['units']} فصل، {counts['nodes']} درس، "
f"{counts['lessons']} نوعِ مسئله."
))
self.stdout.write(self.style.HTTP_INFO(
'قدمِ بعد: python manage.py generate_questions_from_chunks '
f'--grade {book.grade} (این یکی هزینه دارد)'
))
# ------------------------------------------------------------------
def _build_plan(self, book, collection, min_chunks, group_size):
"""[(chapter, [(node_title, node_order, {نوع: [chunk]}), ...]), ...]"""
plan = []
for chapter in book.chapters:
groups = self._sectioned(book, collection, chapter, min_chunks)
if not groups:
# chunkهای واردشده از Qdrantِ تیمِ دیگر شماره‌ی صفحه ندارند، پس
# نمی‌شود آن‌ها را به درس‌های فهرستِ کتاب نسبت داد. به‌جای جا
# انداختنِ کلِ فصل، انواعِ مسئله را دسته‌دسته زیرِ گره می‌بریم.
groups = self._grouped(collection, chapter, min_chunks, group_size)
if groups:
plan.append((chapter, groups))
return plan
def _sectioned(self, book, collection, chapter, min_chunks):
"""درس‌بندیِ واقعی از فهرستِ کتاب — فقط وقتی chunkها شماره‌ی صفحه دارند."""
groups = []
sections = chapter.resolved_sections()
for position, section in enumerate(sections):
# اولین درس تا ابتدای خودِ فصل عقب کشیده می‌شود: صفحه‌ی بازکننده‌ی
# فصل (که معمولاً قبل از «درس اول» است) در هیچ بازه‌ای نمی‌افتاد و
# chunkهایش بی‌صدا از برنامه‌ی درسی حذف می‌شدند.
start_page = chapter.first_page if position == 0 else section.first_page
first = start_page + book.page_offset
last = section.last_page + book.page_offset
chunks = list(
LessonChunk.objects
.filter(collection=collection, chapter=chapter.number,
source_page__gte=first, source_page__lte=last)
.order_by('source_page', 'order')
)
by_type = self._by_type(chunks, min_chunks)
if by_type:
groups.append((section.title, section.number, by_type))
return groups
def _grouped(self, collection, chapter, min_chunks, group_size):
"""
جایگزین برای فصلی که chunkهایش شماره‌ی صفحه ندارند (دادهٔ واردشده از
Qdrantِ تیمِ دیگر).
⚠️ این گروه‌بندی **مالِ ماست، نه ترتیبِ کتاب**: نقاطِ آن collection نه
شماره‌ی صفحه دارند و نه ترتیب، و scroll هم بر اساسِ UUID برمی‌گرداند
یعنی عملاً تصادفی. پس بر اساسِ نامِ نوعِ مسئله مرتب می‌کنیم تا دستِ‌کم
قطعی و تکرارپذیر باشد، و گره را «گروه» می‌نامیم نه «بخش» تا وانمود
نکند ترتیبِ آموزشیِ کتاب است.
"""
chunks = list(
LessonChunk.objects
.filter(collection=collection, chapter=chapter.number, source_page__isnull=True)
.order_by('order', 'id')
)
by_type = self._by_type(chunks, min_chunks)
if not by_type:
return []
# «فصل ۲: کسر» → «کسر»، تا عنوانِ گره در نقشه‌ی مهارت تکراری نشود.
topic = chapter.title.split(':', 1)[-1].strip() or chapter.title
items = sorted(by_type.items(), key=lambda kv: kv[0])
parts = [items[i:i + group_size] for i in range(0, len(items), group_size)]
return [
(topic if len(parts) == 1 else f'{topic} — گروه {index}', index, dict(part))
for index, part in enumerate(parts, start=1)
]
@staticmethod
def _by_type(chunks, min_chunks):
"""هر نوعِ مسئله یک Lesson؛ یک نوع که در چند جا آمده یکی می‌شود."""
by_type: dict[str, list[LessonChunk]] = {}
for chunk in chunks:
key = (chunk.problem_type or '').strip() or 'تمرین'
by_type.setdefault(key, []).append(chunk)
return {k: v for k, v in by_type.items() if len(v) >= min_chunks}
def _print_plan(self, book, plan):
self.stdout.write(self.style.MIGRATE_HEADING(
f'\nپایه {book.grade} — {len(plan)} فصل با محتوای موجود\n'
))
for chapter, groups in plan:
total = sum(len(t) for _title, _order, t in groups)
self.stdout.write(f' {chapter.title} ({len(groups)} درس، {total} نوع مسئله)')
for title, order, by_type in groups:
self.stdout.write(f' درس {order}: {title} — {len(by_type)} نوع')
def _apply(self, book, plan):
icon, color = GRADE_STYLE.get(book.grade, ('grade', '#607D8B'))
grade_title = f'پایه {book.grade}'
grade, _ = Grade.objects.update_or_create(
title=grade_title, defaults={'icon': icon, 'color': color},
)
counts = {'units': 0, 'nodes': 0, 'lessons': 0}
for chapter, groups in plan:
unit, _ = Unit.objects.update_or_create(
grade=grade, title=chapter.title,
defaults={'order': chapter.number},
)
counts['units'] += 1
for node_title, node_order, by_type in groups:
node, _ = Node.objects.update_or_create(
unit=unit, title=node_title,
defaults={'order': node_order},
)
counts['nodes'] += 1
for order, (problem_type, chunks) in enumerate(by_type.items(), start=1):
# متنِ مقدمه از generalSolutionِ خودِ کتاب می‌آید — محتوای
# واقعیِ آموزشی، بدونِ هیچ فراخوانیِ مدلی.
intro = next(
(c.general_solution for c in chunks if c.general_solution), ''
) or f'{problem_type} — {node_title}'
Lesson.objects.update_or_create(
node=node, title=problem_type,
defaults={'intro_text': intro, 'mascot_animation': 'explain'},
)
counts['lessons'] += 1
return counts