feat(progress): rekap distribusi bobot jawaban (poin 5)

Hitung ulang distribusi point N: count dari jawaban, tampilkan di ringkasan siswa, tooltip guru, dan CSV.
This commit is contained in:
a2nr 2026-10-27 16:08:48 +07:00
parent ec34ffed72
commit 2a5fdf8400
7 changed files with 304 additions and 7 deletions

View File

@ -6,6 +6,7 @@ import {
firstUnansweredIndex,
answerQuestion,
questionMaxPoints,
weightedPointDistribution,
QUIZ_PASS_THRESHOLD,
} from './quiz-session';
import type { QuizQuestion, QuizOption } from '../types/quiz';
@ -301,3 +302,56 @@ describe('Fitur B — model skor rata-rata per jenis', () => {
expect(calculateQuizResult(sess).statusString).toBe('1/1');
});
});
describe('weightedPointDistribution (rekap counting bobot)', () => {
it('menghitung per bobot terpilih, urut descending, unanswered terpisah', () => {
const sess = createQuizSession(
[
weighted('w-0', [10, 7, null]),
weighted('w-1', [10, 7, null]),
weighted('w-2', [7, 3, null]),
weighted('w-3', [5, null]),
],
() => 0
);
answerQuestion(sess, 'w-0', 'w-0-o-0'); // 10
answerQuestion(sess, 'w-1', 'w-1-o-1'); // 7
answerQuestion(sess, 'w-2', 'w-2-o-0'); // 7
// w-3 tidak dijawab
expect(weightedPointDistribution(sess.questions, sess.answers)).toEqual({
points: [
{ weight: 10, count: 1 },
{ weight: 7, count: 2 },
],
unanswered: 1,
});
});
it('opsi bobot 0 yang dipilih tercatat sebagai point 0, bukan unanswered', () => {
const sess = createQuizSession([weighted('w-0', [10, 0, null])], () => 0);
answerQuestion(sess, 'w-0', 'w-0-o-1');
expect(weightedPointDistribution(sess.questions, sess.answers)).toEqual({
points: [{ weight: 0, count: 1 }],
unanswered: 0,
});
});
it('soal non-weighted diabaikan', () => {
const sess = createQuizSession([mcq('m-0', 2), weighted('w-0', [5, null])], () => 0);
answerQuestion(sess, 'm-0', 'm-0-o-0');
answerQuestion(sess, 'w-0', 'w-0-o-0');
expect(weightedPointDistribution(sess.questions, sess.answers)).toEqual({
points: [{ weight: 5, count: 1 }],
unanswered: 0,
});
});
it('semua weighted belum dijawab → points kosong, unanswered = jumlah soal', () => {
const sess = createQuizSession([weighted('w-0', [10, null]), weighted('w-1', [7, null])], () => 0);
expect(weightedPointDistribution(sess.questions, sess.answers)).toEqual({
points: [],
unanswered: 2,
});
});
});

View File

