From 2a5fdf8400fa93e9b61e390656480b04dcc22c48 Mon Sep 17 00:00:00 2001 From: a2nr Date: Tue, 27 Oct 2026 16:08:48 +0700 Subject: [PATCH] feat(progress): rekap distribusi bobot jawaban (poin 5) Hitung ulang distribusi point N: count dari jawaban, tampilkan di ringkasan siswa, tooltip guru, dan CSV. --- .../src/lib/services/quiz-session.test.ts | 54 +++++++++++++++ frontend/src/lib/services/quiz-session.ts | 42 +++++++++++- .../src/routes/lesson/[slug]/QuizTab.svelte | 43 +++++++++++- .../src/routes/teacher/progress/+page.svelte | 43 +++++++++++- routes/progress.py | 36 +++++++++- services/storage/postgres_backend.py | 26 +++++++ services/tests/test_progress_routes.py | 67 +++++++++++++++++++ 7 files changed, 304 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/services/quiz-session.test.ts b/frontend/src/lib/services/quiz-session.test.ts index 3486765..4936f31 100644 --- a/frontend/src/lib/services/quiz-session.test.ts +++ b/frontend/src/lib/services/quiz-session.test.ts @@ -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, + }); + }); +}); diff --git a/frontend/src/lib/services/quiz-session.ts b/frontend/src/lib/services/quiz-session.ts index 8f95b58..7744ec7 100644 --- a/frontend/src/lib/services/quiz-session.ts +++ b/frontend/src/lib/services/quiz-session.ts @@ -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 : `. + * 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 +): WeightedPointDistribution { + const counts = new Map(); + 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; diff --git a/frontend/src/routes/lesson/[slug]/QuizTab.svelte b/frontend/src/routes/lesson/[slug]/QuizTab.svelte index 279b119..362721c 100644 --- a/frontend/src/routes/lesson/[slug]/QuizTab.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizTab.svelte @@ -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 @@ } +{#snippet weightedDistCard(dist: WeightedPointDistribution)} +
+

Distribusi Jawaban Berbobot

+
+ {#each dist.points as p} + point {p.weight}: {p.count} + {/each} + {#if dist.unanswered > 0} + belum dijawab: {dist.unanswered} + {/if} +
+
+{/snippet} +
{#if showResumeSnackbar}
@@ -328,6 +355,10 @@
{/if} + {#if hasResultWeightedQuestions} + {@render weightedDistCard(resultWeightedDistribution)} + {/if} + {#if result.evalTotal > 0 || result.diagTotal > 0}
{#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}

Pembahasan (tersimpan)

@@ -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; } diff --git a/frontend/src/routes/teacher/progress/+page.svelte b/frontend/src/routes/teacher/progress/+page.svelte index 0134eb5..5d7a6dd 100644 --- a/frontend/src/routes/teacher/progress/+page.svelte +++ b/frontend/src/routes/teacher/progress/+page.svelte @@ -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} - Bobot: {student.quiz_weighted} + Bobot: {student.quiz_weighted} {/if} {#if student.has_violation} diff --git a/routes/progress.py b/routes/progress.py index 1a54544..39ed341 100644 --- a/routes/progress.py +++ b/routes/progress.py @@ -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) diff --git a/services/storage/postgres_backend.py b/services/storage/postgres_backend.py index 013cf5e..503f11d 100644 --- a/services/storage/postgres_backend.py +++ b/services/storage/postgres_backend.py @@ -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) diff --git a/services/tests/test_progress_routes.py b/services/tests/test_progress_routes.py index 97b0259..b33e7f5 100644 --- a/services/tests/test_progress_routes.py +++ b/services/tests/test_progress_routes.py @@ -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"] == ""