Gap Analysis - Integrasi Pola B

Audit gap antara implementasi aplikasi saat ini vs visi integrasi Sejoli Tryout pola 1-AJAX-per-soal.

Gap Analysis: Integrasi Pola B

Status: Audit teknis — bukan dokumen user-facing. Tujuan: Single source of truth untuk "apa yang kurang" di aplikasi agar sesuai visi integrasi. Sumber temuan: Verifikasi langsung dari backend/app/routers/*.py + backend/app/services/*.py per 2026-07-25.

Konteks: Visi Integrasi Pola B

Setelah integrasi penuh dengan Sejoli Tryout, alur ujian harus pakai Pola B (1 AJAX call per soal) — baik untuk mode fixed maupun adaptive. Siswa tetap di UI Sejoli (tidak redirect/iframe app), Sejoli call API app setiap klik "Next".

sequenceDiagram
    autonumber
    participant Siswa
    participant Sejoli as Sejoli Tryout UI
    participant App as yellow-bank-soal API

    Siswa->>Sejoli: Buka halaman ujian
    Sejoli->>App: POST /session/ start attempt
    App-->>Sejoli: session_id

    loop Setiap soal selama tryout
        Siswa->>Sejoli: Klik Next
        Sejoli->>App: POST /session/id/next_item
        Note over App: Algoritma pilih soal:
        App->>App: Mode fixed? Ambil slot berikutnya
        App->>App: Mode adaptive? Hitung theta, pilih b terdekat
        App->>App: Cek varian level sedang/mudah/sulit
        App-->>Sejoli: 1 soal terpilih
        Sejoli-->>Siswa: Render soal itu saja
        Siswa->>Sejoli: Jawab
        Sejoli->>App: POST /session/id/answer
        Note over App: Update theta, cek terminasi
    end

    Siswa->>Sejoli: Selesai / waktu habis
    Sejoli->>App: POST /session/id/complete
    App-->>Sejoli: NM, NN, theta, hasil
    Sejoli-->>Siswa: Tampilkan hasil

Prinsip kunci yang harus dipenuhi:

  1. Sumber soal utama = App DB (bukan Sejoli). Sejoli hanya fallback saat app unavailable.
  2. Soal dipilih per-request, bahkan di mode fixed (untuk variasi level Sedang/Mudah/Sulit per siswa).
  3. Tidak ada pre-bundle tryout di awal sesi — yang di-preload cuma konfigurasi & pool soal tersedia.
  4. App = backend headless; Sejoli = full UI (render, navigasi, timer, localStorage).
  5. Theta diupdate real-time setiap jawaban (kritis untuk mode adaptive).

Yang Sudah Siap (Tidak Perlu Diubah)

KomponenLokasiStatus
Skor CTT (p, Bobot, NM, NN)services/ctt_scoring.py✅ Lengkap
Estimasi theta MLE + Fisher informationservices/irt_calibration.py✅ Lengkap
Kalibrasi b (joint MLE)services/irt_calibration.py::estimate_b✅ Lengkap
Service pemilihan soal CATservices/cat_selection.py::get_next_item✅ Lengkap (service-level)
Mode hybrid + transition slotservices/cat_selection.py::get_next_item_hybrid✅ Lengkap
AI question generationservices/ai_generation.py✅ Lengkap
JSON import dari Sejoli exportservices/tryout_json_import.py✅ Lengkap (snapshot + diff)
Verifikasi WP JWTservices/wordpress_auth.py✅ Lengkap
Multi-tenant (X-Website-ID)routers/wordpress.py::get_website_id_from_header✅ Lengkap
Endpoint create sessionrouters/sessions.py::create_session✅ Ada (perlu extend)
Endpoint complete sessionrouters/sessions.py::complete_session⚠️ Ada tapi salah paradigm (lihat Gap 2)
Field entitlement metadataschemas/session.py::SessionCreateRequest✅ Field-nya ada

Gap Prioritas P0 (Blok Visi Pola B)

Gap 1: Endpoint /session/{id}/next_item tidak ada

Impact: Tanpa ini, Sejoli tidak bisa minta soal berikutnya dari app. Visi Pola B mustahil.

Yang ada sekarang:

  • Service cat_selection.get_next_item() sudah lengkap di service layer
  • Tapi tidak ada router yang expose ini sebagai HTTP endpoint

Yang harus ditambah:

python
# backend/app/routers/sessions.py (proposed)

@router.post(
    "/{session_id}/next_item",
    response_model=NextItemResponse,
    summary="Get next question for adaptive/fixed session",
)
async def get_next_item_endpoint(
    session_id: str,
    db: AsyncSession = Depends(get_db),
    auth: AuthContext = Depends(get_auth_context),
) -> NextItemResponse:
    """
    Return 1 soal terpilih oleh algoritma app.

    Behavior:
    - Mode fixed: slot berikutnya (urut 1, 2, 3, ...)
    - Mode adaptive: b terdekat ke theta siswa + varian level
    - Mode hybrid: transisi fixed → adaptive di hybrid_transition_slot

    State management:
    - Track soal mana yang sudah diberikan ke sesi ini
    - Idempotent: panggil 2x cepat harus return soal yang sama
    - Handle terminasi: return {item: null, reason: "complete"} kalau selesai
    """

Schema response baru (schemas/session.py):

python
class NextItemResponse(BaseModel):
    item: Optional[ItemResponse]    # null kalau sesi selesai
    selection_method: str           # "fixed" | "adaptive" | "hybrid"
    slot: Optional[int]
    level: Optional[str]            # "mudah" | "sedang" | "sulit"
    reason: str                     # penjelasan kenapa soal ini
    items_remaining: int            # untuk progress bar
    should_terminate: bool          # flag terminasi (SE < 0.5 dll)
    session_status: str             # "in_progress" | "completed"

Gap 2: Endpoint /session/{id}/answer tidak ada

Impact: Tanpa ini, theta tidak bisa di-update real-time. Mode adaptive rusak total (app tidak tahu theta siswa berkembang).

Yang ada sekarang:

  • complete_session menerima user_answers: List[UserAnswerInput] (batch) di akhir ujian
  • Theta dihitung dari nol di akhir (post-hoc), bukan real-time

Yang harus ditambah:

python
@router.post(
    "/{session_id}/answer",
    response_model=AnswerResponse,
    summary="Submit single answer and update theta",
)
async def submit_answer_endpoint(
    session_id: str,
    request: AnswerRequest,        # {item_id, response, time_spent_ms?}
    db: AsyncSession = Depends(get_db),
    auth: AuthContext = Depends(get_auth_context),
) -> AnswerResponse:
    """
    Submit 1 jawaban, simpan ke UserAnswer, update theta real-time.

    Process:
    1. Validate session exists, not completed
    2. Validate item_id cocok dengan last next_item yang diberikan
    3. Cek is_correct, calculate bobot_earned
    4. Save UserAnswer record
    5. Jika mode irt/hybrid: update_session_theta() real-time
    6. Return updated theta + SE (untuk progress display)
    """

Schema baru:

python
class AnswerRequest(BaseModel):
    item_id: int
    response: str                   # label option: "A", "B", "C", "D"
    time_spent_ms: Optional[int]    # untuk analitik

class AnswerResponse(BaseModel):
    is_correct: bool
    bobot_earned: float
    theta: Optional[float]          # null kalau mode ctt
    theta_se: Optional[float]       # null kalau mode ctt
    items_answered: int
    items_remaining: int

Gap 3: Refactor complete_session — dari batch ke finalisasi

Impact: Saat ini complete_session terima array jawaban. Setelah Gap 1+2 jadi, ini redundant dan bikin race condition (sumber truth dobel: jawaban di UserAnswer vs di request body).

Yang harus diubah:

python
# SEBELUM (Pola A — batch submit):
class SessionCompleteRequest(BaseModel):
    end_time: datetime
    user_answers: List[UserAnswerInput]    # ← HAPUS
    force: bool = False

# SETELAH (Pola B — finalisasi saja):
class SessionCompleteRequest(BaseModel):
    end_time: datetime
    reason: Optional[str]           # "user_submit" | "timeout" | "admin_force"
    # Jawaban sudah tersimpan di UserAnswer via /answer endpoint

Behavior baru complete_session:

  1. Validasi sesi exists, not yet completed
  2. Cek minimal 1 jawaban tersimpan (SELECT COUNT(*) FROM user_answers WHERE session_id = ?)
  3. Hitung skor final dari UserAnswer (bukan dari request body)
  4. Kalau mode irt/hybrid: theta sudah ter-update real-time, tinggal ambil nilai terakhir
  5. Update Session: status=completed, NM, NN, theta, completed_at
  6. Update TryoutStats (inkremental)
  7. Return skor final

Backward compatibility: butuh feature flag atau versioning (/v1/session/{id}/complete = batch, /v2/session/{id}/complete = finalisasi). Atau breaking change dengan migrasi plugin Sejoli serentak.

Gap Prioritas P1 (Kualitas & Robustness)

Gap 4: Attempt Lifecycle State Machine

Sekarang Session cuma punya status implisit: "created" (ada record) vs "completed" (ada completed_at).

Yang dibutuhkan: state eksplisit untuk enforce urutan operasi.

python
class Session(Base):
    status: Mapped[Literal["created", "in_progress", "completed", "abandoned"]]
    #         ^^^^^^^^^ tambah kolom status
    current_item_id: Mapped[Optional[int]]   # soal yang sedang aktif
    next_item_seq: Mapped[int]               # counter urutan next_item call

Aturan transisi:

DariKeTrigger
createdin_progressFirst next_item call
in_progressin_progressanswer → next next_item
in_progresscompletedcomplete atau auto-terminasi
in_progressabandonedTimeout panjang (cleanup job)
completed(terminal)Tidak bisa transisi

Validasi endpoint:

  • next_item pada session completed → 400 "Session already completed"
  • answer pada session created (belum next_item) → 400 "No active item"
  • answer pada session completed → 400 "Session already completed"

Gap 5: Idempotency next_item

Skenario masalah: siswa klik Next 2x cepat (double-click, network lag). Tanpa proteksi, app kasih 2 soal berbeda → siswa bingung, jawaban tidak sinkron.

Solusi:

python
@router.post("/{session_id}/next_item")
async def get_next_item_endpoint(...):
    # Cek apakah ada item yang sudah diberikan tapi belum dijawab
    pending = await get_pending_item(session_id)
    if pending:
        # Return item yang sama (idempotent)
        return NextItemResponse(item=pending, ...)

    # Kalau tidak ada pending, pilih soal baru
    next_item = await cat_selection.get_next_item(...)
    await mark_item_as_given(session_id, next_item.id)
    return NextItemResponse(item=next_item, ...)

Field Session.current_item_id dari Gap 4 dipakai untuk tracking ini.

Gap 6: Validasi Urutan answer ↔ next_item

Skenario masalah: siswa submit answer untuk item_id=42, padahal last next_item kasih item_id=99. Bisa karena bug frontend, replay attack, atau siswa buka 2 tab.

Solusi:

python
@router.post("/{session_id}/answer")
async def submit_answer_endpoint(session_id, request: AnswerRequest, ...):
    session = await get_session(session_id)

    if request.item_id != session.current_item_id:
        raise HTTPException(
            status_code=409 CONFLICT,
            detail=f"Expected answer for item {session.current_item_id}, got {request.item_id}"
        )

    # ... save answer, update theta ...
    session.current_item_id = None   # clear, siap untuk next_item berikutnya

Gap Prioritas P2 (Nice-to-have / Hardening)

Gap 7: Quota Entitlement Enforcement (Opsional)

Sekarang: create_session catat entitlement metadata tapi tidak enforce quota.

Pertanyaan desain: siapa yang enforce?

  • Opsi A: App cek attempt_number <= attempt_limit sebelum buat session → sumber truth di app
  • Opsi B: Sejoli enforce sebelum call create_session → app trust Sejoli (sesuai kontrak awal)

Rekomendasi: Opsi B (sesuai SEJOLI_TRYOUT_INTEGRATION.md — "Sejoli tetap pemilik keputusan 'boleh mulai lagi'"). Tapi tambah audit log di app untuk tracing.

Gap 8: Rate Limiting per-Sesi

Cegah abuse: siswa/script spam next_item atau answer untuk scrape soal atau brute-force jawaban.

Implementasi: Redis-based limiter, mis.:

  • next_item: max 1 request per 2 detik per session
  • answer: max 1 request per 500ms per session

Gap 9: Resumption Session (Resume)

Skenario: siswa refresh tab di tengah ujian, atau koneksi putus reconnect.

Sekarang: data tersimpan di localStorage Sejoli, kalau hilang → data hilang.

Yang dibutuhkan:

  • Endpoint GET /session/{id}/state — return soal terakhir yang aktif + jawaban yang sudah tersimpan
  • Sejoli bisa resume dari state server-side, bukan dari localStorage
  • Lebih reliable untuk ujian panjang (>50 soal)

Gap 10: Audit Trail

Untuk compliance & debugging:

  • Log setiap next_item call (siapa, kapan, soal mana yang dipilih, algoritma kenapa pilih itu)
  • Log setiap answer (respons time, theta sebelum/sesudah)
  • Log terminasi (alasan: user vs timeout vs auto-SE-threshold)

Estimasi Effort

Rough estimate (1 developer senior, FTE):

PrioritasGapEffort
P01. Endpoint next_item2-3 hari (service sudah ada, perlu router + state management)
P02. Endpoint answer + theta update2-3 hari
P03. Refactor complete_session1-2 hari (perlu handle backward compat)
P14. State machine Session1-2 hari (migration + validasi)
P15. Idempotency next_item1 hari (test edge cases)
P16. Validasi urutan answer0.5 hari
P27. Quota enforcement1 hari (kalau opsi B)
P28. Rate limiting1 hari (Redis setup)
P29. Session resumption2-3 hari
P210. Audit trail1-2 hari

Total P0+P1: ~10-14 hari kerja Total semua: ~15-20 hari kerja

Test Plan

Setelah implementasi, verifikasi dengan skenario:

Skenario A: Mode fixed (smoke test)

  1. Create session → dapat session_id
  2. Loop 10x: next_itemanswer
  3. Verify: 10 UserAnswer tersimpan, slot urut 1-10
  4. complete → dapat NM, NN

Skenario B: Mode adaptive (CAT real)

  1. Create session dengan tryout yang sudah terkalibrasi IRT
  2. Loop: next_itemanswer, simpan theta tiap iterasi
  3. Verify: theta konvergen (perubahan makin kecil)
  4. Verify: soal terpilih punya b mendekati theta terbaru
  5. Auto-terminasi setelah SE < 0.5 dan n >= 15

Skenario C: Edge cases

  1. next_item di session yang sudah complete → 400
  2. answer tanpa next_item sebelumnya → 400
  3. Double-click next_item → return item yang sama (idempotent)
  4. Submit answer untuk item_id beda → 409 Conflict
  5. Network putus di tengah, refresh → bisa resume

Referensi

Last updated Jul 25, 2026