# Rencana Implementasi — Asesmen Final berbasis Item Bank + Batch (Admin-curated)

> **Status:** rencana (belum ada implementasi). **Dibuat:** 2026-06-10.
> **Konteks:** menggantikan pendekatan asesmen final yang sekarang (soal vocab/kanji/
> grammar dirakit on-the-fly tiap request; reading/listening tersimpan tapi kecil)
> dengan model **item bank yang dikurasi admin + batch berotasi + snapshot per attempt**.
> Lihat juga `jlpt-assessment-criteria.md` (kriteria & status Tahap 1–5).

---

## 1. Tujuan & prinsip

Membuat asesmen final menjadi **gerbang kelulusan level yang kredibel & terkelola**:

1. **Hanya admin yang men-generate soal**, dan hasil generate **wajib bisa diaudit,
   diperbaiki, diganti (satu tombol), atau diedit manual** sebelum dipakai.
2. **Batch tak terbatas per level.** Tiap batch = satu paket ujian utuh (jumlah soal
   per section ditentukan admin saat generate).
3. **Anti-hafalan dua lapis:** (a) user yang sudah mengambil batch A tidak mendapat
   batch yang sama selama masih ada batch lain; (b) urutan soal per section
   diacak setiap kali batch disajikan.
4. **Snapshot per attempt:** soal yang benar-benar disajikan dibekukan ke attempt,
   sehingga penilaian & riwayat kebal terhadap perubahan/penonaktifan item kemudian.

### Tiga invariant yang dipegang sepanjang implementasi

- **I1 — Item append-only/versioned.** Item yang sudah pernah masuk batch aktif tidak
  di-UPDATE in-place. "Ganti/edit" membuat versi/ item baru dan menonaktifkan yang lama.
- **I2 — Attempt menyimpan snapshot.** `assessment_attempts` menyimpan batch + isi soal
  (prompt/choices/jawaban) saat disajikan; grading menilai terhadap snapshot, bukan
  data domain live.
- **I3 — Batch berstatus.** Hanya batch `active` (sudah diaudit) yang boleh disajikan
  ke user. Aktivasi membekukan komposisi batch.

### Kontrak yang TIDAK boleh berubah

- **Lulus = lulus level, bukan lulus batch.** `RoadmapService` menggerbang via
  `AssessmentService::hasPassed($userId, $level)` (lihat `RoadmapService` ~baris 211 &
  442). Setelah refactor, lulus batch mana pun di level L harus tetap membuat
  `hasPassed(userId, L) === true`. Batch hanyalah alat penyajian.
- **Jawaban benar tidak pernah bocor ke klien** saat menyajikan soal.
- **Aturan skor tetap:** total ≥ `PASS_TOTAL_PCT` (60) **dan** tiap komponen ≥
  `PASS_COMPONENT_PCT` (50); komponen tanpa soal dilewati.

---

## 2. Model data (target)

Menggeneralisasi pola `listening_items` (yang sudah punya `batch`, audit admin, generate)
ke **semua** komponen.

### 2.1 `assessment_items` — bank soal terpadu (append-only)
| kolom | tipe | catatan |
|---|---|---|
| id | pk | |
| level | string(3) | N5..N1 |
| component | string | vocab\|kanji\|grammar\|reading\|listening |
| prompt | text | teks soal (mis. kanji, kalimat rumpang, pertanyaan) |
| question_label | string null | instruksi pendek JP (mis. 「よみかたを…」) |
| choices | json | **4 pilihan, sudah final & tersimpan** (urut tetap di item; pengacakan terjadi saat disajikan) |
| answer | string | jawaban benar (string yang sama persis dengan salah satu `choices`) |
| context | json null | reading: {passage_title, passage_body}; listening: {audio_path, transcript, is_instruction} |
| source_type | string null | mis. `card`,`kanji`,`grammar_point`,`reading_question`,`listening_item` |
| source_id | unsignedBigInt null | id sumber domain untuk telusur |
| difficulty | tinyint null | cadangan untuk statistik / CAT masa depan |
| is_active | bool default true | item nonaktif tidak masuk generate batch baru |
| version | unsignedInt default 1 | dinaikkan saat "ganti/edit" |
| replaces_id | unsignedBigInt null | menunjuk item versi sebelumnya (jejak edit) |
| reviewed_at | timestamp null | ditandai saat admin menyetujui |
| reviewed_by | fk users null | |
| timestamps | | |

Indeks: `(level, component, is_active)`.

> Catatan: item instruksi listening direpresentasikan sebagai item `component=listening`
> dengan `context.is_instruction=true`, `choices=[]`, `answer=null` — tidak dinilai
> (mengikuti perilaku `is_instruction` yang sudah ada).

