Files
MathApp/content/management/commands/seed_content.py
Zareian 1131569b76 ground grade 9's questions in the textbook, and catch the book's own errors
پایه نهم تا دیروز ناقص بود: ۲۸ صفحه از کتاب ingest نشده بود و فصل ۸ (حجم و
مساحت) اصلاً وجود نداشت. حالا ۱۴۳ از ۱۴۳ صفحه رفته و برنامه‌ی درسی ۸ فصل،
۲۸ مبحث و ۲۵۸ درس دارد.

۱۷۱ سوالِ تازه از روی مسئله‌های واقعیِ همان کتاب ساخته شد (جمعِ فایل: ۳۳۹).
برخلافِ سوال‌های نمونه‌ی قبلی که از عنوانِ مبحث ساخته شده بودند، این‌ها به
مسئله‌ی مشخصی در کتاب وصل‌اند.

مهم‌تر از خودِ سوال‌ها، چیزی که وسطِ کار معلوم شد: **بخشی از راه‌حل‌هایی که
vision از PDF استخراج کرده غلط است.** ۱۷ مورد پیدا شد، از جمله:

    شمارنده‌های ۶۰ که نه اول‌اند و نه مرکب → کتاب: {۱, ۶۰}   (۶۰ مرکب است)
    x−y=۵ و y=x−۳ → کتاب: (۸,۵)            (خطوط موازی‌اند، بی‌جواب)
    نور در ۱۰۰ ساعت → کتاب: ۱۰۸×۱۰¹³        (ده برابر؛ درست ۱٫۰۸×۱۰¹⁴)
    |۱−√۳| → کتاب: ۱−√۳                     (منفی است؛ درست √۳−۱)
    استوانه ۶۰cm×۱m → کتاب: ۲۸۲٫۷ m³        (خطای واحد؛ درست ۰٫۲۸۳)

اجرای قبلی با gpt-4o-mini همین جواب‌های غلط را بی‌صدا به‌عنوانِ گزینه‌ی درست
علامت می‌زد، چون فقط بازنویسی می‌کرد. مسیرِ تازه اول مسئله را مستقل حل می‌کند
و بعد با راه‌حلِ کتاب مقایسه می‌کند؛ هر اختلاف علامت می‌خورد و دستی بررسی شد.

۷۵ مسئله عمداً سوال نشدند — به شکل یا جدولی در کتاب ارجاع می‌دادند که در متن
نیست. حدس زدن در این موارد یعنی سوالِ بی‌جواب.

`seed_content --export` هم دیگر نمی‌تواند سوال‌ها را بی‌صدا پاک کند: فایل
دست‌نویس است و در دیتابیسِ هر ماشینی نیست، و یک export ساده آن را با آرایه‌ی
خالی بازنویسی می‌کرد. حالا خطا می‌دهد و `--only curriculum` را پیشنهاد می‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:40:09 +03:30

173 lines
9.0 KiB
Python

