carry the curriculum in the repo so a fresh server is not empty
سرورِ production از روزِ اول `{"grades": []}` برگردانده: دیتابیس gitignore است
(درست هم هست) و تنها راهِ پر کردنش اجرای `rag_import_collection` و بعد
`seed_curriculum_from_textbook` روی همان سرور بود — یعنی وابستگی به Qdrant، به
اعتبارِ AI که تمام شده، و به کسی که SSH دارد. نتیجه این شد که هیچکس نتوانست
چیزی را تست کند.
حالا برنامهی درسی در `content/seed/curriculum.json` کامیت میشود (۴ پایه، ۳۱
فصل، ۱۶۸ مبحث، ۱۳۰۰ درس، ۵۲۷ کیلوبایت متن) و `deploy.sh` خودش بالا میآوردش.
سرورِ تازه نه Qdrant میخواهد نه ترمینال.
`db.sqlite3` عمداً کامیت نمیشود: باینری است، دیف نمیشود، و هر دیپلوی محتوای
سرور — و فردا پاسخهای کاربران — را با نسخهی لوکالِ یک نفر جایگزین میکند.
سوالها با مسیرِ عنوان ذخیره میشوند نه با lessonId. این تصمیمِ اصلیِ کامیت
است: لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ دیتای
دمو)، و همان کوریکولوم روی دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ میگیرد. با id،
سوالِ درسِ ۳ روی سرور به درسِ بیربطی میچسبید — بدونِ هیچ خطایی. تست
`test_question_follows_the_title_path_not_the_id` همین را نگه میدارد.
`seed_content` ایدمپوتنت است و هیچچیز پاک نمیکند: کوریکولوم با عنوان
همسانسازی میشود و سوالِ تکراری رد میشود (`skip_existing`). روی سروری که
کاربرِ واقعی دارد، دیپلوی نباید بتواند محتوا یا پاسخی را از بین ببرد — و
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بیآنکه دیپلویِ بعدی برش گرداند.
ردیفهای `[DEMO]`/`[SEED]` موقعِ export کنار گذاشته میشوند؛ آنها همانهاییاند
که `purge_demo_data` برای پاک کردنشان نوشته شد و جایشان در فایلی که مستقیم روی
production لود میشود نیست.
هنوز صفر سوالِ واقعی داریم: هر ۱۴ سوالِ لوکال رویِ ردیفهای دمو بودند. مسیرِ
سوالها ساخته و تست شده، ولی تا وقتی اعتبارِ AI شارژ نشود فایلش خالی میماند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
143
content/management/commands/seed_content.py
Normal file
143
content/management/commands/seed_content.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
بالا آوردنِ محتوای پایه از فایلهایی که در خودِ ریپو کامیت شدهاند.
|
||||
|
||||
چرا: تا امروز تنها راهِ داشتنِ محتوا روی یک سرورِ تازه، اجرای
|
||||
`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('--dry-run', action='store_true',
|
||||
help='فقط گزارش بده؛ چیزی ننویس.')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
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)
|
||||
_write_json(CURRICULUM_FILE, curriculum)
|
||||
_write_json(QUESTIONS_FILE, {'questions': rows})
|
||||
|
||||
lessons = Lesson.objects.filter(node__unit__grade__in=grades).count()
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'نوشته شد: {CURRICULUM_FILE.name} '
|
||||
f'({len(curriculum["grades"])} پایه، {lessons} درس) '
|
||||
f'و {QUESTIONS_FILE.name} ({len(rows)} سوال)'
|
||||
))
|
||||
skipped = Grade.objects.count() - grades.count()
|
||||
if skipped:
|
||||
self.stdout.write(
|
||||
f' {skipped} پایهی نمونه ([DEMO]/[SEED]) کنار گذاشته شد.'
|
||||
)
|
||||
if not rows:
|
||||
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 _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',
|
||||
)
|
||||
Reference in New Issue
Block a user