feat(quiz): tag kategori diagnostik + breakdown evaluasi/diagnostik di kuis & report guru

- Backend parser (_parse_flashcards): deteksi marker ::diagnostic di baris sendiri -> category='diagnostik' (default evaluasi), strip dari tampilan.
- Persistensi: kolom answers_json (migration 0003) simpan ringkasan per-soal {question_id, selected_option_id, is_correct, category}.
- quiz_attempts: validasi/serialize answers_json + endpoint GET /quiz-attempts/<lesson> untuk review-after-refresh.
- quiz-session: hitung evalCorrect/evalTotal/diagCorrect/diagTotal/diagUnmastered; skor resmi = evaluasi saja.
- Frontend: QuizTab badge per-soal + breakdown 'Belum dikuasai'; progress guru tampil badge Eval/Diag per lesson.
- Content: pindahkan ::diagnostic ke baris sendiri (fix kerusakan fenced code/blockquote dari edit sebelumnya).
- Tests: unit parser kategori + payload answers.
This commit is contained in:
a2nr 2026-08-15 09:33:23 +07:00
parent 79edcd1596
commit 58cb35a01a
16 changed files with 548 additions and 45 deletions

View File

@ -10,6 +10,7 @@ image: https://upload.wikimedia.org/wikipedia/commons/1/18/C_Programming_Languag
> Penjelasan: Bahasa C sebenarnya dikategorikan sebagai bahasa tingkat menengah (*middle-level language*) karena memiliki fitur bahasa tingkat rendah (seperti manipulasi memori) sekaligus fitur bahasa tingkat tinggi (seperti struktur kontrol yang manusiawi).
### Apa output dari `printf("%d", 5 + 5);`?
::diagnostic
- [] 55
- [x] 10
- [] 5 + 5
@ -29,7 +30,8 @@ Berapakah keluaran program di atas?
> Pembahasan: `x * 3` menghasilkan `6` karena nilai `x` adalah `2`.
### Berdasarkan tabel berikut, tipe data mana yang tepat untuk menyimpan `3.14`?
| Tipe | Ukuran | Rentang |
::diagnostic
|| Tipe | Ukuran | Rentang ||
|--------|--------|--------------------|
| int | 4 byte | -2^31 s/d 2^31-1 |
| float | 4 byte | ~1e-38 s/d ~1e38 |
@ -40,6 +42,7 @@ Berapakah keluaran program di atas?
> Pembahasan: `double` punya presisi lebih tinggi dan rentang lebih luas untuk bilangan pecahan.
### Berapakah nilai dari $\int_0^1 2x \, dx$?
::diagnostic
- [x] 1
- [] 2
- [] 0.5
@ -67,4 +70,4 @@ image: demo_quiz.png
- [] Kuning
> Penjelasan: Langit terlihat biru karena hamburan Rayleigh — cahaya matahari dihamburkan oleh atmosfer bumi.
---END_QUIZ_FLASHCARD---
---END_QUIZ_FLASHCARD---

View File

