Prompt-Builder / web /pii-test.html
ArielJoe's picture
feat: strengthen all detectors and simplify to Indonesia-only
aa16b4b
Raw
History Blame Contribute Delete
20.4 kB
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PII Test β€” Prompt Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
fontFamily: { sans: ['"Plus Jakarta Sans"', 'Arial', 'sans-serif'] },
extend: {
colors: {
line: '#dbe3ea',
input: '#cbd5df',
teal: { DEFAULT: '#167c80', dark: '#0f5f63', light: '#dff4f5', lighter: '#e7f7f8' },
},
screens: { mobile: { max: '860px' } },
},
},
}
</script>
</head>
<body class="font-sans bg-[#f6f8fb] text-[#1f2933] min-h-screen
py-[clamp(16px,3vh,24px)] px-[clamp(16px,2.5vw,24px)] box-border">
<main class="w-full max-w-[1280px] mx-auto flex flex-col gap-4">
<!-- Header -->
<div class="flex items-baseline justify-between flex-wrap gap-3">
<div>
<h1 class="m-0 text-[28px] font-extrabold leading-tight">PII Test</h1>
<p class="mt-1 text-sm text-[#52606d]">Uji deteksi informasi pribadi dalam teks Bahasa Indonesia.</p>
</div>
<span id="statusBadge" class="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-semibold
bg-slate-100 text-slate-500 border border-slate-200">
<span id="statusDot" class="w-2 h-2 rounded-full bg-slate-400"></span>
<span id="statusText">Menghubungi server…</span>
</span>
</div>
<!-- Grid dua kolom -->
<div class="grid grid-cols-2 gap-6 mobile:grid-cols-1">
<!-- ── Panel Kiri: Input ── -->
<section class="bg-white border border-line rounded-lg p-5 flex flex-col gap-4">
<label class="text-sm font-bold m-0" for="piiInput">Teks Input</label>
<textarea id="piiInput" class="w-full box-border px-[10px] py-[10px] border border-input rounded-[6px]
font-[inherit] text-base resize-none leading-[1.55]
focus:outline-none focus:border-teal focus:ring-[3px] focus:ring-teal/[.14]" rows="10"
placeholder="Tempel teks untuk mencari data pribadi seperti NIK, email, nomor HP, alamat, atau rekening.&#10;&#10;Contoh:&#10;Nama: Budi Santoso, NIK 3273011203980001&#10;email budi@perusahaan.co.id, HP 0812-3456-7890"></textarea>
<!-- Slider confidence -->
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<label class="text-xs font-bold text-[#52606d] uppercase tracking-[0.5px]" for="confSlider">
Minimum Confidence
</label>
<span id="confValue" class="text-xs font-bold text-teal tabular-nums">0.70</span>
</div>
<input id="confSlider" type="range" min="0" max="100" value="70" step="5"
class="w-full accent-teal h-1.5 rounded-full cursor-pointer">
<p class="text-xs text-[#52606d]">Entitas dengan skor di bawah nilai ini diabaikan.</p>
</div>
<!-- Tombol aksi -->
<div class="flex gap-2 flex-wrap">
<button id="detectBtn" class="px-4 py-[10px] bg-teal text-white rounded-[6px] font-bold border-0
cursor-pointer hover:bg-teal-dark transition-colors duration-150" type="button">Deteksi PII</button>
<button id="sampleBtn" class="px-4 py-[10px] bg-teal-light text-teal rounded-[6px] font-bold border-0
cursor-pointer hover:bg-teal-lighter transition-colors duration-150" type="button">Isi
Contoh</button>
<button id="clearBtn" class="px-4 py-[10px] bg-white text-[#52606d] rounded-[6px] font-bold
border border-line cursor-pointer hover:bg-[#f6f8fb] transition-colors duration-150"
type="button">Hapus</button>
</div>
<!-- Legenda -->
<div>
<p class="text-xs font-bold text-[#52606d] uppercase tracking-[0.5px] mb-2">Legenda Tipe PII</p>
<div id="legend" class="flex flex-wrap gap-[6px] text-xs font-semibold leading-[2]"></div>
</div>
</section>
<!-- ── Panel Kanan: Output ── -->
<section class="bg-white border border-line rounded-lg p-5 flex flex-col gap-5">
<!-- Tab navigasi -->
<div class="flex gap-0 border-b border-line -mx-5 px-5">
<button data-tab="highlight"
class="tab-btn px-4 py-2 text-sm font-bold border-b-2 border-teal text-teal -mb-px transition-colors"
type="button">Teks Ternotasi</button>
<button data-tab="redacted" class="tab-btn px-4 py-2 text-sm font-bold border-b-2 border-transparent text-[#52606d] -mb-px
hover:text-[#1f2933] transition-colors" type="button">Teks Tersunting</button>
<button data-tab="entities" class="tab-btn px-4 py-2 text-sm font-bold border-b-2 border-transparent text-[#52606d] -mb-px
hover:text-[#1f2933] transition-colors" type="button">Daftar Entitas</button>
</div>
<!-- Tab: Teks Ternotasi -->
<div id="tab-highlight" class="tab-panel flex flex-col gap-3">
<div id="highlightBox" class="min-h-[120px] p-4 bg-[#fbfdff] border border-line rounded-[6px]
leading-[2.1] text-base [overflow-wrap:anywhere]">
<span class="text-[#52606d] text-sm italic">Hasil highlight akan muncul di sini.</span>
</div>
</div>
<!-- Tab: Teks Tersunting -->
<div id="tab-redacted" class="tab-panel hidden flex-col gap-3">
<div class="flex justify-between items-center">
<p class="text-xs text-[#52606d] m-0">Semua PII diganti dengan placeholder bernomor.</p>
<button id="copyRedacted" class="text-xs font-bold text-teal hover:text-teal-dark transition-colors"
type="button">Salin</button>
</div>
<pre id="redactedBox"
class="m-0 p-4 bg-[#fbfdff] border border-line rounded-[6px] text-sm
leading-[1.7] [overflow-wrap:anywhere] whitespace-pre-wrap
font-[inherit] text-[#1f2933]"><span class="text-[#52606d] italic">Teks tersunting akan muncul di sini.</span></pre>
</div>
<!-- Tab: Daftar Entitas -->
<div id="tab-entities" class="tab-panel hidden flex-col gap-3">
<p id="entitySummary" class="text-xs text-[#52606d] m-0"></p>
<div id="entityList" class="flex flex-col gap-4"></div>
<p id="noEntities" class="text-sm text-[#52606d] italic hidden">Tidak ada PII terdeteksi.</p>
</div>
<!-- Error box -->
<div id="errorBox" hidden
class="p-4 bg-red-50 border border-red-200 rounded-[6px] text-sm text-red-700 font-medium"></div>
</section>
</div>
</main>
<script>
"use strict";
const API_BASE = "http://127.0.0.1:8002";
const SAMPLES = [
"Nama: Budi Santoso, NIK 3273011203980001, lahir di Bandung.",
"NPWP: 01.234.567.4-901.000 β€” hubungi budi@perusahaan.co.id",
"Rekening: 1234567890 BCA, HP 0812-3456-7890 atau +6281298765432.",
"Plat kendaraan B 1234 ABC, IP server: 192.168.1.254.",
"Kartu kredit Visa 4111 1111 1111 1111 atas nama Ibu Sari.",
"Alamat: Jl. Merdeka No. 45, RT 03/RW 07, Kel. Sukamaju, Kec. Cibeunying.",
"Paspor A1234567, SIM No. 123456789012, BPJS Kesehatan 0001234567890.",
"Tgl. Lahir: 15 Januari 1990, nomor tetap (021) 55667788.",
].join("\n");
// Warna per tipe PII β€” berbeda dari NER agar mudah dibedakan
const PII_COLORS = {
NIK: { bg: "#fee2e2", color: "#7f1d1d", border: "#fca5a5", label: "NIK" },
NPWP_LAMA: { bg: "#ffedd5", color: "#7c2d12", border: "#fdba74", label: "NPWP (lama)" },
NPWP_BARU: { bg: "#fef3c7", color: "#78350f", border: "#fcd34d", label: "NPWP (baru)" },
BPJS_KES: { bg: "#fce7f3", color: "#831843", border: "#f9a8d4", label: "BPJS Kes." },
BPJS_TK: { bg: "#fdf2f8", color: "#701a75", border: "#f0abfc", label: "BPJS TK" },
PASPOR: { bg: "#ede9fe", color: "#4c1d95", border: "#c4b5fd", label: "Paspor" },
SIM: { bg: "#e0e7ff", color: "#1e1b4b", border: "#a5b4fc", label: "SIM" },
PLAT_NOMOR: { bg: "#d1fae5", color: "#064e3b", border: "#6ee7b7", label: "Plat Nomor" },
EMAIL: { bg: "#dbeafe", color: "#1e3a8a", border: "#93c5fd", label: "Email" },
TELEPON_HP: { bg: "#cffafe", color: "#164e63", border: "#67e8f9", label: "Telepon HP" },
TELEPON_TETAP: { bg: "#e0f2fe", color: "#0c4a6e", border: "#7dd3fc", label: "Telepon Tetap" },
REKENING_BANK: { bg: "#ccfbf1", color: "#134e4a", border: "#5eead4", label: "Rekening Bank" },
KARTU_KREDIT: { bg: "#f3e8ff", color: "#581c87", border: "#d8b4fe", label: "Kartu Kredit" },
IP_V4: { bg: "#f0fdf4", color: "#14532d", border: "#86efac", label: "IPv4" },
IP_V6: { bg: "#ecfdf5", color: "#065f46", border: "#6ee7b7", label: "IPv6" },
TANGGAL_LAHIR: { bg: "#f8fafc", color: "#1e293b", border: "#cbd5e1", label: "Tgl. Lahir" },
ALAMAT: { bg: "#fafaf9", color: "#292524", border: "#d6d3d1", label: "Alamat" },
};
const DEFAULT_COLOR = { bg: "#f3f4f6", color: "#374151", border: "#d1d5db", label: "Lainnya" };
// ── DOM refs ───────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const piiInput = $("piiInput");
const detectBtn = $("detectBtn");
const sampleBtn = $("sampleBtn");
const clearBtn = $("clearBtn");
const confSlider = $("confSlider");
const confValue = $("confValue");
const highlightBox = $("highlightBox");
const redactedBox = $("redactedBox");
const copyRedacted = $("copyRedacted");
const entityList = $("entityList");
const entitySummary = $("entitySummary");
const noEntities = $("noEntities");
const errorBox = $("errorBox");
const statusBadge = $("statusBadge");
const statusDot = $("statusDot");
const statusText = $("statusText");
const legend = $("legend");
// ── Tab logic ──────────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll(".tab-panel").forEach(p => p.classList.add("hidden"));
document.querySelectorAll(".tab-btn").forEach(b => {
const active = b.dataset.tab === name;
b.classList.toggle("border-teal", active);
b.classList.toggle("text-teal", active);
b.classList.toggle("border-transparent", !active);
b.classList.toggle("text-[#52606d]", !active);
});
const panel = $("tab-" + name);
panel.classList.remove("hidden");
panel.classList.add("flex");
}
document.querySelectorAll(".tab-btn").forEach(b =>
b.addEventListener("click", () => switchTab(b.dataset.tab))
);
// ── Legenda ────────────────────────────────────────────────
(function buildLegend() {
for (const [key, c] of Object.entries(PII_COLORS)) {
const el = document.createElement("span");
el.style.cssText = `background:${c.bg};color:${c.color};border:1px solid ${c.border};
border-radius:4px;padding:2px 8px;`;
el.textContent = c.label;
legend.appendChild(el);
}
})();
// ── Server status ──────────────────────────────────────────
async function checkStatus() {
try {
const res = await fetch(API_BASE + "/api/status", { signal: AbortSignal.timeout(3000) });
if (!res.ok) throw new Error();
statusDot.className = "w-2 h-2 rounded-full bg-emerald-500";
statusText.textContent = "Server siap";
statusBadge.className = statusBadge.className
.replace(/bg-\S+\s+text-\S+\s+border-\S+/, "")
.trim() + " bg-emerald-50 text-emerald-700 border-emerald-200";
} catch {
statusDot.className = "w-2 h-2 rounded-full bg-red-500";
statusText.textContent = "Server tidak terjangkau";
statusBadge.className = statusBadge.className
.replace(/bg-\S+\s+text-\S+\s+border-\S+/, "")
.trim() + " bg-red-50 text-red-700 border-red-200";
setTimeout(checkStatus, 5000);
}
}
// ── HTML escape ────────────────────────────────────────────
function esc(s) {
return String(s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── Highlight builder ──────────────────────────────────────
function buildHighlight(text, entities) {
if (!entities.length) {
return '<span class="text-[#52606d] text-sm italic">Tidak ada PII terdeteksi.</span>';
}
const sorted = [...entities].sort((a, b) => a.start - b.start);
let html = "", cursor = 0;
for (const e of sorted) {
if (e.start < cursor) continue;
html += esc(text.slice(cursor, e.start));
const c = PII_COLORS[e.label] || DEFAULT_COLOR;
const pct = Math.round(e.confidence * 100);
html += `<mark
style="background:${c.bg};color:${c.color};border:1px solid ${c.border};
border-radius:4px;padding:1px 6px;cursor:default;white-space:nowrap;"
title="${esc(c.label)} β€” ${pct}% kepercayaan"
>${esc(e.word)}<sup style="font-size:9px;margin-left:3px;opacity:.75;font-weight:700;"
>${esc(e.label)}</sup></mark>`;
cursor = e.end;
}
html += esc(text.slice(cursor));
return html;
}
// ── Daftar entitas per grup ────────────────────────────────
function buildEntityList(entities) {
entityList.innerHTML = "";
// Kelompokkan per label
const grouped = {};
for (const e of entities) {
(grouped[e.label] ??= []).push(e);
}
const types = Object.keys(grouped).sort();
for (const label of types) {
const items = grouped[label];
const c = PII_COLORS[label] || DEFAULT_COLOR;
const section = document.createElement("div");
// Header
const header = document.createElement("div");
header.className = "flex items-center justify-between mb-1";
header.innerHTML = `
<span class="text-xs font-bold text-[#1f2933]">${esc(c.label)}</span>
<span class="text-xs text-[#52606d] font-semibold">${items.length} entitas</span>`;
section.appendChild(header);
// Badge per entitas
const wrap = document.createElement("div");
wrap.style.lineHeight = "2.4";
for (const e of items) {
const badge = document.createElement("span");
badge.style.cssText = `
display:inline-block;margin:2px 4px;padding:3px 9px;
background:${c.bg};color:${c.color};border:1px solid ${c.border};
border-radius:5px;font-size:13px;font-weight:600;cursor:default;`;
badge.title = `Skor: ${Math.round(e.confidence * 100)}%\nKonteks: …${e.context}…`;
badge.textContent = e.word;
// Meta NIK: tampilkan info tanggal lahir
if (e.label === "NIK" && e.meta && e.meta.day) {
const gender = e.meta.is_female ? "P" : "L";
const info = document.createElement("sup");
info.style.cssText = "font-size:9px;margin-left:4px;opacity:.7;";
info.textContent = `${gender} ${String(e.meta.day).padStart(2, "0")}/${String(e.meta.month).padStart(2, "0")}/${e.meta.year_suffix}`;
badge.appendChild(info);
}
wrap.appendChild(badge);
}
section.appendChild(wrap);
entityList.appendChild(section);
const hr = document.createElement("hr");
hr.className = "border-line";
entityList.appendChild(hr);
}
entityList.lastElementChild?.remove(); // hapus HR terakhir
}
// ── Deteksi ────────────────────────────────────────────────
async function detect() {
const text = piiInput.value.trim();
if (!text) return;
detectBtn.disabled = true;
detectBtn.textContent = "Mendeteksi…";
errorBox.hidden = true;
const minConf = parseFloat(confSlider.value) / 100;
try {
const res = await fetch(API_BASE + "/api/pii", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, min_confidence: minConf }),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const entities = data.entities || [];
// Tab highlight
highlightBox.innerHTML = buildHighlight(text, entities);
// Tab redacted
redactedBox.textContent = data.redacted || text;
// Tab entities
if (entities.length) {
const typeCount = new Set(entities.map(e => e.label)).size;
entitySummary.textContent =
`${entities.length} entitas terdeteksi dari ${typeCount} tipe PII.`;
buildEntityList(entities);
noEntities.classList.add("hidden");
} else {
entitySummary.textContent = "";
entityList.innerHTML = "";
noEntities.classList.remove("hidden");
}
switchTab("highlight");
} catch (err) {
errorBox.textContent = `Gagal menghubungi server PII: ${err.message}`;
errorBox.hidden = false;
} finally {
detectBtn.disabled = false;
detectBtn.textContent = "Deteksi PII";
checkStatus();
}
}
// ── Salin teks tersunting ─────────────────────────────────
copyRedacted.addEventListener("click", () => {
const text = redactedBox.textContent;
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(() => {
copyRedacted.textContent = "Disalin!";
setTimeout(() => { copyRedacted.textContent = "Salin"; }, 1500);
});
}
});
// ── Slider confidence ─────────────────────────────────────
confSlider.addEventListener("input", () => {
confValue.textContent = (confSlider.value / 100).toFixed(2);
});
// ── Event binding ──────────────────────────────────────────
detectBtn.addEventListener("click", detect);
piiInput.addEventListener("keydown", e => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) detect();
});
sampleBtn.addEventListener("click", () => { piiInput.value = SAMPLES; });
clearBtn.addEventListener("click", () => {
piiInput.value = "";
highlightBox.innerHTML =
'<span class="text-[#52606d] text-sm italic">Hasil highlight akan muncul di sini.</span>';
redactedBox.innerHTML =
'<span class="text-[#52606d] italic">Teks tersunting akan muncul di sini.</span>';
entityList.innerHTML = "";
entitySummary.textContent = "";
noEntities.classList.add("hidden");
errorBox.hidden = true;
});
// ── Init ───────────────────────────────────────────────────
checkStatus();
switchTab("highlight");
</script>
</body>
</html>