diff --git a/services/lesson_service.py b/services/lesson_service.py index 82a02ba..9a2d756 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -829,6 +829,39 @@ def _extract_body_image(text): return None, text +def _split_flashcard_parts(text): + """Split quiz markdown into per-question blocks on heading lines + (`^#{1,3}\\s+`), but ignore heading-looking lines that live INSIDE a + fenced code block (``` ... ```). + + Re-implementasi dari `re.split(r'^#{1,3}\\s+', text, MULTILINE)` yang lama + agar komentar `#` di dalam kode (python/shell/bash/yaml) tidak membelah + soal. Penggunaan state-machine per-baris, bukan placeholder, supaya tidak + ada risiko tabrakan string. + """ + parts: list[str] = [] + buf: list[str] = [] + in_fence = False + for line in text.split('\n'): + stripped = line.strip() + # Toggle fence state pada baris yang HANYA berisi ``` (atau ```lang) + if stripped.startswith('```'): + in_fence = not in_fence + buf.append(line) + continue + # Heading pembatas soal — hanya di luar fence + 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)) + # Baris pertama biasanya kosong (teks sebelum heading `###` pertama) + return [p for p in parts if p.strip()] + + def _parse_flashcards(text): """Parse a string of markdown with headings and options into a list of dicts. @@ -848,9 +881,12 @@ def _parse_flashcards(text): """ if not text.strip(): return [] - - # Split by headings starting with #, ##, or ### - parts = re.split(r'^#{1,3}\s+', text, flags=re.MULTILINE) + + # Split by headings starting with #, ##, or ### — but ONLY outside fenced + # code blocks. A line like `# ini komentar` inside ```python must NOT be + # treated as a new question (it would split the question and steal its + # options, producing phantom questions / mismatched choices). + parts = _split_flashcard_parts(text) flashcards = [] for part in parts: diff --git a/services/tests/test_quiz_hash_in_code_fence.py b/services/tests/test_quiz_hash_in_code_fence.py new file mode 100644 index 0000000..57611f5 --- /dev/null +++ b/services/tests/test_quiz_hash_in_code_fence.py @@ -0,0 +1,45 @@ +import pytest + +pytestmark = pytest.mark.unit +from services.lesson_service import _parse_flashcards + + +def test_mcq_code_fence_with_hash_comment_is_single_question(): + """Soal yang prompt-nya berisi code fence dengan komentar `#` (python/shell) + tidak boleh di-split menjadi soal ganda — opsi tetap milik soal tersebut. + + REGRESI: splitter `^#{1,3}\\s+` di _parse_flashcards membelah teks + pada baris `# ini komentar`, sehingga opsi meleset ke 'soal hantu'. + """ + md_text = """### Berapa hasil dari kode berikut? +```python +# ini komentar python +x = 5 +print(x) +``` +- [] 3 +- [x] 5 +- [] 5.0 +""" + quiz = _parse_flashcards(md_text) + # Harus 1 soal, bukan 2 (soal asli + 'ini komentar python' sebagai phantom) + assert len(quiz) == 1, f"ter-split menjadi {len(quiz)} soal: {[q.get('question', q.get('front')) for q in quiz]}" + q = quiz[0] + assert q['type'] == 'mcq' + assert len(q['options']) == 3, f"opsi cuma {len(q['options'])}: {q['options']}" + assert q['options'][1]['is_correct'] is True + + +def test_mcq_code_fence_with_hash_comment_in_middle(): + """Kasus `#` komentar muncul di tengah kode (bukan baris pertama).""" + md_text = """### Apa output? +```bash +echo "hi" # comment di sini +echo "bye" +``` +- [x] hi +- [] bye +""" + quiz = _parse_flashcards(md_text) + assert len(quiz) == 1, f"ter-split menjadi {len(quiz)} soal" + assert len(quiz[0]['options']) == 2