@ -58,6 +58,7 @@ describe('submitQuizAttempt', () => {
occurred_at: '2026-08-09T14:04:44.000Z',
started_at: '2026-08-09T14:03:00.000Z',
visibility_event_count: 1,
answers: [],
...overrides
};
}
@ -85,7 +86,8 @@ describe('submitQuizAttempt', () => {
score: '2/4',
occurred_at: p.occurred_at,
started_at: p.started_at,
visibility_event_count: 1
visibility_event_count: 1,
answers: []
});
});
@ -110,37 +112,39 @@ describe('quizAttemptBeacon', () => {
vi.stubGlobal('navigator', { sendBeacon });
const p: QuizAttemptSubmission = {
attempt_id: '3f2f8c24-8c1a-4b2a-9e5a-1a2b3c4d5e6f',
token: 'student-token',
lesson_name: 'quiz_test',
status: 'terminated',
termination_reason: 'focus_lost',
score: '2/4',
occurred_at: '2026-08-09T14:04:44.000Z',
started_at: '2026-08-09T14:03:00.000Z',
visibility_event_count: 1
};
attempt_id: '3f2f8c24-8c1a-4b2a-9e5a-1a2b3c4d5e6f',
token: 'student-token',
lesson_name: 'quiz_test',
status: 'terminated',
termination_reason: 'focus_lost',
score: '2/4',
occurred_at: '2026-08-09T14:04:44.000Z',
started_at: '2026-08-09T14:03:00.000Z',
visibility_event_count: 1,
answers: [],
};
const ok = quizAttemptBeacon(p);
expect(ok).toBe(true);
expect(sendBeacon).toHaveBeenCalledTimes(1);
expect(sendBeacon.mock.calls[0][0]).toBe('/api/quiz-attempts/submit');
const blob = sendBeacon.mock.calls[0][1];
expect(blob.type).toBe('application/json');
const text = await blob.text();
const body = JSON.parse(text);
expect(body).toEqual({
attempt_id: p.attempt_id,
token: p.token,
lesson_name: 'quiz_test',
status: 'terminated',
termination_reason: 'focus_lost',
score: '2/4',
occurred_at: p.occurred_at,
started_at: p.started_at,
visibility_event_count: 1
const ok = quizAttemptBeacon(p);
expect(ok).toBe(true);
expect(sendBeacon).toHaveBeenCalledTimes(1);
expect(sendBeacon.mock.calls[0][0]).toBe('/api/quiz-attempts/submit');
const blob = sendBeacon.mock.calls[0][1];
expect(blob.type).toBe('application/json');
const text = await blob.text();
const body = JSON.parse(text);
expect(body).toEqual({
attempt_id: p.attempt_id,
token: p.token,
lesson_name: 'quiz_test',
status: 'terminated',
termination_reason: 'focus_lost',
score: '2/4',
occurred_at: p.occurred_at,
started_at: p.started_at,
visibility_event_count: 1,
answers: p.answers
});
});
});
it('tidak melempar dan mengembalikan false saat sendBeacon tidak tersedia', () => {
vi.stubGlobal('navigator', {});
@ -153,7 +157,8 @@ describe('quizAttemptBeacon', () => {
score: '2/4',
occurred_at: '2026-08-09T14:04:44.000Z',
started_at: '2026-08-09T14:03:00.000Z',
visibility_event_count: 1
visibility_event_count: 1,
answers: []
};
expect(quizAttemptBeacon(p)).toBe(false);
});

View File

@ -155,6 +155,15 @@ export interface QuizAttemptSubmission {
occurred_at: string;
started_at: string;
visibility_event_count: number;
/** Ringkasan per-soal untuk review-after-refresh + breakdown kategori. */
answers: QuizAnswerPayload[];
}
export interface QuizAnswerPayload {
question_id: string;
selected_option_id: string | null;
is_correct: boolean;
category: 'evaluasi' | 'diagnostik';
}
export interface QuizAttemptSubmitResponse {
@ -164,6 +173,20 @@ export interface QuizAttemptSubmitResponse {
message: string;
}
/** Attempt yang dikembalikan endpoint GET (untuk review-after-refresh). */
export interface QuizAttemptFetchResponse {
success: boolean;
attempt_id: string;
status: 'submitted' | 'terminated';
termination_reason: QuizTerminationReason | null;
score: string;
score_earned?: number | null;
score_total?: number | null;
started_at?: string | null;
finished_at?: string | null;
answers: QuizAnswerPayload[];
}
/**
* Submit finalisasi kuis (satu attempt) — endpoint atomic di backend.
* Dipakai untuk exit path normal: tombol Keluar, SPA navigation, finish.
@ -175,6 +198,20 @@ export function submitQuizAttempt(
return post<QuizAttemptSubmitResponse>('/quiz-attempts/submit', payload, customFetch);
}
/**
* Fetch attempt kuis siswa untuk sebuah lesson (GET /quiz-attempts/<lesson>).
* Dipakai saat halaman dibuka kembali supaya review per-soal tetap tampil
* setelah refresh (one-attempt policy → session hilang, tapi attempt persist).
*/
export function fetchQuizAttempt(
token: string,
lessonName: string,
customFetch = fetch
): Promise<QuizAttemptFetchResponse> {
const query = `token=${encodeURIComponent(token)}`;
return get<QuizAttemptFetchResponse>(`/quiz-attempts/${lessonName}?${query}`, customFetch);
}
/**
* Kirim attempt via `navigator.sendBeacon` (synchronous, fire-and-forget).
* Dipakai untuk event lifecycle yang bisa membuat browser suspend

View File

@ -13,8 +13,16 @@ export interface QuizResult {
unansweredMcq: number;
completedFlashcards: number;
allHandled: boolean;
/** e.g. "2/4" when MCQ exist, "completed" when only flashcards. */
/** Skor resmi = evaluasi saja. "X/Y" bila ada MCQ evaluasi, "completed" bila tidak ada MCQ evaluasi (hanya diagnostik/flashcard). */
statusString: string;
/** MCQ dengan category 'evaluasi' */
evalCorrect: number;
evalTotal: number;
/** MCQ dengan category 'diagnostik' */
diagCorrect: number;
diagTotal: number;
/** Soal diagnostik yang salah/atur tidak dijawab */
diagUnmastered: QuizQuestion[];
}
/**
@ -74,12 +82,28 @@ export function calculateQuizResult(session: QuizSession): QuizResult {
let unansweredMcq = 0;
let completedFlashcards = 0;
let evalCorrect = 0;
let evalTotal = 0;
let diagCorrect = 0;
let diagTotal = 0;
const diagUnmastered: QuizQuestion[] = [];
for (const q of session.questions) {
const a = session.answers[q.id];
const isEval = q.category !== 'diagnostik'; // default 'evaluasi'
if (q.type === 'mcq') {
totalMcq += 1;
if (a.isCorrect) correctMcq += 1;
if (a.selectedOptionId === null) unansweredMcq += 1;
if (isEval) {
evalTotal += 1;
if (a.isCorrect) evalCorrect += 1;
} else {
diagTotal += 1;
if (a.isCorrect) diagCorrect += 1;
else diagUnmastered.push(q);
}
} else if (a.acknowledged) {
completedFlashcards += 1;
}
@ -90,7 +114,20 @@ export function calculateQuizResult(session: QuizSession): QuizResult {
? session.answers[q.id].selectedOptionId !== null
: session.answers[q.id].acknowledged
);
const statusString = totalMcq > 0 ? `${correctMcq}/${totalMcq}` : 'completed';
// Skor resmi = evaluasi saja; bila tidak ada MCQ evaluasi → 'completed'.
const statusString = evalTotal > 0 ? `${evalCorrect}/${evalTotal}` : 'completed';
return { totalMcq, correctMcq, unansweredMcq, completedFlashcards, allHandled, statusString };
return {
totalMcq,
correctMcq,
unansweredMcq,
completedFlashcards,
allHandled,
statusString,
evalCorrect,
evalTotal,
diagCorrect,
diagTotal,
diagUnmastered,
};
}

View File

@ -7,6 +7,7 @@ export interface QuizOption {
export interface QuizQuestion {
id: string;
type: 'flashcard' | 'mcq';
category?: 'evaluasi' | 'diagnostik';
question?: string;
front?: string;
back?: string;

View File

@ -1,8 +1,13 @@
<script lang="ts">
import { authLoggedIn } from '$stores/auth';
import { authLoggedIn, authToken } from '$stores/auth';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import { renderMath } from '$lib/actions/renderMath';
import { shouldShowQuizReview } from '$services/quiz-integrity';
import type { LessonManager } from './lesson.svelte';
import type { QuizQuestion, QuizAnswer } from '$types/quiz';
import { fetchQuizAttempt } from '$services/api';
import type { QuizAttemptFetchResponse } from '$services/api';
interface Props {
mgr: LessonManager;
@ -26,6 +31,50 @@
// pembahasan (siswa yang kehilangan fokus tidak mendapat bocoran jawaban).
const showReview = $derived(shouldShowQuizReview(mgr.quizTerminationReason));
// --- Review-after-refresh: fetch stored attempt (one-attempt → session lost) ---
let storedAttempt: QuizAttemptFetchResponse | null = $state(null);
let reviewQuestions: QuizQuestion[] = $state([]);
let reviewAnswers: Record<string, QuizAnswer> = $state({});
async function loadStoredAttempt() {
const token = get(authToken) || localStorage.getItem('student_token') || '';
if (!token || !mgr.data) return;
const lessonName = mgr.slug.replace('.md', '');
try {
const resp = await fetchQuizAttempt(token, lessonName);
if (resp.success && resp.answers?.length) {
storedAttempt = resp;
// Rekonstruksi question+answer maps dari stored attempt agar
// summary review per-soal bisa dirender setelah refresh.
const src = mgr.data?.quiz_data ?? [];
// Cocokkan by question_id; gunakan urutan asli (parser order).
reviewQuestions = src.filter((q) => resp.answers.some((a) => a.question_id === q.id));
reviewAnswers = {};
for (const ans of resp.answers) {
const q = src.find((qq) => qq.id === ans.question_id);
if (q) {
reviewAnswers[q.id] = {
questionId: q.id,
selectedOptionId: ans.selected_option_id,
acknowledged: false,
isCorrect: ans.is_correct,
};
}
}
}
} catch {
// best-effort; review akan tetap tersedia di session bila masih hidup
}
}
// Fetch attempt bila sudah selesai tapi session hilang (refresh/page-open)
onMount(() => {
const r = result;
if (!r && quizAlreadyCompleted && !mgr.quizSession) {
loadStoredAttempt();
}
});
function handleExit() {
if (!mgr.isQuizMode) return;
@ -57,7 +106,15 @@
<div class="summary-icon">🏁</div>
<h2>Kuis Selesai!</h2>
{#if quizAlreadyCompleted && !result}
<p class="score-display">Nilai Anda: <strong>{mgr.data?.lesson_progress_status}</strong></p>
{#if storedAttempt}
{@const s = storedAttempt}
<p class="score-display">Nilai Anda: <strong>{s.score}</strong></p>
<div class="score-breakdown">
<span class="breakdown-item">Evaluasi: <strong>{s.score_earned ?? '-'}/{s.score_total ?? '-'}</strong></span>
</div>
{:else}
<p class="score-display">Nilai Anda: <strong>{mgr.data?.lesson_progress_status}</strong></p>
{/if}
<div class="alert alert-info">
Kuis ini sudah diselesaikan. Silakan hubungi guru jika ingin mengulang.
</div>
@ -65,7 +122,7 @@
{@const totalMcq = result.totalMcq}
{#if mgr.quizTerminationReason === 'focus_lost'}
<div class="alert alert-danger">
Kuis dihentikan karena halaman kehilangan fokus. Skor disimpan dan percobaan tidak dapat diulang.
Kuis dihentikan karena halaman kehilangan fokus. Skor disimpan dan percobaann tidak dapat diulang.
</div>
{/if}
{@const correct = result.correctMcq}
@ -81,14 +138,33 @@
</div>
{/if}
{#if result.evalTotal > 0 || result.diagTotal > 0}
<div class="score-breakdown">
{#if result.evalTotal > 0}
<span class="breakdown-item">Evaluasi: <strong>{result.evalCorrect}/{result.evalTotal}</strong></span>
{/if}
{#if result.diagTotal > 0}
<span class="breakdown-item">Diagnostik: <strong>{result.diagCorrect}/{result.diagTotal}</strong></span>
{#if result.diagUnmastered.length > 0}
<span class="breakdown-item unmastered">
Belum dikuasai:
{#each result.diagUnmastered as q}
<span class="unmastered-topic">{(q.front ?? q.question ?? 'Soal').replace(/<[^>]*>/g, '').slice(0, 40)}</span>
{/each}
</span>
{/if}
{/if}
</div>
{/if}
{#if showReview && questions.length > 0}
<div class="summary-review">
<h3 class="review-title">Pembahasan</h3>
{#each questions as q, i}
{@const a = mgr.quizSession!.answers[q.id]}
<div class="review-item">
<div class="review-item" data-category={q.category ?? 'evaluasi'}>
<div class="review-header">
<span class="review-number">Soal {i + 1}</span>
<span class="review-number">Soal {i + 1} <span class="review-category-badge">{q.category === 'diagnostik' ? 'Diagnostik' : 'Evaluasi'}</span></span>
{#if q.type === 'mcq'}
<span class="review-badge" class:ok={a.isCorrect} class:bad={!a.isCorrect}>
{a.isCorrect ? '✓ Benar' : '✗ Salah'}
@ -134,6 +210,52 @@
</p>
{/if}
{/if}
{#if storedAttempt && !result && showReview && reviewQuestions.length > 0}
<div class="summary-review">
<h3 class="review-title">Pembahasan (tersimpan)</h3>
{#each reviewQuestions as q, i}
{@const a = reviewAnswers[q.id]}
<div class="review-item" data-category={q.category ?? 'evaluasi'}>
<div class="review-header">
<span class="review-number">Soal {i + 1} <span class="review-category-badge">{q.category === 'diagnostik' ? 'Diagnostik' : 'Evaluasi'}</span></span>
{#if q.type === 'mcq'}
<span class="review-badge" class:ok={a.isCorrect} class:bad={!a.isCorrect}>
{a.isCorrect ? '✓ Benar' : '✗ Salah'}
</span>
{:else}
<span class="review-badge ok">Selesai</span>
{/if}
</div>
<div class="review-question">{@html q.question ?? q.front ?? ''}</div>
{#if q.type === 'mcq' && q.options}
<div class="review-options">
{#each q.options as opt}
<div
class="review-option"
class:chosen={opt.id === a.selectedOptionId}
class:correct-opt={opt.is_correct}
>
<span class="review-option-mark">
{opt.is_correct ? '✅' : opt.id === a.selectedOptionId ? '❌' : '○'}
</span>
<span class="review-option-text">{@html opt.text}</span>
</div>
{/each}
</div>
{#if !a.isCorrect && a.selectedOptionId === null}
<p class="review-unanswered">Tidak dijawab — dianggap salah.</p>
{/if}
{:else}
<div class="review-back">{@html q.back ?? ''}</div>
{/if}
{#if q.explanation}
<div class="explanation-box">{@html q.explanation}</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{:else if !mgr.isQuizMode}
@ -354,6 +476,12 @@
.explanation-box { font-size: 0.85rem; line-height: 1.5; padding: 1rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; text-align: left; margin-top: 0.5rem; }
.explanation-box :global(p) { margin: 0; }
.score-breakdown { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; margin: 0.75rem 0 1.25rem; }
.breakdown-item { font-size: 0.95rem; padding: 0.35rem 0.7rem; border-radius: 999px; background: var(--color-bg-secondary); border: 1px solid var(--color-border); }
.breakdown-item strong { color: var(--color-primary); }
.breakdown-item.unmastered { flex-basis: 100%; justify-content: center; }
.unmastered-topic { font-size: 0.8rem; background: rgba(220, 53, 69, 0.1); color: var(--color-danger); padding: 0.15rem 0.5rem; border-radius: 6px; margin: 0.15rem; display: inline-block; }
@media (max-width: 600px) {
.option-btn { padding: 0.75rem; font-size: 0.95rem; }
.quiz-container { padding: 1rem; }

View File

@ -4,7 +4,7 @@ import { pickDefaultTab } from '$services/lesson-tabs';
import { tick, untrack } from 'svelte';
import { auth, authLoggedIn, authToken } from '$stores/auth';
import { lessonContext } from '$stores/lessonContext';
import { compileCode, trackProgress, submitQuizAttempt, quizAttemptBeacon, type QuizAttemptSubmission } from '$services/api';
import { compileCode, trackProgress, submitQuizAttempt, quizAttemptBeacon, fetchQuizAttempt, type QuizAttemptSubmission, type QuizAttemptFetchResponse } from '$services/api';
import { createAttemptId, type QuizTerminationReason } from '$services/quiz-integrity';
import { evaluateVelxioSubmission } from '$services/velxio-evaluator';
import { evaluateFlowchartSubmission } from '$services/flowchart-evaluator';
@ -416,7 +416,16 @@ export class LessonManager {
score: result.statusString,
occurred_at: now,
started_at: this.quizStartedAt ?? now,
visibility_event_count: this.visibilityEventCount
visibility_event_count: this.visibilityEventCount,
answers: this.quizSession.questions.map((q) => {
const a = this.quizSession!.answers[q.id];
return {
question_id: q.id,
selected_option_id: a.selectedOptionId,
is_correct: a.isCorrect,
category: (q.category === 'diagnostik' ? 'diagnostik' : 'evaluasi') as 'evaluasi' | 'diagnostik'
};
})
};
}

View File

@ -18,6 +18,17 @@
[key: string]: string | number | boolean;
}
// `_diag_unmastered` dikirim backend sebagai JSON string array of question_id.
function parseUnmastered(raw: string | number | boolean | undefined): string[] {
if (typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
let students = $state<StudentProgress[]>([]);
let lessons = $state<LessonHeader[]>([]);
let loading = $state(true);
@ -247,6 +258,9 @@
{@const violation = student[key + '_has_violation']}
{@const violationReason = student[key + '_termination_reason']}
{@const violationTime = student[key + '_attempt_finished_at']}
{@const evalScore = student[key + '_eval']}
{@const diagScore = student[key + '_diag']}
{@const diagUnmastered = parseUnmastered(student[key + '_diag_unmastered'])}
<td class="status-cell">
<div class="cell-content">
{#if status === 'completed'}
@ -266,6 +280,21 @@
</span>
{/if}
{#if evalScore}
<span class="badge eval" title="Skor evaluasi (skor resmi)">Eval: {evalScore}</span>
{/if}
{#if diagScore}
<span class="badge diag" title="Skor soal diagnostik">Diag: {diagScore}</span>
{#if diagUnmastered.length > 0}
<span
class="badge unmastered"
title="Belum dikuasai: {diagUnmastered.join(', ')}"
>
{diagUnmastered.length} belum dikuasai
</span>
{/if}
{/if}
{#if status && status !== 'not_started'}
<button
class="btn-reset-mini"
@ -443,10 +472,33 @@
font-weight: 700;
white-space: nowrap;
}
.badge.eval, .badge.diag, .badge.unmastered {
border-radius: 999px;
padding: 0.1rem 0.45rem;
font-size: 0.65rem;
font-weight: 700;
white-space: nowrap;
}
.badge.eval {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg));
border: 1px solid color-mix(in srgb, var(--color-primary) 40%, var(--color-bg));
}
.badge.diag {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.badge.unmastered {
color: var(--color-danger, #e8590c);
background: color-mix(in srgb, var(--color-danger, #e8590c) 10%, var(--color-bg));
border: 1px solid color-mix(in srgb, var(--color-danger, #e8590c) 40%, var(--color-bg));
}
.cell-content {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.4rem;
}
.btn-reset-mini {

View File

@ -0,0 +1,23 @@
"""quiz_attempts: simpan ringkasan per-soal (review-after-refresh + breakdown kategori)
Revision ID: 0003
Revises: 0002
Create Date: 2026-08-14
"""
from alembic import op
import sqlalchemy as sa
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("quiz_attempts", sa.Column("answers_json", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("quiz_attempts", "answers_json")

View File

@ -15,6 +15,7 @@ Kontrak:
- Raw token tidak pernah di-log; response hanya memuat status/message/attempt_id.
"""
import json
import logging
from datetime import datetime, timezone
@ -119,6 +120,17 @@ def submit_quiz_attempt():
):
return jsonify({"success": False, "message": "visibility_event_count tidak valid"}), 400
# answers: ringkasan per-soal [{question_id, selected_option_id, is_correct, category}]
answers_raw = data.get("answers")
answers_json = None
if answers_raw is not None:
if not isinstance(answers_raw, list):
return jsonify({"success": False, "message": "answers harus berupa list"}), 400
try:
answers_json = json.dumps(answers_raw)
except (TypeError, ValueError):
return jsonify({"success": False, "message": "answers tidak dapat diserialisasi"}), 400
if SessionLocal is None:
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
@ -145,6 +157,7 @@ def submit_quiz_attempt():
finished_at=datetime.now(timezone.utc),
visibility_event_count=visibility_count,
user_agent=user_agent,
answers_json=answers_json,
)
except ValueError as exc:
db.rollback()
@ -185,3 +198,57 @@ def submit_quiz_attempt():
"attempt_id": attempt.id,
}
)
@quiz_attempts_bp.route("/quiz-attempts/<lesson_name>", methods=["GET"])
def get_quiz_attempt(lesson_name: str):
"""Dapatkan attempt kuis siswa untuk lesson (untuk review-after-refresh).
Auth: student_token via query param `token` (siswa hanya lihat attempt miliknya).
Response: attempt_id, status, termination_reason, score, score_earned, score_total,
started_at, finished_at, answers (parsed dari answers_json).
"""
token = (request.args.get("token") or "").strip()
if not token:
return jsonify({"success": False, "message": "Token wajib"}), 400
if SessionLocal is None:
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
db = SessionLocal()
try:
user = repo.get_user_by_token(db, token)
if user is None:
return jsonify({"success": False, "message": "Token tidak valid"}), 401
lesson = repo.get_lesson_by_slug(db, lesson_name)
if lesson is None:
return jsonify({"success": False, "message": "Lesson tidak dikenal"}), 404
attempt = repo.get_quiz_attempt_for_user_lesson(
db, user_id=user.id, lesson_id=lesson.id
)
if attempt is None:
return jsonify({"success": False, "message": "Belum ada attempt"}), 404
try:
answers = json.loads(attempt.answers_json) if attempt.answers_json else []
except (json.JSONDecodeError, TypeError):
answers = []
return jsonify({
"success": True,
"attempt_id": attempt.id,
"status": attempt.status,
"termination_reason": attempt.termination_reason,
"score": f"{attempt.score_earned}/{attempt.score_total}"
if attempt.score_earned is not None and attempt.score_total is not None
else "completed",
"score_earned": attempt.score_earned,
"score_total": attempt.score_total,
"started_at": attempt.started_at.isoformat() if attempt.started_at else None,
"finished_at": attempt.finished_at.isoformat() if attempt.finished_at else None,
"answers": answers,
})
except Exception: # noqa: BLE001
logger.exception("get_quiz_attempt gagal")
return jsonify({"success": False, "message": "Gagal mengambil attempt"}), 500
finally:
db.close()

View File

@ -796,6 +796,12 @@ def _parse_flashcards(text):
image_url = f'/assets/{image_url}'
body = re.sub(r'^\s*image:\s*.*$', '', body, flags=re.MULTILINE).strip()
# Check for ::diagnostic marker (stripped from display, sets category)
category = 'evaluasi'
if re.search(r'^\s*::diagnostic\s*$', body, re.MULTILINE):
category = 'diagnostik'
body = re.sub(r'^\s*::diagnostic\s*$', '', body, flags=re.MULTILINE).strip()
# Option lines outside fenced code, in original order
option_lines = list(_iter_option_lines(body))
first_option_idx = option_lines[0][0] if option_lines else len(body)
@ -849,7 +855,8 @@ def _parse_flashcards(text):
'question': md.markdown(prompt_text, extensions=MD_EXTENSIONS),
'options': parsed_options,
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
'image': image_url
'image': image_url,
'category': category
})
else:
# It's a simple Flashcard
@ -865,7 +872,8 @@ def _parse_flashcards(text):
'back': md.markdown(clean_back, extensions=MD_EXTENSIONS),
'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "",
'image': image_url,
'options': None
'options': None,
'category': category
})
return flashcards

View File

@ -17,7 +17,9 @@ from sqlalchemy import (
DateTime,
ForeignKey,
Integer,
JSON,
String,
Text,
UniqueConstraint,
func,
)
@ -142,6 +144,11 @@ class QuizAttempt(Base):
# Diagnosis device/browser saja — bukan dasar hukuman tambahan.
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Ringkasan per-soal (question_id -> selected_option_id, is_correct, category).
# Digunakan frontend untuk render review-after-refresh + breakdown kategori;
# tidak memengaruui skor resmi (evaluasi saja, lihat quiz-session.ts).
answers_json: Mapped[str | None] = mapped_column(Text, nullable=True)
user: Mapped["User"] = relationship(back_populates="quiz_attempts")
lesson: Mapped["Lesson"] = relationship(back_populates="quiz_attempts")

View File

@ -253,6 +253,19 @@ def list_quiz_attempts_for_user(db: Session, *, user_id: str) -> list[QuizAttemp
)
def get_quiz_attempt_for_user_lesson(db: Session, *, user_id: str, lesson_id: str) -> QuizAttempt | None:
"""Satu attempt kuis per (user, lesson) — one-attempt policy (unique constraint)."""
return db.scalars(
select(QuizAttempt)
.where(
QuizAttempt.user_id == user_id,
QuizAttempt.lesson_id == lesson_id,
)
.order_by(QuizAttempt.finished_at.desc())
.limit(1)
).first()
def delete_quiz_attempts(db: Session, *, user_id: str, lesson_id: str) -> int:
"""Hapus attempt (one-attempt) — dipakai saat guru me-reset progress."""
attempts = list(
@ -282,6 +295,7 @@ def finalize_quiz_attempt(
finished_at: datetime,
visibility_event_count: int,
user_agent: str | None = None,
answers_json: str | None = None,
) -> tuple[QuizAttempt, bool]:
"""Insert/confirm SATU attempt kuis — idempotent per `attempt_id`.
@ -315,6 +329,7 @@ def finalize_quiz_attempt(
finished_at=finished_at,
visibility_event_count=visibility_event_count,
user_agent=user_agent,
answers_json=answers_json,
)
db.add(attempt)
try:

View File

@ -5,6 +5,7 @@ validasi via HMAC-SHA256 digest + role eksplisit di tabel users.
"""
import logging
import json
from sqlalchemy import select
@ -216,6 +217,41 @@ def get_all_students_progress(all_lessons_func):
student[f"{lesson.slug}_attempt_finished_at"] = (
attempt.finished_at.isoformat() if attempt and attempt.finished_at else ""
)
# Breakdown kategori (evaluasi / diagnostik) dari answers_json attempt —
# untuk report guru. `eval_correct/eval_total` = skor resmi;
# `diag_correct/diag_total` = info diagnostik.
if attempt and attempt.answers_json:
try:
answers = json.loads(attempt.answers_json)
except (json.JSONDecodeError, TypeError):
answers = []
eval_correct = 0
eval_total = 0
diag_correct = 0
diag_total = 0
for ans in answers:
cat = ans.get('category', 'evaluasi')
if cat == 'diagnostik':
diag_total += 1
if ans.get('is_correct'):
diag_correct += 1
else:
eval_total += 1
if ans.get('is_correct'):
eval_correct += 1
student[f"{lesson.slug}_eval"] = f"{eval_correct}/{eval_total}"
student[f"{lesson.slug}_diag"] = f"{diag_correct}/{diag_total}"
student[f"{lesson.slug}_diag_unmastered"] = json.dumps(
[
a.get('question_id', '')
for a in answers
if a.get('category') == 'diagnostik' and not a.get('is_correct')
]
)
else:
student[f"{lesson.slug}_eval"] = ""
student[f"{lesson.slug}_diag"] = ""
student[f"{lesson.slug}_diag_unmastered"] = "[]"
student['completed_count'] = count_completed_lessons(db, user_id=user.id)
students.append(student)
return students, ordered_lessons

View File

@ -198,3 +198,77 @@ def test_old_flat_format_still_parses_with_ids():
assert quiz[0]['type'] == 'mcq'
assert len(quiz[0]['options']) == 2
assert quiz[0]['options'][1]['is_correct'] is True
def test_default_category_is_evaluasi():
md_text = """### Mana yang benar?
- [] Salah
- [x] Benar
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
assert quiz[0]['category'] == 'evaluasi'
def test_diagnostic_marker_on_own_line_sets_category_mcq():
md_text = """### Apa output dari `printf("%d", 5 + 5);`?
::diagnostic
- [] 55
- [x] 10
- [] 5 + 5
> Penjelasan: 5 + 5 = 10.
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['category'] == 'diagnostik'
# Marker tidak boleh bocor ke teks yang dirender
assert '::diagnostic' not in q['question']
assert len(q['options']) == 3
def test_diagnostic_marker_on_own_line_sets_category_flashcard():
md_text = """### Apa fungsi `return 0;`?
::diagnostic
Program selesai dengan sukses.
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['category'] == 'diagnostik'
assert '::diagnostic' not in q['front']
assert 'sukses' in q['back']
def test_diagnostic_marker_inline_in_heading_does_not_set_category():
"""Kontrak: marker wajib di baris sendiri — format inline (lama) diabaikan."""
md_text = """### Apa output dari `printf("%d", 5 + 5);` <-- ::diagnostic
- [] 55
- [x] 10
- [] 5 + 5
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['category'] == 'evaluasi'
# Konten heading tetap dipertahankan apa adanya (termasuk komentar)
assert '::diagnostic' in q['question']
def test_diagnostic_mixed_questions_categorized_independently():
md_text = """### Soal evaluasi 1
- [] a
- [x] b
### Soal diagnostik 1
::diagnostic
- [] a
- [x] b
### Soal evaluasi 2
- [] a
- [x] b
"""
quiz = _parse_flashcards(md_text)
assert [q['category'] for q in quiz] == ['evaluasi', 'diagnostik', 'evaluasi']

View File

@ -45,6 +45,7 @@ def _payload(overrides: dict | None = None) -> dict:
"occurred_at": "2026-08-09T14:04:44.000Z",
"started_at": "2026-08-09T14:03:00.000Z",
"visibility_event_count": 1,
"answers": [],
}
if overrides:
data.update(overrides)