elemes/scripts/convert_quiz_keys.py

265 lines
9.2 KiB
Python
Raw Permalink Normal View History

feat(quiz): taksonomi key quiz per jenis + MCQ berbobot (partial credit) Fitur B — setiap jenis soal kini punya key sendiri sehingga model penilaiannya eksplisit, tidak ditebak dari isi konten: - ---QUIZ_MCQ--- pilihan ganda biner (tepat satu [x]; larang [N]) - ---QUIZ_WEIGHTED--- pilihan ganda berbobot ([N] >= 1, minimal satu [ ], bobot tertinggi unik = kunci; poin parsial) - ---FLASHCARD--- kartu hafalan tanpa penilaian (larang opsi) - legacy ---QUIZ_FLASHCARD--- / ---INITIAL_QUIZ--- tetap dibaca tanpa perubahan perilaku (regex opsi legacy sengaja tidak berubah — baris '- [10]' di konten lama BUKAN opsi); dihapus dari docs/template - ::diagnostic tetap kategori di dalam QUIZ_MCQ & tetap masuk skor resmi Model skor: rata-rata persentase per jenis yang ada, Math.round, lulus >= 75. statusString: "X/Y" bila hanya MCQ/legacy, "P/100" bila ada weighted, "completed" bila hanya flashcard (auto-lulus). Satu sesi/ attempt untuk semua jenis; soal flat urut dokumen. Parser (backend & frontend mirror, ekstraksi multi-blok per key urut dokumen via loop marker-start paling awal; id q-N deterministik): - lesson_service.py: regex [ xX]?[0-9]* utk key baru, _iter_option_lines kind-aware, _validate_quiz_options per kind, _parse_flashcards(kind, id_offset), _extract_quiz_sections + guard loop maju (marker end hilang tidak lagi infinite loop), get_lesson_quiz_total -> 100 bila ada weighted (round-trip CSV "P/100") - parseFlashcards.ts / index.ts / detectActiveTabs.ts / extractSections.ts: mirror perilaku; heading soal <h3> kini fence-aware (fix fixture stale) Payload attempt baru: quiz_type, earned_points, question_max_points — opsional di validasi backend supaya klien lama tetap diterima; poin di-recompute dari konten saat resume/review. UI: QuizTab skor via statusString + alert berbasis percent, badge weighted (✓ 10 / 🟡 7/10 / ✗ 0) di review, preview guru badge bobot + "Berbobot (total N)", laporan guru badge MCQ/Bobot + tooltip, CSV progress 2 kolom baru per lesson (_quiz_mcq, _quiz_weighted), template editor konten QUIZ_MCQ/Quiz Bobot/Flashcard. Tooling & docs: scripts/convert_quiz_keys.py (konversi legacy -> key baru per run jenis, idempoten, verifikasi parse identik; examples/ content dikonversi), scanner mendukung semua key, docs/07 ditulis ulang, docs/15 direvisi ke desain final, README/documentation/docs 02/04/09 diperbarui. Tes: backend 444 lulus (+33: validasi ketat per key, urutan dokumen, multi-blok, zero regression, field selalu di-emit), frontend 249 lulus (+13 parser key baru, +11 model skor), parity fixture diregenerasi, svelte-check 0 error, vite build OK.
2026-10-25 20:16:56 +07:00
#!/usr/bin/env python3
"""Konversi key quiz legacy ---QUIZ_FLASHCARD--- ke key baru per jenis.
Fitur B (taksonomi key quiz): setiap jenis quiz kini punya key sendiri:
---QUIZ_MCQ--- soal pilihan ganda biner (tepat satu [x]; ::diagnostic
tetap valid sebagai penanda kategori di dalamnya)
---QUIZ_WEIGHTED--- soal pilihan ganda berbobot ([N], N >= 1)
---FLASHCARD--- kartu hafalan tanpa opsi
Script ini menulis ulang blok legacy menjadi rangkaian blok baru dengan
aturan "run berurutan": soal-soal MCQ dan flashcard yang BERSAMBUNG di blok
legacy digabung ke satu blok baru masing-masing, sehingga URUTAN soal (dan
dengan itu id `q-0`, `q-1`, ...) tetap persis sama setelah konversi.
Contoh: [MCQ, MCQ, Flash, MCQ] → QUIZ_MCQ(2) + FLASHCARD(1) + QUIZ_MCQ(1).
Sifat:
- Idempoten — dijalankan kedua kali tidak mengubah apa pun (tidak ada blok
legacy tersisa setelah konversi pertama).
- Terverifikasi — setelah menulis, hasil parse `_render_markdown_string`
dibandingkan dengan hasil parse SEBELUM konversi; mismatch → exit 1.
Pemakaian:
python scripts/convert_quiz_keys.py # proses examples/content
python scripts/convert_quiz_keys.py content/ # path folder/file lain
python scripts/convert_quiz_keys.py --dry-run # lihat rencana saja
"""
import argparse
import os
import re
import sys
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
LEGACY_START = '---QUIZ_FLASHCARD---'
LEGACY_END = '---END_QUIZ_FLASHCARD---'
# Regex opsi legacy — sama dengan services/lesson_service.py::_OPTION_LINE_RE.
_OPTION_LINE_RE = re.compile(r'^\s*-\s*\[([ xX]?)\]\s*(.*)$')
def split_question_blocks(text):
"""Pecah markdown jadi blok per soal pada heading (`^#{1,3}\\s+`),
mengabaikan heading di dalam fenced code block. Mirror dari
`_split_flashcard_parts` (services/lesson_service.py).
"""
parts = []
buf = []
in_fence = False
for line in text.split('\n'):
if line.strip().startswith('```'):
in_fence = not in_fence
buf.append(line)
continue
if not in_fence and re.match(r'^#{1,3}\s+', line):
if buf:
parts.append('\n'.join(buf))
buf = [line]
continue
buf.append(line)
if buf:
parts.append('\n'.join(buf))
return [p for p in parts if p.strip()]
def is_mcq_block(block):
"""True bila blok soal punya minimal satu baris opsi legacy."""
in_fence = False
for line in block.split('\n'):
if line.strip().startswith('```'):
in_fence = not in_fence
continue
if not in_fence and _OPTION_LINE_RE.match(line):
return True
return False
def group_runs(blocks):
"""Kelompokkan blok soal berurutan dengan jenis sama → list of (kind, text)."""
runs = []
for block in blocks:
kind = 'mcq' if is_mcq_block(block) else 'flashcard'
if runs and runs[-1][0] == kind:
runs[-1] = (kind, runs[-1][1].rstrip('\n') + '\n\n' + block)
else:
runs.append((kind, block))
return runs
RUN_MARKER = {
'mcq': ('---QUIZ_MCQ---', '---END_QUIZ_MCQ---'),
'flashcard': ('---FLASHCARD---', '---END_FLASHCARD---'),
}
def convert_text(text):
"""Return (converted_text, jumlah_blok_legacy, jumlah_soal) atau
(text, 0, 0) bila tidak ada blok legacy."""
if LEGACY_START not in text:
return text, 0, 0
out = []
question_count = 0
legacy_blocks = 0
rest = text
while LEGACY_START in rest:
start_idx = rest.find(LEGACY_START)
end_idx = rest.find(LEGACY_END, start_idx + len(LEGACY_START))
if end_idx == -1:
# Marker end hilang — biarkan apa adanya (parser juga melewatinya).
out.append(rest)
rest = ''
break
before = rest[:start_idx]
inner = rest[start_idx + len(LEGACY_START):end_idx]
after = rest[end_idx + len(LEGACY_END):]
blocks = split_question_blocks(inner)
legacy_blocks += 1
if not blocks:
# Blok kosong — biarkan legacy (menghapus marker mengubah status
# tab quiz; lebih aman dibiarkan & dilaporkan).
out.append(rest[:end_idx + len(LEGACY_END)])
rest = after
continue
segments = []
for kind, run_text in group_runs(blocks):
m_start, m_end = RUN_MARKER[kind]
segments.append(f"{m_start}\n{run_text.strip()}\n{m_end}")
question_count += len(split_question_blocks(run_text))
out.append(before + '\n\n' + '\n\n'.join(segments) + '\n\n')
rest = after
if rest:
out.append(rest)
return ''.join(out), legacy_blocks, question_count
def render_quiz_summary(parsed):
"""Ringkasan quiz_data utk perbandingan sebelum/sesudah konversi."""
summary = []
for q in parsed.get('quiz_data') or []:
summary.append({
'id': q.get('id'),
'type': q.get('type'),
'quiz_type': q.get('quiz_type'),
'category': q.get('category'),
'image': q.get('image'),
'question': q.get('question'),
'front': q.get('front'),
'back': q.get('back'),
'explanation': q.get('explanation'),
'options': [
{
'id': o.get('id'),
'text': o.get('text'),
'is_correct': o.get('is_correct'),
'weight': o.get('weight'),
}
for o in (q.get('options') or [])
],
})
return summary
def verify_parse_parity(path, before_raw, after_raw):
"""Bandingkan hasil parse (quiz_data + active_tabs) sebelum vs sesudah."""
from services.lesson_service import _render_markdown_string
before = _render_markdown_string(before_raw)
after = _render_markdown_string(after_raw)
problems = []
if render_quiz_summary(before) != render_quiz_summary(after):
problems.append('quiz_data berubah setelah konversi')
if before.get('active_tabs') != after.get('active_tabs'):
problems.append(
f"active_tabs berubah: {before.get('active_tabs')} -> {after.get('active_tabs')}"
)
return problems
def iter_markdown_files(paths):
for p in paths:
if os.path.isfile(p):
yield p
elif os.path.isdir(p):
for root, _dirs, files in os.walk(p):
for name in sorted(files):
if name.endswith('.md'):
yield os.path.join(root, name)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
'paths', nargs='*',
default=[os.path.join(REPO_ROOT, 'examples', 'content')],
help='File/folder markdown yang diproses (default: examples/content)',
)
parser.add_argument('--dry-run', action='store_true', help='Tidak menulis file')
parser.add_argument(
'--no-verify', action='store_true',
help='Lewati verifikasi parse sebelum/sesudah konversi',
)
args = parser.parse_args()
# Import parser backend hanya untuk verifikasi.
if not args.no_verify:
try:
import services.lesson_service # noqa: F401
except Exception as exc: # noqa: BLE001
print(f"Peringatan: parser backend tidak bisa diimport ({exc}); "
f"verifikasi dilewati.", file=sys.stderr)
args.no_verify = True
converted = skipped = 0
failures = 0
for path in iter_markdown_files(args.paths):
with open(path, 'r', encoding='utf-8') as f:
original = f.read()
if LEGACY_START not in original:
continue
converted_text, legacy_blocks, question_count = convert_text(original)
if legacy_blocks == 0:
continue
rel = os.path.relpath(path, REPO_ROOT)
if not question_count:
print(f"LEWATI {rel} — blok legacy tanpa soal (dibiarkan apa adanya)")
skipped += 1
continue
label = 'DRY-RUN' if args.dry_run else 'KONVERSI'
print(f"{label} {rel} — {legacy_blocks} blok legacy, {question_count} soal")
if not args.dry_run:
with open(path, 'w', encoding='utf-8') as f:
f.write(converted_text)
if not args.no_verify:
problems = verify_parse_parity(path, original, converted_text)
if problems:
failures += 1
print(f" GAGAL verifikasi: {'; '.join(problems)}", file=sys.stderr)
# Pulihkan konten asli agar repo tidak setengah terkonversi.
with open(path, 'w', encoding='utf-8') as f:
f.write(original)
continue
print(" verifikasi parse OK (quiz_data & active_tabs identik)")
converted += 1
print(f"\nSelesai: {converted} file dikonversi, {skipped} dilewati, {failures} gagal verifikasi.")
if args.dry_run:
print("(dry-run — tidak ada file yang ditulis)")
return 1 if failures else 0
if __name__ == '__main__':
sys.exit(main())