### 2.2 `assessment_batches` — paket ujian per level
| kolom | tipe | catatan |
|---|---|---|
| id | pk | |
| level | string(3) | |
| label | string null | mis. "N5 — Batch C" |
| status | string | `draft` → `under_review` → `active` → `archived` |
| section_counts | json | jumlah soal per komponen yang diminta saat generate |
| activated_at | timestamp null | saat dibekukan & dirilis |
| created_by / reviewed_by | fk users null | |
| timestamps | | |

### 2.3 `assessment_batch_items` — pivot batch↔item (komposisi beku)
| kolom | tipe | catatan |
|---|---|---|
| batch_id | fk | |
| item_id | fk | |
| component | string | denormal untuk query cepat |
| sort_order | int | urutan default; pengacakan per-penyajian tetap dilakukan |

Komposisi dibekukan saat batch di-`active`-kan (I3). Menonaktifkan item setelah itu
tidak mengubah batch yang sudah aktif.

### 2.4 Perubahan `assessment_attempts` (tambah kolom, non-breaking)
- `batch_id` (fk null) — batch yang dikerjakan.
- `snapshot` (json null) — daftar soal yang disajikan: per soal `{item_id, component,
  prompt, choices_shown, answer}` (I2). Grading & review memakai ini.
- (opsional) `status` (`in_progress`|`completed`) bila ingin melacak attempt yang dibuat
  saat mulai vs saat submit.

Kolom lama (`level, kind, total, correct, score_pct, passed, breakdown, completed_at`)
tetap → `hasPassed(level)` tidak berubah.

### 2.5 `user_batch_assignments` (atau turunkan dari attempts) — rotasi batch
Melacak batch mana yang sudah diambil user di tiap level, untuk aturan "jangan ulang
batch yang sama selama masih ada batch lain". Bisa berupa tabel ringan
`(user_id, level, batch_id, taken_at)` **atau** diturunkan dari `assessment_attempts`
yang kini punya `batch_id` (hemat tabel). Keputusan di Fase 3.

---

## 3. Keputusan yang perlu dikonfirmasi (sebelum/ saat Fase 0)

Ditandai dengan usulan default; semuanya menentukan perilaku tepi.

1. **Saat user kehabisan batch baru di sebuah level:** (a) diblok sampai admin
   generate batch baru · (b) cooldown lalu boleh ambil batch terlama lagi ·
   **(c, usulan)** boleh ambil ulang batch lama tapi ditandai "latihan ulang" dan
   tetap dinilai.
2. **Sumber generate vocab/kanji/grammar:** (a, usulan) pakai builder domain yang sudah
   ada (deterministik, murah) lalu dibekukan jadi item · (b) lewat AI seperti listening
   (lebih bervariasi, berbiaya).
3. **"Ganti soal satu tombol" menghasilkan:** item baru menggantikan slot (I1, usulan) —
   bukan UPDATE in-place.
4. **Aktivasi batch:** butuh semua item `reviewed_at`? (usulan: ya — batch tak bisa
   `active` bila ada item belum direview).
5. **Apakah `listening_items`/`reading_*` dimigrasikan ke `assessment_items`** atau
   dijembatani via `source_type/source_id`? (usulan: migrasi bertahap; listening lebih
   dulu karena polanya paling dekat).

---

## 4. Fase implementasi

Diurutkan **dampak-tinggi/biaya-rendah lebih dulu**, tiap fase berdiri sendiri & green.

### Fase 0 — Snapshot per attempt (TANPA tabel item bank) ⭐ paling untung — ✅ SELESAI (2026-06-10)
**Tujuan:** menutup race build↔grade, brute-force submit, dan nihilnya audit — dengan
perubahan minimal, sebelum membangun item bank.

**Terimplementasi:**
- Migrasi `2026_06_10_000005_add_snapshot_to_assessment_attempts`: tambah `snapshot`
  (json), `batch_id` (null, untuk fase nanti), `status` (default `completed`) +
  indeks `(user_id, level, status)`. Model `AssessmentAttempt` di-update.
- `AssessmentService::buildFor(User, level)`: bila ada attempt `in_progress` →
  **resume** (kembalikan soal yang sama dari snapshot); bila tidak → `build()`,
  resolve jawaban benar tiap soal via `answerKeyFor()` saat itu juga (data masih
  konsisten dgn choices), simpan snapshot `{id,kind,prompt,reading,question_label,
  passage,audio_url,transcript,is_instruction,choices,answer}` ke attempt baru
  `in_progress` (total/correct/score=0). Payload ke klien selalu **tanpa `answer`**.