"""
بالا آوردنِ محتوای پایه از فایل‌هایی که در خودِ ریپو کامیت شده‌اند.
چرا: تا امروز تنها راهِ داشتنِ محتوا روی یک سرورِ تازه، اجرای
`rag_import_collection` و بعد `seed_curriculum_from_textbook` روی همان سرور
بود — یعنی وابستگی به Qdrant، به اعتبارِ AI، و به آدمی با دسترسیِ SSH. نتیجه
این شد که production ماه‌هاست `{"grades": []}` برمی‌گرداند و هیچ‌کس نمی‌تواند
چیزی را تست کند.
محتوا یک‌بار لوکال ساخته می‌شود، با `--export` در ریپو می‌نشیند، و از آن پس هر
دیپلوی خودش آن را بالا می‌آورد. سرورِ تازه نه Qdrant می‌خواهد نه ترمینال.
python manage.py seed_content # وارد کردن (در deploy.sh)
python manage.py seed_content --dry-run # فقط گزارش، بدونِ نوشتن
python manage.py seed_content --export # گرفتنِ خروجی از دیتابیسِ فعلی
ایمن برای اجرای تکراری: کوریکولوم با عنوان هم‌سان‌سازی می‌شود و سوالِ تکراری
رد می‌شود. هیچ‌چیز پاک نمی‌شود — روی سروری که کاربرِ واقعی دارد، دیپلوی نباید
بتواند محتوا یا پاسخی را از بین ببرد.
"""
import json
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from content import curriculum_io, question_io
from content.models import Grade, Lesson, Question
# ردیف‌های نمونه هرگز نباید در فایلِ ریپو بنشینند: این فایل مستقیم روی
# production لود می‌شود و «[DEMO] پایهٔ نمونه» همان چیزی است که در
# `/api/grades` جلوِ چشمِ کاربرِ واقعی می‌آید. purge_demo_data دقیقاً برای
# پاک کردنشان نوشته شد؛ اینجا از همان ابتدا بیرون گذاشته می‌شوند.
DEMO_PREFIXES = ('[SEED]', '[DEMO]')
SEED_DIR = Path(__file__).resolve().parents[2] / 'seed'
CURRICULUM_FILE = SEED_DIR / 'curriculum.json'
QUESTIONS_FILE = SEED_DIR / 'questions.json'
class Command(BaseCommand):
help = 'وارد کردنِ محتوای پایه از content/seed/ (ایدمپوتنت، بدونِ حذف).'
def add_arguments(self, parser):
parser.add_argument('--export', action='store_true',
help='به‌جای وارد کردن، از دیتابیسِ فعلی خروجی بگیر.')
parser.add_argument('--only', choices=('curriculum', 'questions'), default=None,
help='فقط یکی از دو فایل را خروجی بگیر.')
parser.add_argument('--dry-run', action='store_true',
help='فقط گزارش بده؛ چیزی ننویس.')
def handle(self, *args, **options):
self.only = options.get('only')
if options['export']:
return self._export()
return self._load(dry_run=options['dry_run'])
# ─── خروجی گرفتن ────────────────────────────────────────────────────────
def _export(self):
SEED_DIR.mkdir(exist_ok=True)
grades = Grade.objects.order_by('pk')
questions = Question.objects.order_by('pk')
for prefix in DEMO_PREFIXES:
grades = grades.exclude(title__startswith=prefix)
questions = questions.exclude(
lesson__node__unit__grade__title__startswith=prefix
)
curriculum = curriculum_io.export_curriculum(grades)
rows = question_io.export_questions_by_path(questions)
# محافظ در برابرِ پاک شدنِ خاموشِ سوال‌ها: فایلِ سوال‌ها دست‌نویس است و
# ممکن است در دیتابیسِ این ماشین نباشد (مثلاً وقتی فقط کوریکولوم را
# محلی ساخته‌ایم). یک export بی‌احتیاط آن را با آرایه‌ی خالی بازنویسی
# می‌کرد و کلِ کار از بین می‌رفت — بدونِ خطا.
only = self.only
if only != 'questions':
_write_json(CURRICULUM_FILE, curriculum)
if only != 'curriculum':
if not rows and _has_questions(QUESTIONS_FILE):
raise CommandError(
f'{QUESTIONS_FILE.name} سوال دارد ولی دیتابیسِ این ماشین ندارد؛ '
'بازنویسی‌اش یعنی پاک شدنشان. اگر کوریکولوم را می‌خواهید: '
'--export --only curriculum'
)
_write_json(QUESTIONS_FILE, {'questions': rows})
lessons = Lesson.objects.filter(node__unit__grade__in=grades).count()
written = []
if only != 'questions':
written.append(f'{CURRICULUM_FILE.name} '
f'({len(curriculum["grades"])} پایه، {lessons} درس)')
if only != 'curriculum':
written.append(f'{QUESTIONS_FILE.name} ({len(rows)} سوال)')
self.stdout.write(self.style.SUCCESS('نوشته شد: ' + ' و '.join(written)))
skipped = Grade.objects.count() - grades.count()
if skipped:
self.stdout.write(
f' {skipped} پایه‌ی نمونه ([DEMO]/[SEED]) کنار گذاشته شد.'
)
if not rows and only != 'curriculum':
self.stdout.write(self.style.WARNING(
' هشدار: هیچ سوالِ واقعی‌ای برای خروجی نبود — '
'برنامه‌ی درسی بدونِ سوال منتشر می‌شود.'
))
# ─── وارد کردن ──────────────────────────────────────────────────────────
def _load(self, *, dry_run: bool):
if not CURRICULUM_FILE.exists():
raise CommandError(
f'{CURRICULUM_FILE} نیست. اول با --export خروجی بگیرید.'
)
try:
grades = curriculum_io.parse(CURRICULUM_FILE.read_bytes())
except curriculum_io.CurriculumError as exc:
raise CommandError(f'{CURRICULUM_FILE.name}: {exc}')
# dry-run با rollback انجام می‌شود، نه با شمارشِ تقریبی: تنها چیزی که
# واقعاً می‌گوید چه اتفاقی می‌افتد، خودِ اتفاق است.
try:
with transaction.atomic():
counts = curriculum_io.import_curriculum(grades)
question_counts = self._load_questions()
if dry_run:
transaction.set_rollback(True)
except (curriculum_io.CurriculumError, question_io.ImportError_) as exc:
raise CommandError(str(exc))
prefix = 'اگر اجرا شود: ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'{prefix}'
f'{counts["grades"]} پایه، {counts["units"]} فصل، '
f'{counts["nodes"]} مبحث، {counts["lessons"]} درسِ تازه '
f'({counts["updated"]} درس به‌روز شد)؛ '
f'{question_counts["created"]} سوالِ تازه '
f'({question_counts["skipped"]} سوال از قبل بود)'
))
def _load_questions(self) -> dict:
if not QUESTIONS_FILE.exists():
return {'created': 0, 'skipped': 0}
raw = json.loads(QUESTIONS_FILE.read_text(encoding='utf-8'))
rows = raw.get('questions', raw) if isinstance(raw, dict) else raw
if not rows:
return {'created': 0, 'skipped': 0}
# مسیرِ عنوان → lessonId، سپس همان اعتبارسنجیِ همیشگیِ question_io.
resolved = question_io.resolve_paths(rows)
parsed = question_io.parse(json.dumps(resolved).encode('utf-8'))
return question_io.import_questions(parsed, skip_existing=True)
def _has_questions(path: Path) -> bool:
if not path.exists():
return False
try:
data = json.loads(path.read_text(encoding='utf-8'))
except json.JSONDecodeError:
return False
return bool(data.get('questions') if isinstance(data, dict) else data)
def _write_json(path: Path, payload) -> None:
# ensure_ascii=False تا فارسی در دیفِ گیت خوانا بماند، و newline انتهایی
# تا هر بار export یک دیفِ تمیز بدهد نه یک خطِ تغییرکرده‌ی الکی.
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=1) + '\n',
encoding='utf-8',
)