You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iRCFA-data/inference_results_viewer.html

1204 lines
36 KiB
HTML

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RCFA RLHF Comparison Desk</title>
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<style>
:root {
--bg: #0b1120;
--card: #141e33;
--card2: #1a2640;
--border: #2a3a5c;
--primary: #4f8ef7;
--accent: #22d3ee;
--purple: #7c6ef7;
--text: #e2e8f0;
--muted: #7a8faf;
--high: #ef4444;
--high-bg: rgba(239, 68, 68, .13);
--medium: #f59e0b;
--medium-bg: rgba(245, 158, 11, .13);
--low: #10b981;
--low-bg: rgba(16, 185, 129, .13);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
body {
font-family: 'Outfit', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 15px;
padding-top: 40px;
}
code {
background: rgba(0, 0, 0, .3);
padding: .1rem .4rem;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: .82em
}
button {
cursor: pointer;
font-family: inherit;
}
/* PROGRESS BAR */
#prog-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--card2);
border-bottom: 1px solid var(--border);
padding: 0.4rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 999;
font-size: 0.8rem;
height: 40px;
}
.pb-stats {
display: flex;
gap: 1.5rem;
align-items: center;
}
.pb-fill {
width: 200px;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
}
.pb-inner {
height: 100%;
background: var(--low);
width: 0%;
transition: width 0.3s;
}
.btn-small {
background: none;
border: 1px solid var(--border);
color: var(--text);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
}
.btn-small:hover {
border-color: var(--primary);
color: var(--primary);
}
/* HEADER */
header {
background: rgba(15, 22, 43, .92);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
position: sticky;
top: 40px;
z-index: 200
}
.hi {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap
}
h1 {
font-size: 1.3rem;
font-weight: 700;
background: linear-gradient(90deg, #fff 20%, var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent
}
.hr {
display: flex;
align-items: center;
gap: .6rem;
flex-wrap: wrap
}
.pill {
padding: .28rem .85rem;
background: rgba(255, 255, 255, .05);
border: 1px solid var(--border);
border-radius: 20px;
font-size: .78rem
}
/* MODAL */
#modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
}
.modal {
background: var(--card);
padding: 2rem;
border-radius: 12px;
border: 1px solid var(--border);
max-width: 400px;
width: 100%;
text-align: center;
}
.modal input {
width: 100%;
padding: 0.8rem;
margin: 1rem 0;
background: var(--bg);
border: 1px solid var(--border);
color: white;
border-radius: 6px;
}
.modal button {
background: var(--primary);
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 6px;
font-weight: bold;
}
.hidden {
display: none !important;
}
/* UPLOAD */
#up {
max-width: 760px;
margin: 4rem auto;
padding: 0 2rem
}
.up-title {
font-size: 1rem;
font-weight: 600;
color: var(--accent);
margin-bottom: 1.2rem
}
.up-slots {
display: flex;
flex-direction: column;
gap: .8rem;
margin-bottom: 1rem
}
.up-slot {
display: flex;
align-items: center;
gap: 1rem;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: .9rem 1.3rem
}
.sl-label {
font-size: .8rem;
color: var(--muted);
width: 75px;
flex-shrink: 0;
font-weight: 600
}
.sl-name {
flex: 1;
font-size: .82rem;
font-family: 'JetBrains Mono', monospace;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
.sl-pick {
padding: .35rem .9rem;
background: rgba(79, 142, 247, .1);
border: 1px solid var(--primary);
border-radius: 7px;
color: var(--primary);
font-size: .8rem;
cursor: pointer;
}
.sl-rm {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 1rem;
padding: .1rem .35rem
}
.sl-rm:hover {
color: var(--high)
}
.add-btn {
display: flex;
align-items: center;
gap: .4rem;
padding: .6rem 1.1rem;
background: rgba(34, 211, 238, .04);
border: 1px dashed var(--accent);
border-radius: 9px;
color: var(--accent);
cursor: pointer;
font-size: .84rem;
font-family: inherit;
transition: all .2s;
width: fit-content;
margin-bottom: 1.4rem
}
.add-btn:hover {
background: rgba(34, 211, 238, .1)
}
.go-btn {
display: block;
width: 100%;
padding: .85rem;
background: var(--primary);
border: none;
border-radius: 12px;
color: #fff;
font-size: 1rem;
font-weight: 600;
}
.go-btn:disabled {
opacity: .35;
cursor: default
}
/* CONTROLS */
.controls {
max-width: 1400px;
margin: 1.5rem auto 1rem;
padding: 0 2rem;
display: flex;
gap: .7rem;
flex-wrap: wrap;
align-items: center
}
input[type=text],
select {
background: var(--card);
border: 1px solid var(--border);
border-radius: 9px;
color: var(--text);
padding: .6rem 1rem;
font-size: .88rem;
}
input[type=text] {
flex: 1;
min-width: 200px
}
.rmeta {
max-width: 1400px;
margin: 0 auto .5rem;
padding: 0 2rem;
font-size: .8rem;
color: var(--muted)
}
.export-pref-btn {
background: rgba(16, 185, 129, .1);
border: 1px solid var(--low);
border-radius: 9px;
color: var(--low);
padding: .6rem 1.1rem;
font-size: .84rem;
margin-left: auto;
}
/* CARDS */
#results {
max-width: 1400px;
margin: 0 auto 5rem;
padding: 0 2rem;
display: flex;
flex-direction: column;
gap: 2rem
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 18px;
overflow: hidden;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s;
scroll-margin-top: 130px;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(0,0,0,0.4);
border-color: rgba(79, 142, 247, 0.3);
}
.card-top {
padding: 1.2rem 1.6rem;
border-bottom: 1px solid var(--border);
background: var(--card2)
}
.card-top-row {
display: flex;
align-items: center;
gap: .8rem;
margin-bottom: .9rem;
flex-wrap: wrap
}
.case-n {
font-size: .72rem;
color: var(--muted)
}
.tag {
font-family: 'JetBrains Mono', monospace;
font-size: 1rem;
font-weight: 700;
color: var(--accent)
}
/* RISK EDITOR */
.risk-edit-grp {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(0, 0, 0, 0.2);
padding: 0.2rem 0.5rem;
border-radius: 6px;
border: 1px solid var(--border);
}
.risk-sel {
background: transparent;
border: none;
color: inherit;
font-weight: bold;
outline: none;
font-size: 0.75rem;
cursor: pointer;
}
.risk-reason-inp {
background: transparent;
border: none;
border-left: 1px solid var(--border);
padding-left: 0.5rem;
color: var(--text);
font-size: 0.75rem;
outline: none;
width: 200px;
}
.diff-badge {
font-size: 0.6rem;
border: 1px dashed var(--medium);
color: var(--medium);
padding: 0.1rem 0.3rem;
border-radius: 4px;
}
/* FLAG & SKIP */
.card-actions {
margin-left: auto;
display: flex;
gap: 0.5rem;
}
.flag-btn {
background: rgba(239, 68, 68, .1);
border: 1px solid var(--high);
color: var(--high);
padding: 0.3rem 0.8rem;
border-radius: 6px;
font-size: 0.75rem;
}
.flag-btn.flagged {
background: var(--high);
color: white;
}
.flag-reason {
display: none;
background: var(--bg);
border: 1px solid var(--high);
color: var(--text);
padding: 0.3rem;
border-radius: 4px;
font-size: 0.7rem;
outline: none;
}
/* GROUND TRUTH BOX */
.gt-box {
background: rgba(124, 110, 247, 0.06);
padding: 1.2rem 1.6rem;
border-bottom: 1px solid var(--border);
border-left: 4px solid var(--purple);
}
.gt-title {
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 700;
color: var(--purple);
margin-bottom: 0.8rem;
letter-spacing: 0.5px;
}
.gt-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.8rem 1.5rem;
}
.fi-key {
font-size: .68rem;
font-weight: 600;
color: var(--muted);
margin-bottom: .12rem
}
.fi-val {
font-size: .82rem;
line-height: 1.5;
color: var(--text)
}
/* RESPONSES */
.card-responses {
display: grid
}
.resp-col {
padding: 1.3rem 1.6rem;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.resp-col:last-child {
border-right: none
}
.resp-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: .7rem;
border-bottom: 1px solid var(--border)
}
.resp-model {
font-family: 'JetBrains Mono', monospace;
font-size: .8rem;
font-weight: 700;
color: var(--accent)
}
.resp-body {
font-size: .84rem;
line-height: 1.7;
flex: 1;
}
.resp-body p {
margin: .35rem 0
}
.resp-body ul {
padding-left: 1.3rem;
margin: .3rem 0
}
.resp-body strong {
font-weight: 600;
color: var(--text)
}
.resp-body code {
background: rgba(0, 0, 0, .3);
padding: .1rem .3rem;
border-radius: 4px;
color: var(--accent)
}
/* DIFF VIEW */
.diff-removed {
background-color: rgba(239, 68, 68, 0.25);
text-decoration: line-through;
opacity: 0.7;
}
.diff-toggle {
font-size: 0.7rem;
color: var(--primary);
cursor: pointer;
background: none;
border: none;
text-decoration: underline;
}
/* SOP & TAGS */
.sop-dots {
display: flex;
gap: .3rem;
align-items: center
}
.sop-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.sop-dot.ok {
background: var(--low);
}
.sop-dot.miss {
background: var(--high);
}
.tag-error {
border: 1px solid var(--high) !important;
box-shadow: inset 0 0 10px rgba(239, 68, 68, .2);
}
/* RLHF FORM */
.rlhf-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px dashed var(--border);
}
.stars {
display: flex;
flex-direction: row-reverse;
justify-content: flex-end;
gap: 0.2rem;
margin-bottom: 0.5rem;
}
.stars input {
display: none;
}
.stars label {
font-size: 1.4rem;
color: rgba(255, 255, 255, 0.15);
cursor: pointer;
transition: color 0.2s, transform 0.1s;
}
.stars label:active {
transform: scale(0.75);
}
.stars label:hover,
.stars label:hover~label,
.stars input:checked~label {
color: #fbbf24;
text-shadow: 0 0 10px rgba(251, 191, 36, 0.4);
}
.dims {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.dim-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.65rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.dim-btn.active {
background: rgba(79, 142, 247, 0.15);
border-color: var(--primary);
color: var(--primary);
}
.qc-group {
display: flex;
gap: 0.3rem;
margin-bottom: 0.3rem;
flex-wrap: wrap;
}
.qc-btn {
background: none;
border: 1px solid var(--border);
color: var(--muted);
font-size: 0.65rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
.qc-btn:hover {
border-color: var(--text);
color: var(--text);
}
.rv-comment {
width: 100%;
height: 50px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border);
color: var(--text);
padding: 0.4rem;
font-size: 0.75rem;
border-radius: 4px;
resize: vertical;
font-family: inherit;
}
.best-btn {
width: 100%;
margin-top: .7rem;
padding: .5rem;
background: rgba(255, 255, 255, .03);
border: 1px dashed rgba(255, 215, 0, .25);
border-radius: 9px;
color: rgba(255, 215, 0, .55);
font-size: .78rem;
transition: all .25s
}
.best-btn:hover {
background: rgba(255, 215, 0, .07);
border-color: rgba(255, 215, 0, .5);
color: #ffd700
}
.best-btn.chosen {
background: rgba(255, 215, 0, .15);
border-color: #ffd700;
color: #ffd700;
font-weight: 700
}
</style>
</head>
<body>
<div id="modal-overlay">
<div class="modal">
<h2>Welcome to RCFA Validation</h2>
<p style="margin-top:0.5rem;font-size:0.9rem;color:var(--muted)">Enter your Reviewer ID to begin.</p>
<input type="text" id="reviewer-name" placeholder="E.g., Eng-Daniel">
<button onclick="startSession()">Start Reviewing</button>
</div>
</div>
<div id="prog-bar" class="hidden">
<div class="pb-stats">
<span>👤 <b id="pb-user"></b></span>
<span>✅ Reviewed: <b id="pb-rev">0</b> / <span id="pb-tot">0</span></span>
<span style="color:#f87171">🚩 Flagged: <b id="pb-flg">0</b></span>
<div class="pb-fill">
<div class="pb-inner" id="pb-bar"></div>
</div>
</div>
<div>
<button class="btn-small" onclick="jumpNext()">⏭ Jump to Next Unreviewed</button>
</div>
</div>
<header>
<div class="hi">
<h1>⚡ RCFA Data Validation Desk</h1>
<div class="hr">
<div id="hpills"></div>
</div>
</div>
</header>
<div id="up">
<div class="up-title">Select Datasets to Evaluate</div>
<p style="font-size:0.85rem; color:var(--muted); margin-bottom:1rem;">The datasets below will be automatically downloaded from the repository. Select the ones you wish to compare.</p>
<div class="up-slots" id="slots">
<label class="sl-pick" style="display:flex; gap:0.5rem; align-items:center; cursor:pointer;">
<input type="checkbox" value="../benchmark/100_queries/results_gemma4-e2b_100.jsonl" checked>
Gemma4 2B (results_gemma4-e2b_100.jsonl)
</label>
<label class="sl-pick" style="display:flex; gap:0.5rem; align-items:center; cursor:pointer;">
<input type="checkbox" value="../benchmark/100_queries/results_gemma4-e4b_100.jsonl" checked>
Gemma4 4B (results_gemma4-e4b_100.jsonl)
</label>
<label class="sl-pick" style="display:flex; gap:0.5rem; align-items:center; cursor:pointer;">
<input type="checkbox" value="../benchmark/100_queries/results_qwen3-4b_100.jsonl">
Qwen3 4B (results_qwen3-4b_100.jsonl)
</label>
<label class="sl-pick" style="display:flex; gap:0.5rem; align-items:center; cursor:pointer;">
<input type="checkbox" value="../benchmark/100_queries/results_qwen3-8b_100.jsonl">
Qwen3 8B (results_qwen3-8b_100.jsonl)
</label>
</div>
<button class="go-btn" id="gobtn" onclick="fetchAndCompare()">Download & Start Validation →</button>
<div id="fetch-status" style="margin-top:1rem; color:var(--accent); font-size:0.85rem; text-align:center;"></div>
<div style="margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px dashed var(--border);">
<div class="up-title" style="font-size:0.95rem;">Fallback: Manual Data Loading</div>
<div style="background: rgba(34, 211, 238, 0.05); border: 1px dashed rgba(34, 211, 238, 0.3); padding: 1rem; border-radius: 8px; margin-bottom: 1.5rem;">
<h4 style="color: var(--accent); font-size: 0.85rem; margin-bottom: 0.5rem;">💡 How to load data manually:</h4>
<p style="font-size:0.8rem; color:var(--text); line-height: 1.5; margin: 0;">
If the auto-download fails due to browser security restrictions (like CORS) when running locally, you must load the data files manually.
</p>
<ul style="font-size:0.8rem; color:var(--muted); line-height: 1.6; padding-left: 1.2rem; margin-top: 0.5rem;">
<li>Click the <b>Browse</b> buttons below for each model slot.</li>
<li>Navigate to the <code style="color: var(--primary);">benchmark/100_queries</code> folder in this project directory.</li>
<li>Select the corresponding inference result file (e.g., <code>results_gemma4-e2b_100.jsonl</code>).</li>
<li>Click <b>+ Add manual file</b> if you want to compare more than two models at once.</li>
<li>Once all files are selected, click <b>Load Local Files →</b> to begin your validation session.</li>
</ul>
</div>
<div class="up-slots" id="manual-slots">
<div class="up-slot" data-i="0">
<span class="sl-label">Model A</span>
<span class="sl-name" id="msn0">No file selected</span>
<label class="sl-pick">Browse <input type="file" accept=".jsonl" style="display:none" onchange="pickManual(0,this)"></label>
</div>
<div class="up-slot" data-i="1">
<span class="sl-label">Model B</span>
<span class="sl-name" id="msn1">No file selected</span>
<label class="sl-pick">Browse <input type="file" accept=".jsonl" style="display:none" onchange="pickManual(1,this)"></label>
</div>
</div>
<button class="add-btn" onclick="addManualSlot()">+ Add manual file</button>
<button class="go-btn" id="manualbtn" onclick="compareManual()" disabled style="background:var(--border); color:var(--text); margin-top:0.5rem;">Load Local Files →</button>
</div>
</div>
<div class="controls hidden" id="ctrl">
<input type="text" id="search" placeholder="🔍 Search tags, symptoms...">
<select id="rflt">
<option value="all">All Risks</option>
<option value="High">🔴 High</option>
<option value="Medium">🟡 Medium</option>
<option value="Low">🟢 Low</option>
</select>
<select id="sflt">
<option value="all">All Status</option>
<option value="pending">⏳ Pending</option>
<option value="reviewed">✅ Reviewed</option>
<option value="flagged">🚩 Flagged</option>
</select>
<button class="export-pref-btn" onclick="exportData()">⬇ Export Validation Dataset</button>
</div>
<div class="rmeta hidden" id="rmeta"></div>
<div id="results"></div>
<script>
let files = [null, null];
let mSlots = 2;
let cases = []; // merged
let state = JSON.parse(localStorage.getItem('rcfa_state')) || {};
let reviewer = localStorage.getItem('rcfa_reviewer') || '';
let diffMode = {}; // caseIdx -> boolean
const SOP = [{ l: 'Gejala', r: /gejala/i }, { l: 'Verifikasi', r: /verifikasi/i }, { l: 'Akar Masalah', r: /akar\s*masalah/i }, { l: 'Dampak', r: /dampak/i }, { l: 'FDT', r: /rekomendasi|FDT/i }];
const TAG_RE = /\b3[A-Z]{2}-H\d{3}\b/g;
// INIT
if (reviewer) {
document.getElementById('modal-overlay').classList.add('hidden');
document.getElementById('pb-user').textContent = reviewer;
}
function startSession() {
const v = document.getElementById('reviewer-name').value.trim();
if (!v) return;
reviewer = v;
localStorage.setItem('rcfa_reviewer', reviewer);
document.getElementById('pb-user').textContent = reviewer;
document.getElementById('modal-overlay').classList.add('hidden');
}
async function fetchAndCompare() {
const btn = document.getElementById('gobtn');
const stat = document.getElementById('fetch-status');
const checks = Array.from(document.querySelectorAll('#slots input:checked'));
if (checks.length < 1) { alert('Please select at least one dataset to evaluate.'); return; }
btn.disabled = true;
stat.textContent = "Starting download...";
files = [];
try {
for(let i=0; i<checks.length; i++) {
const url = checks[i].value;
const name = url.split('/').pop();
stat.textContent = `Downloading ${name} (${i+1}/${checks.length})...`;
const res = await fetch(url);
if(!res.ok) throw new Error(`HTTP ${res.status} when downloading ${name}`);
const text = await res.text();
files.push({ name: name, rows: text.split('\n').filter(l => l.trim()).map(l => JSON.parse(l)) });
}
stat.textContent = "Merging datasets...";
compare();
} catch (err) {
alert("Failed to download datasets: " + err.message + "\n\nMake sure the files exist in the repository relative to this page.");
btn.disabled = false;
stat.textContent = "";
}
}
function addManualSlot() {
const i = mSlots++;
files.push(null);
const labels = 'ABCDEFG';
const d = document.createElement('div');
d.className = 'up-slot'; d.dataset.i = i;
d.innerHTML = `<span class="sl-label">Model ${labels[i] || i + 1}</span>
<span class="sl-name" id="msn${i}">No file selected</span>
<label class="sl-pick">Browse <input type="file" accept=".jsonl" style="display:none" onchange="pickManual(${i},this)"></label>
<button class="sl-rm" onclick="rmManualSlot(this,${i})" title="Remove">✕</button>`;
document.getElementById('manual-slots').appendChild(d);
updateManualBtn();
}
function rmManualSlot(btn, i) {
btn.parentElement.remove();
files[i] = null;
updateManualBtn();
}
function updateManualBtn() {
const btn = document.getElementById('manualbtn');
btn.disabled = files.filter(Boolean).length < 1;
if (files.filter(Boolean).length > 0) {
btn.style.background = 'var(--primary)';
btn.style.color = '#fff';
} else {
btn.style.background = 'var(--border)';
btn.style.color = 'var(--text)';
}
}
function pickManual(i, inp) {
const f = inp.files[0]; if (!f) return;
document.getElementById('msn' + i).textContent = f.name;
const reader = new FileReader();
reader.onload = ev => {
try {
files[i] = { name: f.name, rows: ev.target.result.split('\n').filter(l => l.trim()).map(l => JSON.parse(l)) };
updateManualBtn();
} catch (e) { alert('Parse error'); }
};
reader.readAsText(f);
}
function compareManual() {
compare();
}
function compare() {
const loaded = files.filter(Boolean);
if (loaded.length < 1) return;
const maxLen = Math.max(...loaded.map(m => m.rows.length));
cases = [];
for (let i = 0; i < maxLen; i++) {
const firstRow = loaded.find(m => m.rows[i])?.rows[i];
if (!firstRow) continue;
cases.push({ tag: firstRow.tag, risk: firstRow.risk, prompt: firstRow.input_prompt, rows: loaded.map(m => m.rows[i] || null) });
if (!state[i]) state[i] = { status: 'pending', origRisk: firstRow.risk, risk: firstRow.risk, riskReason: '', flag: '', best: null, models: {} };
}
document.getElementById('up').classList.add('hidden');
document.getElementById('prog-bar').classList.remove('hidden');
document.getElementById('ctrl').classList.remove('hidden');
document.getElementById('rmeta').classList.remove('hidden');
['search', 'rflt', 'sflt'].forEach(id => document.getElementById(id).addEventListener('input', render));
updateProgress();
render();
}
function updateProgress() {
if (!cases.length) return;
let rev = 0, flg = 0;
Object.values(state).forEach(s => {
if (s.status === 'reviewed') rev++;
if (s.status === 'flagged') flg++;
});
document.getElementById('pb-rev').textContent = rev;
document.getElementById('pb-tot').textContent = cases.length;
document.getElementById('pb-flg').textContent = flg;
document.getElementById('pb-bar').style.width = `${(rev / cases.length) * 100}%`;
localStorage.setItem('rcfa_state', JSON.stringify(state));
}
function jumpNext() {
for (let i = 0; i < cases.length; i++) {
if (state[i].status === 'pending') {
document.getElementById(`case-${i}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
}
}
// STATE UPDATERS (Now using updateCaseDOM for lag-free rendering)
function setRisk(ci, val) { state[ci].risk = val; checkStatus(ci); updateCaseDOM(ci); }
function setRiskReason(ci, val) { state[ci].riskReason = val; checkStatus(ci); }
function toggleFlag(ci) {
state[ci].status = state[ci].status === 'flagged' ? 'pending' : 'flagged';
if (state[ci].status !== 'flagged') state[ci].flag = '';
checkStatus(ci); updateCaseDOM(ci);
}
function setFlagReason(ci, val) { state[ci].flag = val; checkStatus(ci); }
function setRating(ci, model, val) {
if (!state[ci].models[model]) state[ci].models[model] = { rating: 0, comment: '', dims: {} };
if (state[ci].models[model].rating === val) {
state[ci].models[model].rating = 0; // Unclick
} else {
state[ci].models[model].rating = val;
}
checkStatus(ci); updateCaseDOM(ci);
}
function toggleDim(ci, model, dim) {
if (!state[ci].models[model]) state[ci].models[model] = { rating: 0, comment: '', dims: {} };
state[ci].models[model].dims[dim] = !state[ci].models[model].dims[dim];
updateCaseDOM(ci);
}
function setComment(ci, model, val) {
if (!state[ci].models[model]) state[ci].models[model] = { rating: 0, comment: '', dims: {} };
state[ci].models[model].comment = val; checkStatus(ci);
}
function addQc(ci, model, txt) {
if (!state[ci].models[model]) state[ci].models[model] = { rating: 0, comment: '', dims: {} };
state[ci].models[model].comment += (state[ci].models[model].comment ? '; ' : '') + txt;
updateCaseDOM(ci);
}
function toggleBest(ci, model) {
state[ci].best = state[ci].best === model ? null : model;
checkStatus(ci); updateCaseDOM(ci);
}
function checkStatus(ci) {
if (state[ci].status === 'flagged') return updateProgress();
const s = state[ci];
let hasRating = false;
Object.values(s.models).forEach(m => { if (m.rating > 0) hasRating = true; });
if (s.best || hasRating || s.risk !== s.origRisk) s.status = 'reviewed';
else s.status = 'pending';
updateProgress();
}
function toggleDiff(ci) { diffMode[ci] = !diffMode[ci]; updateCaseDOM(ci); }
function getDiff(txtA, txtB) {
const sA = txtA.split(/(?<=[.!?])\s+/); const sB = txtB.split(/(?<=[.!?])\s+/);
return sA.map(s => sB.some(b => b.includes(s)) ? s : `<span class="diff-removed">${s}</span>`).join(' ');
}
// Parses the Ground Truth from the system prompt
function parseFmea(p) {
const ind = (p.match(/Indikasi:\s*([\s\S]+?)(?=\nVerifikasi:|$)/) || [])[1]?.trim() || '';
const ver = (p.match(/Verifikasi:\s*([\s\S]+?)(?=\nPenyebab:|$)/) || [])[1]?.trim() || '';
const cau = (p.match(/Penyebab:\s*([\s\S]+?)(?=\nEfek:|$)/) || [])[1]?.trim() || '';
const eff = (p.match(/Efek:\s*([\s\S]+?)(?=\nRekomendasi FDT:|$)/) || [])[1]?.trim() || '';
const rec = (p.match(/Rekomendasi FDT:\s*([\s\S]+?)(?=\n\nPERTANYAAN:|$)/) || [])[1]?.trim() || '';
return { ind, ver, cau, eff, rec };
}
function esc(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') }
function md(t) { return String(t || '').replace(/\*\*(.+?)\*\*/g, '<b>$1</b>').replace(/\n/g, '<br>'); }
function getCaseHtml(ci) {
const c = cases[ci];
const st = state[ci];
const fm = parseFmea(c.prompt);
const isExactlyTwo = c.rows.length === 2 && c.rows[0] && c.rows[1];
const cols = c.rows.map((row, idx) => {
if (!row) return `<div class="resp-col"><p style="color:var(--muted);font-style:italic;padding:1rem">No Data</p></div>`;
const mName = row.model;
let rHtml = row.generated_response || '';
if (diffMode[ci] && isExactlyTwo) {
const otherHtml = c.rows[idx === 0 ? 1 : 0].generated_response || '';
rHtml = getDiff(rHtml, otherHtml);
}
const mSt = st.models[mName] || { rating: 0, comment: '', dims: {} };
const tagErr = validateTag(c.tag, row.generated_response).length > 0;
const sopOk = checkSop(row.generated_response);
const stars = [5, 4, 3, 2, 1].map(v => `<input type="radio" id="s_${ci}_${idx}_${v}" name="s_${ci}_${idx}" value="${v}" ${mSt.rating == v ? 'checked' : ''} onclick="setRating(${ci},'${mName}',${v})"><label for="s_${ci}_${idx}_${v}">★</label>`).join('');
return `<div class="resp-col ${tagErr ? 'tag-error' : ''}">
<div class="resp-header">
<span class="resp-model">Model ${String.fromCharCode(65 + idx)}</span>
<div class="sop-dots">${sopOk.map(s => `<span class="sop-dot ${s ? 'ok' : 'miss'}"></span>`).join('')}</div>
</div>
<div class="resp-body">${md(rHtml)}</div>
<div class="rlhf-form">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:0.75rem;color:var(--muted)">Rate this response:</span>
<div class="stars">${stars}</div>
</div>
<div class="dims">
${['Accuracy', 'Format', 'Detail'].map(d => `<button class="dim-btn ${mSt.dims[d] ? 'active' : ''}" onclick="toggleDim(${ci},'${mName}','${d}')">${d}</button>`).join('')}
</div>
<div class="qc-group">
<button class="qc-btn" onclick="addQc(${ci},'${mName}','Missing RCA')">+ No RCA</button>
<button class="qc-btn" onclick="addQc(${ci},'${mName}','Tag Mismatch')">+ Tag Err</button>
<button class="qc-btn" onclick="addQc(${ci},'${mName}','Perfect')">+ Perfect</button>
</div>
<textarea class="rv-comment" placeholder="Reviewer comments..." oninput="setComment(${ci},'${mName}',this.value)">${esc(mSt.comment)}</textarea>
<button class="best-btn ${st.best === mName ? 'chosen' : ''}" onclick="toggleBest(${ci},'${mName}')">${st.best === mName ? '⭐ Selected as Best' : 'Mark as Best'}</button>
</div>
</div>`;
}).join('');
return `
<div class="card-top">
<div class="card-top-row">
<span class="case-n">Case #${ci + 1}</span>
<span class="tag">${esc(c.tag)}</span>
<div class="risk-edit-grp" style="border-color:var(--${st.risk.toLowerCase()}); background:var(--${st.risk.toLowerCase()}-bg)">
<select class="risk-sel" style="color:var(--${st.risk.toLowerCase()})" onchange="setRisk(${ci},this.value)">
<option value="High" ${st.risk === 'High' ? 'selected' : ''}>High</option>
<option value="Medium" ${st.risk === 'Medium' ? 'selected' : ''}>Medium</option>
<option value="Low" ${st.risk === 'Low' ? 'selected' : ''}>Low</option>
</select>
${st.risk !== st.origRisk ? `<input type="text" class="risk-reason-inp" placeholder="Reason for change..." value="${esc(st.riskReason)}" oninput="setRiskReason(${ci},this.value)"><span class="diff-badge" title="Original risk">Orig: ${st.origRisk}</span>` : ''}
</div>
<div class="card-actions">
<button class="diff-toggle" style="display:${isExactlyTwo ? 'block' : 'none'}" onclick="toggleDiff(${ci})">${diffMode[ci] ? 'Hide Diff' : 'Show Diff'}</button>
<button class="flag-btn ${st.status === 'flagged' ? 'flagged' : ''}" onclick="toggleFlag(${ci})">🚩 Flag Issue</button>
</div>
</div>
${st.status === 'flagged' ? `<input type="text" class="flag-reason" style="display:block;width:100%;margin-bottom:0.5rem" placeholder="Why is this case flagged?" value="${esc(st.flag)}" oninput="setFlagReason(${ci},this.value)">` : ''}
</div>
<div class="gt-box">
<div class="gt-title">Ground Truth (Expected Context)</div>
<div class="gt-grid">
<div><div class="fi-key">1. Gejala / Indikasi</div><div class="fi-val">${esc(fm.ind)}</div></div>
<div><div class="fi-key">2. Verifikasi Lapangan</div><div class="fi-val">${esc(fm.ver)}</div></div>
<div><div class="fi-key">3. Akar Masalah (Penyebab)</div><div class="fi-val">${esc(fm.cau)}</div></div>
<div><div class="fi-key">4. Dampak Sistem (Efek)</div><div class="fi-val">${esc(fm.eff)}</div></div>
<div style="grid-column: 1 / -1;"><div class="fi-key">5. Rekomendasi Tindakan (FDT)</div><div class="fi-val">${esc(fm.rec)}</div></div>
</div>
</div>
<div class="card-responses" style="grid-template-columns:repeat(${c.rows.length},1fr)">${cols}</div>
`;
}
function updateCaseDOM(ci) {
const el = document.getElementById(`case-${ci}`);
if (el) el.innerHTML = getCaseHtml(ci);
}
function render() {
const q = document.getElementById('search').value.toLowerCase();
const rflt = document.getElementById('rflt').value;
const sflt = document.getElementById('sflt').value;
let html = '';
let count = 0;
cases.forEach((c, ci) => {
const st = state[ci];
if (rflt !== 'all' && st.risk !== rflt) return;
if (sflt !== 'all' && st.status !== sflt) return;
if (q && !(c.tag + c.prompt).toLowerCase().includes(q)) return;
count++;
html += `<div class="card" id="case-${ci}">${getCaseHtml(ci)}</div>`;
});
document.getElementById('results').innerHTML = html;
document.getElementById('rmeta').textContent = `Showing ${count} cases`;
}
function checkSop(t) { return SOP.map(k => k.r.test(t)); }
function validateTag(exp, t) { return [...new Set(String(t || '').match(TAG_RE) || [])].filter(x => x !== exp); }
function exportData() {
const lines = cases.map((c, i) => {
const s = state[i];
if (s.status === 'pending') return null;
return JSON.stringify({
case_index: i + 1, tag: c.tag, reviewer,
status: s.status, flag_reason: s.flag,
original_risk: s.origRisk, reviewer_risk: s.risk, risk_change_reason: s.riskReason,
best_model: s.best,
evaluations: c.rows.filter(Boolean).map(row => {
const m = row.model;
const ev = s.models[m] || {};
return { model: m, rating: ev.rating || 0, comment: ev.comment || '', dimensions: ev.dims || {}, is_best: s.best === m };
})
});
}).filter(Boolean);
if (!lines.length) { alert('No reviewed cases to export.'); return; }
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([lines.join('\n')]));
a.download = `rcfa_expert_eval_${reviewer || 'anon'}_${new Date().getTime()}.jsonl`;
a.click();
}
// Hotkeys
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'Enter') { e.preventDefault(); jumpNext(); }
});
</script>
</body>
</html>