- `grade()` kini menilai terhadap **snapshot** attempt `in_progress` (key `kind:id`,
  karena id bisa tabrakan antar komponen), lalu menandai attempt `completed`
  (update in-place, bukan baris baru). Item instruksi (answer null) tak dinilai.
  **Submit tanpa GET dulu → tidak ada snapshot → skor 0** (anti brute-force).
- Controller `show()` → `buildFor(Auth::user(), level)`; `submit()` tak berubah
  (signature `grade()` tetap).
- Tes: +5 di `AssessmentTest` (snapshot-tersimpan-tanpa-bocor, resume-soal-sama,
  **grade-pakai-snapshot-bukan-data-live** [ubah semua `kana` setelah show → tetap
  100%], submit-melengkapi-attempt-in-place, submit-tanpa-buka=0). Total
  AssessmentTest 20 hijau; suite penuh 207 pass / 714 assertions (3 fail pre-existing
  whatsapp-branch). Frontend tetap kompatibel (payload identik; `bunx tsc` bersih).

**Hasil:** I2 tercapai; audit dasar ada; race & sebagian anti-cheat beres — **tanpa**
UI admin baru. Fondasi untuk semua fase berikutnya. Catatan perilaku baru: submit
buta kini = 0 (sebelumnya menilai jawaban klien); ini peningkatan keamanan, sengaja.

### Fase 1 — Skema item bank + batch (data layer) — ✅ SELESAI (2026-06-10)
- Migrasi `2026_06_10_000006_create_assessment_items_table`,
  `..._000007_create_assessment_batches_table`,
  `..._000008_create_assessment_batch_items_table` (sesuai §2.1–2.3).
- Model `AssessmentItem` (scope `active`/`reviewed`/`forLevel`/`component`,
  helper `isReviewed()`/`isInstruction()`, relasi `reviewer`/`replaces`/`batches`),
  `AssessmentBatch` (konstanta status `draft|under_review|active|archived`, scope
  `active`/`forLevel`, relasi `items` [terurut pivot sort_order]/`batchItems`/
  `creator`/`reviewer`), `AssessmentBatchItem` (pivot eksplisit). Semua pakai
  `HasFactory`.
- Factory `AssessmentItemFactory` (state `reviewed`/`inactive`/`component`/`level`/
  `instruction`; answer ∈ choices) + `AssessmentBatchFactory` (state `active`/`level`).
- **Belum mengubah alur ujian** (masih jalur Fase 0 snapshot). Murni struktur.
- Tes `AssessmentItemBankTest` (10): factory valid MCQ, scope active/reviewed/level/
  component, instruction tanpa soal, **versioned replacement jejak (I1)**, reviewer
  relation, batch composition+ordering, unique (batch,item), batch active scope,
  item↔many-batches. Suite penuh 217 pass / 741 assertions (3 fail pre-existing).

### Fase 2 — Generator item + batch (admin-only, server side) — ✅ SELESAI (2026-06-10)
- Builder `AssessmentService` (vocab/kanji/grammar/reading) diparameterisasi
  `?int $count = null` (default ke `COMPONENTS`, jadi alur Fase 0 IDENTIK).
- Method publik baru `AssessmentService::generateQuestions(level, component, count)`
  → soal **lengkap dengan `answer`** (memakai `snapshotFrom()` yang sudah ada untuk
  resolve answer + normalisasi shape). Listening via `listeningItemsForBank()` (ambil
  `listening_items` per level sebagai soal individual, termasuk klip instruksi
  answer=null; cap pada $count soal, instruksi tak dihitung).
- `AssessmentItemService`:
  - `generateItems(level, component, count)` → bekukan ke `assessment_items`
    (active, **unreviewed**; `source_type`+`source_id` untuk telusur; listening/reading
    bawa `context` audio/transcript atau passage). **Listening & reading IKUT
    dibekukan** ke item (satu bank terpadu — keputusan: lakukan yang terbaik).
  - `regenerateItem(item)` → item baru (version+1, `replaces_id`) + nonaktifkan lama,
    dalam transaksi (I1).
  - `buildBatch(level, sectionCounts, createdBy?)` → batch `draft` dari item aktif
    (acak per komponen; listening menyertakan klip instruksi di depan).
  - `activateBatch(batch, reviewedBy?)` → **menolak (RuntimeException) bila ada item
    belum direview**; jika semua reviewed → `active` + `activated_at` (I3).
