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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user