پایه نهم تا دیروز ناقص بود: ۲۸ صفحه از کتاب 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>
173 lines
9.0 KiB
Python
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',
|
|
)
|