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:
2026-09-14 20:20:26 +03:30
parent f3be99e4e2
commit 9c3db68cbb
6 changed files with 6873 additions and 3 deletions

View File

@@ -91,12 +91,19 @@ def parse(raw: bytes) -> list[dict]:
@transaction.atomic
def import_questions(rows: list[dict], *, replace: bool = False) -> dict:
def import_questions(
rows: list[dict], *, replace: bool = False, skip_existing: bool = False,
) -> dict:
"""
همه‌ی ردیف‌ها یا با هم نوشته می‌شوند یا هیچ‌کدام — نیمه‌کاره نمی‌ماند.
replace=True یعنی سوال‌های قبلیِ همان درس‌ها اول پاک شوند؛ بدونِ آن
سوال‌ها اضافه می‌شوند.
skip_existing=True یعنی سوالی که همین صورت را در همین درس دارد دوباره
ساخته نشود. این چیزی است که وارد کردنِ فایلِ ثابتِ ریپو را در هر دیپلوی
بی‌خطر می‌کند: بدونِ آن، دیپلویِ دوم همه‌ی سوال‌ها را تکراری می‌سازد، و با
replace به‌جایش پاسخ‌های کاربران هم آبشاری پاک می‌شوند.
"""
lesson_ids = {row['lesson_id'] for row in rows}
existing = set(Lesson.objects.filter(pk__in=lesson_ids).values_list('pk', flat=True))
@@ -114,8 +121,18 @@ def import_questions(rows: list[dict], *, replace: bool = False) -> dict:
_, per_model = Question.objects.filter(lesson_id__in=lesson_ids).delete()
removed = per_model.get('content.Question', 0)
created = 0
already = set()
if skip_existing:
already = set(
Question.objects.filter(lesson_id__in=lesson_ids)
.values_list('lesson_id', 'prompt')
)
created = skipped = 0
for row in rows:
if (row['lesson_id'], row['prompt']) in already:
skipped += 1
continue
question = Question.objects.create(
lesson_id=row['lesson_id'], type=row['type'], prompt=row['prompt'],
)
@@ -124,8 +141,14 @@ def import_questions(rows: list[dict], *, replace: bool = False) -> dict:
for i, value in enumerate(row['choices'])
])
created += 1
if skip_existing:
# ردیفِ تکراری در خودِ فایل هم نباید دو بار ساخته شود.
already.add((row['lesson_id'], row['prompt']))
return {'created': created, 'removed': removed, 'lessons': len(lesson_ids)}
return {
'created': created, 'removed': removed,
'lessons': len(lesson_ids), 'skipped': skipped,
}
def export_questions(queryset) -> list[dict]:
@@ -142,3 +165,73 @@ def export_questions(queryset) -> list[dict]:
'correct': correct,
})
return out
# ─── سوال‌ها بر پایه‌ی مسیرِ عنوان ───────────────────────────────────────────
#
# `lessonId` برای کارِ روزمره‌ی ادمین خوب است (کاربر درس را از همان صفحه
# برمی‌دارد و id جلوِ چشمش است)، ولی برای فایلی که در ریپو کامیت می‌شود و روی
# سرورِ دیگری لود خواهد شد اشتباهِ خطرناکی است: idها بینِ دو دیتابیس یکی
# نیستند. لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ
# دیتای دمو)، و همان کوریکولوم روی یک دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ می‌گیرد.
# پس سوالِ درسِ ۳ روی سرور به درسِ بی‌ربطی می‌چسبد — بدونِ هیچ خطایی.
#
# مسیرِ عنوان (پایه › فصل › مبحث › درس) همان چیزی است که curriculum_io هم با
# آن هم‌سان‌سازی می‌کند، پس هر دو فایل به یک زبان حرف می‌زنند.
def export_questions_by_path(queryset) -> list[dict]:
"""همان قالبِ export_questions، ولی با مسیرِ عنوان به‌جای lessonId."""
out = []
for question in queryset.prefetch_related('choices').select_related(
'lesson__node__unit__grade'
):
choices = list(question.choices.all())
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
lesson = question.lesson
node = lesson.node
out.append({
'path': [node.unit.grade.title, node.unit.title, node.title, lesson.title],
'prompt': question.prompt,
'type': question.type,
'choices': [c.value for c in choices],
'correct': correct,
})
return out
def resolve_paths(rows: list[dict]) -> list[dict]:
"""
مسیرهای عنوان را به lesson_id تبدیل می‌کند.
اگر مسیری پیدا نشود خطا می‌دهد و هیچ ردیفی برنمی‌گرداند: مسیرِ گم‌شده یعنی
فایلِ سوال‌ها و فایلِ کوریکولوم با هم نمی‌خوانند، و وارد کردنِ بقیه یعنی
محتوایی ناقص که کسی متوجهش نمی‌شود.
"""
lessons = {
(lesson.node.unit.grade.title, lesson.node.unit.title,
lesson.node.title, lesson.title): lesson.pk
for lesson in Lesson.objects.select_related('node__unit__grade')
}
resolved, missing = [], []
for index, row in enumerate(rows):
path = row.get('path')
if not isinstance(path, list) or len(path) != 4:
raise ImportError_(
f'ردیف {index + 1}: «path» باید چهار عنوان باشد '
'(پایه، فصل، مبحث، درس).'
)
key = tuple(str(part).strip() for part in path)
lesson_id = lessons.get(key)
if lesson_id is None:
missing.append(' › '.join(key))
continue
resolved.append({**{k: v for k, v in row.items() if k != 'path'},
'lessonId': lesson_id})
if missing:
raise ImportError_(
'این درس‌ها در برنامه‌ی درسی پیدا نشدند (اول کوریکولوم را وارد کنید): '
+ '؛ '.join(missing[:5]) + ('…' if len(missing) > 5 else '')
)
return resolved