@ -234,8 +234,46 @@ export function calculateQuizResult(session: QuizSession): QuizResult {
};
}
/** Ringkasan jawaban tersimpan dari attempt sebelumnya (payload backend). */
export interface StoredQuizAnswer {
/** Distribusi bobot jawaban soal weighted — dihitung ulang dari konten. */
export interface WeightedPointDistribution {
/** Bobot terpilih → jumlah soal, urut bobot tertinggi → terendah. */
points: Array<{ weight: number; count: number }>;
/** Soal weighted yang belum dijawab (bukan `point 0`). */
unanswered: number;
}
/**
* Rekap counting jawaban soal berbobot: `point <bobot>: <jumlah>`.
* Soal yang belum dijawab dipisah agar tidak tercampur dengan `point 0`
* (opsi berbobot 0 yang benar-benar dipilih).
*/
export function weightedPointDistribution(
questions: QuizQuestion[],
answers: Record<string, QuizAnswer>
): WeightedPointDistribution {
const counts = new Map<number, number>();
let unanswered = 0;
for (const q of questions) {
if (q.type !== 'mcq' || q.quiz_type !== 'weighted') continue;
const a = answers[q.id];
if (!a || a.selectedOptionId === null) {
unanswered += 1;
continue;
}
const weight = a.earnedPoints ?? 0;
counts.set(weight, (counts.get(weight) ?? 0) + 1);
}
return {
points: [...counts.entries()]
.sort((a, b) => b[0] - a[0])
.map(([weight, count]) => ({ weight, count })),
unanswered
};
}
/** Ringkasan jawaban tersimpan dari attempt sebelumnya (payload backend). */export interface StoredQuizAnswer {
question_id: string;
selected_option_id: string | null;
is_correct?: boolean;

View File

@ -5,7 +5,8 @@
import { autoRenderMath } from '$lib/actions/renderMath';
import { shouldShowQuizReview, type QuizTerminationReason } from '$services/quiz-integrity';
import { resolveQuizTabView, terminationReasonLabel } from '$services/quiz-view';
import { questionMaxPoints, QUIZ_PASS_THRESHOLD } from '$services/quiz-session';
import { questionMaxPoints, weightedPointDistribution, QUIZ_PASS_THRESHOLD } from '$services/quiz-session';
import type { WeightedPointDistribution } from '$services/quiz-session';
import type { LessonManager } from './lesson.svelte';
import type { QuizQuestion, QuizAnswer } from '$types/quiz';
import { fetchLessonProgress } from '$services/api';
@ -91,6 +92,18 @@
const currentAnswer = $derived(mgr.currentQuizAnswer);
const questions = $derived(mgr.quizSession?.questions ?? []);
const result = $derived(mgr.quizResult);
const resultWeightedDistribution = $derived(
weightedPointDistribution(questions, mgr.quizSession?.answers ?? {})
);
const hasResultWeightedQuestions = $derived(
questions.some((q) => q.type === 'mcq' && q.quiz_type === 'weighted')
);
const storedWeightedDistribution = $derived(
weightedPointDistribution(reviewQuestions, reviewAnswers)
);
const hasStoredWeightedQuestions = $derived(
reviewQuestions.some((q) => q.type === 'mcq' && q.quiz_type === 'weighted')
);
// Strict policy: kuis yang dihentikan karena focus_lost tidak menampilkan
// pembahasan (siswa yang kehilangan fokus tidak mendapat bocoran jawaban).
const showReview = $derived(shouldShowQuizReview(mgr.quizTerminationReason));
@ -228,6 +241,20 @@
}
</script>
{#snippet weightedDistCard(dist: WeightedPointDistribution)}
<div class="weighted-distribution">
<h4 class="weighted-distribution-title">Distribusi Jawaban Berbobot</h4>
<div class="weighted-distribution-chips">
{#each dist.points as p}
<span class="weighted-chip">point {p.weight}: <strong>{p.count}</strong></span>
{/each}
{#if dist.unanswered > 0}
<span class="weighted-chip unanswered">belum dijawab: <strong>{dist.unanswered}</strong></span>
{/if}
</div>
</div>
{/snippet}
<div class="quiz-container" bind:this={quizEl}>
{#if showResumeSnackbar}
<div class="quiz-snackbar" role="status" aria-live="polite">
@ -328,6 +355,10 @@
</div>
{/if}
{#if hasResultWeightedQuestions}
{@render weightedDistCard(resultWeightedDistribution)}
{/if}
{#if result.evalTotal > 0 || result.diagTotal > 0}
<div class="score-breakdown">
{#if result.evalTotal > 0}
@ -410,6 +441,10 @@
{/if}
{/if}
{#if storedAttempt && !result && hasStoredWeightedQuestions}
{@render weightedDistCard(storedWeightedDistribution)}
{/if}
{#if storedAttempt && !result && shouldShowQuizReview((storedAttempt.termination_reason ?? null) as QuizTerminationReason | null) && reviewQuestions.length > 0}
<div class="summary-review">
<h3 class="review-title">Pembahasan (tersimpan)</h3>
@ -715,6 +750,12 @@
.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); }
.weighted-distribution { margin: 0.75rem 0 1.25rem; text-align: center; }
.weighted-distribution-title { font-size: 0.95rem; margin: 0 0 0.5rem; color: var(--color-text); }
.weighted-distribution-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
.weighted-chip { font-size: 0.9rem; padding: 0.3rem 0.7rem; border-radius: 999px; background: rgba(51, 154, 240, 0.1); border: 1px solid rgba(51, 154, 240, 0.35); color: var(--color-text); }
.weighted-chip strong { color: var(--color-primary); }
.weighted-chip.unanswered { background: var(--color-bg-secondary); border-color: var(--color-border); color: var(--color-text-muted); }
.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; }

View File

@ -41,6 +41,8 @@
quiz_mcq: string;
/** Poin soal weighted ("7/10") — kosong bila tidak ada soal weighted. */
quiz_weighted: string;
/** Distribusi bobot terpilih (JSON) — kosong bila tidak ada soal weighted. */
weighted_points: string;
}
interface LessonNode {
@ -78,6 +80,39 @@
return Math.round((earned / total) * 100);
}
/**
* Parse `_weighted_points` JSON → teks ringkas distribusi bobot.
* Contoh: "point 10: 5 · point 7: 2 · belum dijawab: 1".
*/
function weightedDistributionText(raw: string | number | boolean | undefined): string | null {
if (typeof raw !== 'string' || !raw) return null;
try {
const parsed = JSON.parse(raw) as {
points?: Array<{ weight?: number; count?: number }>;
unanswered?: number;
};
const points = Array.isArray(parsed.points) ? parsed.points : [];
const parts = points
.filter((p) => typeof p.weight === 'number' && typeof p.count === 'number')
.map((p) => `point ${p.weight}: ${p.count}`);
const unanswered = Number(parsed.unanswered ?? 0);
if (unanswered > 0) parts.push(`belum dijawab: ${unanswered}`);
return parts.length > 0 ? parts.join(' · ') : null;
} catch {
return null;
}
}
/**
* Tooltip badge "Bobot" — jelaskan rasio sekaligus distribusi bobot
* terpilih (counting) dari Task E.
*/
function weightedBadgeTooltip(s: StudentLessonDetail): string {
const base = 'Poin soal QUIZ_WEIGHTED (bobot tertinggi tiap soal dijumlahkan)';
const dist = weightedDistributionText(s.weighted_points);
return dist ? `${base} — Distribusi: ${dist}` : base;
}
/**
* Tooltip badge skor kuis — jelaskan model skor rata-rata per jenis:
* "Rata-rata jenis — MCQ 75% · Weighted 70%".
@ -307,7 +342,8 @@
diag_score: String(s[key + '_diag'] ?? ''),
diag_unmastered: parseUnmastered(s[key + '_diag_unmastered']),
quiz_mcq: String(s[key + '_quiz_mcq'] ?? ''),
quiz_weighted: String(s[key + '_quiz_weighted'] ?? '')
quiz_weighted: String(s[key + '_quiz_weighted'] ?? ''),
weighted_points: String(s[key + '_weighted_points'] ?? '')
});
if (s.role === 'student') {
@ -372,7 +408,8 @@
diag_score: String(s[key + '_diag'] ?? ''),
diag_unmastered: parseUnmastered(s[key + '_diag_unmastered']),
quiz_mcq: String(s[key + '_quiz_mcq'] ?? ''),
quiz_weighted: String(s[key + '_quiz_weighted'] ?? '')
quiz_weighted: String(s[key + '_quiz_weighted'] ?? ''),
weighted_points: String(s[key + '_weighted_points'] ?? '')
});
if (s.role === 'student') {
@ -598,7 +635,7 @@
{/if}
{#if student.quiz_weighted}
<span class="badge badge-detail" title="Poin soal QUIZ_WEIGHTED (bobot tertinggi tiap soal dijumlahkan)">Bobot: {student.quiz_weighted}</span>
<span class="badge badge-detail" title={weightedBadgeTooltip(student)}>Bobot: {student.quiz_weighted}</span>
{/if}
{#if student.has_violation}

View File

@ -4,6 +4,7 @@ Student progress tracking and reporting JSON API routes.
import csv
import io
import json
import logging
from flask import Blueprint, request, jsonify, Response
@ -30,6 +31,31 @@ def _masked(token: str) -> str:
return f"{token[:6]}..." if token else "(kosong)"
def _weighted_points_compact(raw) -> str:
"""JSON `_weighted_points` → compact CSV `10:5|7:2|0:1`.
Kosong bila kuis tidak punya soal weighted (atau payload lama).
"""
if not isinstance(raw, str) or not raw:
return ""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return ""
points = data.get("points") if isinstance(data, dict) else None
if not isinstance(points, list):
return ""
parts = []
for p in points:
if not isinstance(p, dict):
continue
try:
parts.append(f"{int(p['weight'])}:{int(p['count'])}")
except (KeyError, TypeError, ValueError):
continue
return "|".join(parts)
@progress_bp.route('/reset-progress', methods=['POST'])
def reset_progress():
"""Reset progress siswa via student_id (Teacher only).
@ -185,7 +211,12 @@ def export_progress_csv():
# poin weighted. Kolom kosong bila jenis itu tidak ada di kuis lesson.
fieldnames = ['nama_siswa']
for slug in lesson_slugs:
fieldnames.extend([slug, f"{slug}_quiz_mcq", f"{slug}_quiz_weighted"])
fieldnames.extend([
slug,
f"{slug}_quiz_mcq",
f"{slug}_quiz_weighted",
f"{slug}_weighted_points",
])
fieldnames.append('completed_count')
output = io.StringIO()
@ -207,6 +238,9 @@ def export_progress_csv():
row[slug] = ""
row[f"{slug}_quiz_mcq"] = student.get(f"{slug}_quiz_mcq", "")
row[f"{slug}_quiz_weighted"] = student.get(f"{slug}_quiz_weighted", "")
row[f"{slug}_weighted_points"] = _weighted_points_compact(
student.get(f"{slug}_weighted_points", "")
)
row['completed_count'] = student.get('completed_count', 0)
writer.writerow(row)

View File

@ -309,6 +309,8 @@ def get_all_students_progress(all_lessons_func):
mcq_correct = 0
weighted_earned = 0
weighted_max = 0
weighted_points = {}
weighted_unanswered = 0
for ans in answers:
qt = _answer_quiz_type(ans)
@ -322,6 +324,16 @@ def get_all_students_progress(all_lessons_func):
weighted_earned += int(float(ans.get('earned_points') or 0))
except (TypeError, ValueError):
pass
# Rekap counting bobot terpilih: unanswered dipisah
# agar tidak tercampur dengan pilihan berbobot 0.
if ans.get('selected_option_id') in (None, ''):
weighted_unanswered += 1
else:
try:
pts = int(float(ans.get('earned_points') or 0))
except (TypeError, ValueError):
pts = 0
weighted_points[pts] = weighted_points.get(pts, 0) + 1
continue
mcq_total += 1
if ans.get('is_correct'):
@ -356,12 +368,26 @@ def get_all_students_progress(all_lessons_func):
student[f"{lesson.slug}_quiz_weighted"] = (
f"{weighted_earned}/{weighted_max}" if weighted_max else ""
)
# Distribusi bobot (counting) — kosong bila kuis tidak punya
# soal weighted. JSON agar UI bisa parse tanpa konvensi baru.
student[f"{lesson.slug}_weighted_points"] = (
json.dumps({
"points": [
{"weight": w, "count": c}
for w, c in sorted(weighted_points.items(), reverse=True)
],
"unanswered": weighted_unanswered,
})
if weighted_max
else ""
)
else:
student[f"{lesson.slug}_eval"] = ""
student[f"{lesson.slug}_diag"] = ""
student[f"{lesson.slug}_diag_unmastered"] = "[]"
student[f"{lesson.slug}_quiz_mcq"] = ""
student[f"{lesson.slug}_quiz_weighted"] = ""
student[f"{lesson.slug}_weighted_points"] = ""
# Field komposit (skor gabungan) untuk report guru — emit TERPISAH,
# tidak mengubah kontrak status lama di student[lesson.slug].
progress_row = rows.get(lesson.id)

View File

@ -335,3 +335,70 @@ def test_export_progress_csv_contains_composite_scores(client):
assert siti_row["completed_count"] == "0"
def test_report_weighted_points_distribution(client):
"""Rekap counting bobot: unanswered dipisah, pilihan bobot 0 tetap tercatat."""
import json
answers = [
{"question_id": "q1", "selected_option_id": "o-10", "quiz_type": "weighted",
"earned_points": 10, "question_max_points": 10, "is_correct": True},
{"question_id": "q2", "selected_option_id": "o-7", "quiz_type": "weighted",
"earned_points": 7, "question_max_points": 10, "is_correct": False},
{"question_id": "q3", "selected_option_id": "o-0", "quiz_type": "weighted",
"earned_points": 0, "question_max_points": 10, "is_correct": False},
{"question_id": "q4", "selected_option_id": None, "quiz_type": "weighted",
"earned_points": 0, "question_max_points": 10, "is_correct": False},
]
assert _post_quiz(client, score="17/40", answers=answers).status_code == 200
resp = client.get(f"/progress-report.json?token={TEACHER_TOKEN}")
assert resp.status_code == 200
budi = next(s for s in resp.get_json()["students"] if s["nama_siswa"] == "Budi Santoso")
data = json.loads(budi["hello_world_weighted_points"])
assert data["points"] == [
{"weight": 10, "count": 1},
{"weight": 7, "count": 1},
{"weight": 0, "count": 1},
]
assert data["unanswered"] == 1
def test_export_csv_weighted_points_column(client):
"""Kolom CSV compact `10:1|7:1|0:1` (unanswered tidak masuk kolom poin)."""
import csv
import io
answers = [
{"question_id": "q1", "selected_option_id": "o-10", "quiz_type": "weighted",
"earned_points": 10, "question_max_points": 10, "is_correct": True},
{"question_id": "q2", "selected_option_id": "o-7", "quiz_type": "weighted",
"earned_points": 7, "question_max_points": 10, "is_correct": False},
{"question_id": "q3", "selected_option_id": "o-0", "quiz_type": "weighted",
"earned_points": 0, "question_max_points": 10, "is_correct": False},
]
assert _post_quiz(client, score="17/30", answers=answers).status_code == 200
resp = client.get(f"/progress-report/export-csv?token={TEACHER_TOKEN}")
assert resp.status_code == 200
reader = csv.DictReader(io.StringIO(resp.data.decode("utf-8-sig")), delimiter=";")
assert "hello_world_weighted_points" in reader.fieldnames
budi_row = next(r for r in reader if r["nama_siswa"] == "Budi Santoso")
assert budi_row["hello_world_weighted_points"] == "10:1|7:1|0:1"
def test_report_legacy_answers_without_quiz_type(client):
"""Attempt lama tanpa quiz_type tetap terbaca sebagai MCQ (tanpa weighted)."""
answers = [
{"question_id": "q1", "selected_option_id": "o1", "is_correct": True, "type": "mcq"},
{"question_id": "q2", "selected_option_id": "o2", "is_correct": False, "type": "mcq"},
]
assert _post_quiz(client, score="1/2", answers=answers).status_code == 200
resp = client.get(f"/progress-report.json?token={TEACHER_TOKEN}")
assert resp.status_code == 200
budi = next(s for s in resp.get_json()["students"] if s["nama_siswa"] == "Budi Santoso")
assert budi["hello_world_quiz_mcq"] == "1/2"
assert budi["hello_world_quiz_weighted"] == ""
assert budi["hello_world_weighted_points"] == ""