265 lines
9.2 KiB
Python
265 lines
9.2 KiB
Python
#!/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())
|