- Tes `AssessmentItemServiceTest` (8): generate valid (answer ∈ choices, source),
  listening context+instruksi, reading passage context, **regenerate versioned**,
  buildBatch draft, buildBatch listening menyertakan instruksi, **activate menolak
  unreviewed**, activate sukses saat semua reviewed. Suite 225 pass / 774 assertions
  (3 fail pre-existing). Belum ada UI admin (Fase 4) & belum dipakai alur ujian (Fase 3).

### Fase 3 — Penyajian batch + rotasi + acak urutan (alur ujian pindah ke batch) — ✅ SELESAI (2026-06-10)
Keputusan §2.5 & §3.1 diambil: rotasi **diturunkan dari `assessment_attempts.batch_id`**
(tanpa tabel assignment); kehabisan batch → **ambil ulang batch terlama, ditandai
`is_retake`**. Level tanpa batch aktif → **diblok** ("belum siap").
- Migrasi `2026_06_10_000009_add_is_retake_to_assessment_attempts` (+`is_retake` bool).
- `AssessmentService::assignBatch(user, level)` — batch `active` (urut id) yang belum
  ada di attempt `completed` user; jika semua sudah → batch terlama + `isRetake=true`;
  tanpa batch aktif → null.
- `buildFor` sekarang: resume attempt `in_progress` jika ada; else `assignBatch` →
  `snapshotFromBatch` (per-SECTION **shuffle** kecuali listening yang jaga urutan
  instruksi→soal; snapshot id = `assessment_items.id`). Attempt menyimpan `batch_id`+
  `is_retake`. Tanpa batch → kembalikan `[]`.
- `grade()` tetap menilai dari snapshot (Fase 0); `batch_id`/`is_retake` terjaga
  karena `update($values)` tak menyentuhnya.
- Controller `show` → `ready:false`+`assessment:null` saat tak ada batch (HTTP 200).
- `hasPassed(level)` TIDAK berubah — lulus batch apa pun = lulus level (RoadmapTest
  21 hijau).
- **`build()` kini hanya dipakai builder/generator** (bukan alur ujian); builder
  vocab/kanji/grammar/reading sudah parameterized di Fase 2.
- Frontend: `AssessmentPayload` gained `ready` + nullable `assessment`; `Assessment`
  gained `batch_id?`/`is_retake?`; page handles `notReady` ("Asesmen belum siap"
  screen). `bunx tsc`+`eslint --max-warnings=0` clean.
- Tes: `AssessmentTest` di-REFACTOR ke arsitektur batch (helper `seedActiveBatch`/
  `seedFullActiveBatch` generate→review→buildBatch→activate; `correctAnswers()`
  menyaring instruksi seperti klien). 22 tes incl. Fase 3: batch-served-no-leak,
  not-ready-without-batch, **second-attempt-different-batch**, **retake-oldest-when-all-taken**,
  **passing-any-batch-passes-level**. Suite 227 pass / 781 assertions (3 pre-existing).

### Fase 4 — UI Admin: audit, edit, ganti, generate — ✅ SELESAI (2026-06-10)
- Permission `assessments.manage` (RolePermissionSeeder + content-manager + sidebar nav).
- `AssessmentItemService` ditambah: `editItem(item, overrides)` (edit manual →
  versi baru + nonaktifkan lama, answer wajib ∈ choices, otomatis reviewed — I1),
  `reviewItem`, `deactivateItem`, `archiveBatch`.
- `AdminAssessmentController` + routes di **routes/web.php** (perm-gated):
  `index` (filter level/komponen, ringkasan isi bank per komponen, daftar batch),
  `generate`, `items/{item}/regenerate|review|deactivate`, `PUT items/{item}` (edit),
  `batches` (buildBatch dari `counts[component]`), `batches/{batch}/activate|archive`.
  Route-model-binding via tipe param (`AssessmentItem`/`AssessmentBatch`). Semua aksi
  diaudit via `AdminAuditService`.
- View `admin/assessments/index.blade.php`: filter, form generate, form rakit batch
  (jumlah per komponen), tabel batch (status + Aktifkan/Arsip), tabel soal aktif
  (pilihan+jawaban hijau, status reviewed/draft, `<details>` edit manual inline,
  tombol Reviewed/Generate-Ulang/Nonaktifkan).
- Tes `AdminAssessmentTest` (9): gate permission, render, generate-unreviewed,
  review, **edit→versi-baru-reviewed**, **edit-tolak-answer-bukan-choice**, deactivate,
  **build→activate-butuh-review**, archive; audit tercatat. Suite 236 pass / 814
  assertions (3 pre-existing). Prod: reseed RolePermissionSeeder (+config/route clear).

### Fase 5 — Kebijakan kehabisan batch & retry/cooldown — ✅ SELESAI (2026-06-10)
- Kehabisan batch sudah ditangani di Fase 3 (retake terlama, `is_retake`).
- **Cooldown setelah GAGAL**: `config/assessment.php` `fail_cooldown_minutes`
  (default 60, env `ASSESSMENT_FAIL_COOLDOWN_MINUTES`, 0 = nonaktif).
  `AssessmentService::cooldownUntil(user, level)` — jika attempt `completed`
  TERAKHIR gagal & masih dalam window → kembalikan Carbon akhir cooldown; lulus
  membersihkan. `buildFor` mengembalikan sentinel `['cooldown_until' => iso]`;
  controller → `ready:false` + `cooldown_until`. Frontend: layar "Tunggu sebentar
  dulu" (pukul berapa boleh coba lagi). Tes: gagal→blok, lewat-window→boleh,
  lulus→tak ada cooldown (3 tes).

### Fase 6 — Migrasi data lama & statistik — ✅ SELESAI (2026-06-10)
- **Statistik per item**: migrasi `2026_06_10_000010` (+`times_served`,
  `correct_count` di `assessment_items`). `grade()` mengumpulkan id soal yang
  dinilai + yang benar (snapshot id = item id), lalu `recordItemStats()` bulk-
  increment. Model: `correctRate()`, scope `suspect(minServes=5, maxRate=0.2)`
  (banyak disajikan tapi jarang benar = indikasi rusak). UI admin menampilkan
  "% benar (c/n)" + ⚠️ untuk item suspect.
- **Migrasi penuh data lama**: command `nihongo:migrate-assessment-bank`
  (`--dry-run`) meng-import `listening_items` + `reading_questions` ke
  `assessment_items` (idempoten via source_type+source_id; instruksi listening
  → answer null/choices kosong; reading bawa passage context). Vocab/kanji/grammar
  tidak diimport (digenerate on-demand dari tabel domain).
- Tes: stats-tercatat, instruksi-tak-disajikan, scope-suspect (2) + command
  import/idempoten/dry-run (3).

---

## 5. Dampak ke kode yang ada (peta perubahan)

| Berkas | Perubahan |
|---|---|
| `AssessmentService::build/grade/answerKeyFor` | Fase 0: grade pakai snapshot. Fase 3: build → assign+sajikan batch. Builder domain dipindah/dipakai-ulang oleh generator item (Fase 2). |
| `AssessmentController` | show membuat attempt+snapshot; submit merujuk attempt. |
| `assessment_attempts` (migrasi) | +`snapshot`, +`batch_id`, (+`status`). Non-breaking. |
| `RoadmapService` | **tidak berubah** — tetap via `hasPassed(level)`. (Verifikasi saja.) |
| `ListeningGeneratorService` | dipakai ulang oleh generator item (komponen listening). |
| Admin (controller/view/route/permission/sidebar) | generalisasi pola `listening` → `assessments.manage`. |
| Frontend `app/assessment/[level]/page.tsx` | minimal; payload soal kompatibel. Opsional: label batch. |

---

## 6. Risiko & mitigasi

- **Ketergantungan kerja admin** (kualitas = admin rajin generate+audit). Mitigasi:
  generator mengisi banyak item sekali jalan; batch bisa dibuat massal; default cukup
  item sebelum level dibuka.
- **Biaya AI** (listening Whisper+GPT; vocab/kanji/grammar bila lewat AI). Mitigasi:
  default §3.2(a) pakai builder domain non-AI; AI hanya untuk listening/penyegaran.
- **Kompleksitas bertambah** (3 tabel + UI). Mitigasi: fase berdiri sendiri; Fase 0
  sudah memberi nilai besar tanpa UI; sisanya inkremental.
- **Migrasi data lama** berisiko. Mitigasi: Fase 6 opsional & belakangan; awalnya
  jembatani via `source_*` tanpa memindah baris.

---

## 7. Urutan kerja yang disarankan

1. **Konfirmasi keputusan §3** (terutama 3.1 kebijakan kehabisan batch & 3.2 sumber generate).
2. **Fase 0** (snapshot) — paling untung, paling kecil. Bisa langsung dikerjakan & dirilis.
3. **Fase 1 → 2 → 3** (skema → generator → penyajian batch).
4. **Fase 4** (UI admin) setelah alur batch jalan, agar admin punya kontrol penuh.
5. **Fase 5–6** menyusul sesuai kebutuhan.

> Belum ada kode yang ditulis. Setelah keputusan §3 dikonfirmasi, mulai dari Fase 0.
