veylant/internal/health/playground.go
2026-02-23 13:35:04 +01:00

287 lines
10 KiB
Go

package health
import "net/http"
// PlaygroundHandler serves a self-contained HTML playground page at GET /playground.
// The page lets visitors type text, submit it to POST /playground/analyze, and see
// the PII entities highlighted inline — no login required.
func PlaygroundHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// CSP relaxed for playground: allow inline scripts/styles + fetch to same origin.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(playgroundHTML))
}
const playgroundHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Veylant IA — PII Playground</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--brand: #4f46e5;
--brand-light: #e0e7ff;
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
--error: #ef4444;
--radius: 8px;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
header {
width: 100%;
background: var(--brand);
color: #fff;
padding: 1.5rem 2rem;
display: flex;
align-items: center;
gap: 1rem;
}
header h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.02em; }
header p { font-size: 0.85rem; opacity: 0.85; margin-top: 0.2rem; }
main {
width: 100%;
max-width: 860px;
padding: 2rem 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
label { font-size: 0.8rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
textarea {
width: 100%;
min-height: 140px;
margin-top: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.95rem;
line-height: 1.6;
resize: vertical;
outline: none;
font-family: inherit;
color: var(--text);
}
textarea:focus { border-color: var(--brand); }
.examples { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.75rem; }
.example-btn {
font-size: 0.78rem;
padding: 0.3rem 0.65rem;
border: 1px solid var(--border);
border-radius: 999px;
background: #fff;
cursor: pointer;
color: var(--muted);
transition: border-color 0.15s, color 0.15s;
}
.example-btn:hover { border-color: var(--brand); color: var(--brand); }
.actions { display: flex; gap: 0.75rem; margin-top: 1rem; align-items: center; }
.btn {
padding: 0.65rem 1.4rem;
border-radius: var(--radius);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: opacity 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--brand); color: #fff; }
.btn-secondary { background: var(--bg); color: var(--muted); border: 1px solid var(--border); }
.status { font-size: 0.8rem; color: var(--muted); }
/* Results */
.results { display: none; }
.results.visible { display: flex; flex-direction: column; gap: 1rem; }
.section-label { font-size: 0.75rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.5rem; }
.anonymized-box {
background: #f1f5f9;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 1rem;
font-size: 0.95rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
/* Entity badges */
.entity-list { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.entity-badge {
font-size: 0.78rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-weight: 600;
border: 1px solid;
}
/* Colour palette per entity type */
.et-PERSON { background:#fce7f3; color:#9d174d; border-color:#fbcfe8; }
.et-EMAIL_ADDRESS { background:#e0f2fe; color:#075985; border-color:#bae6fd; }
.et-PHONE_NUMBER { background:#dcfce7; color:#166534; border-color:#bbf7d0; }
.et-IBAN_CODE { background:#fef9c3; color:#713f12; border-color:#fde68a; }
.et-LOCATION { background:#ede9fe; color:#5b21b6; border-color:#ddd6fe; }
.et-ORGANIZATION { background:#ffedd5; color:#9a3412; border-color:#fed7aa; }
.et-default { background:#f1f5f9; color:#334155; border-color:#cbd5e1; }
/* No entities notice */
.empty-notice { font-size: 0.9rem; color: var(--muted); padding: 1rem 0; }
.err-msg { font-size: 0.85rem; color: var(--error); padding: 0.75rem; background:#fef2f2; border-radius:var(--radius); border:1px solid #fecaca; }
footer { font-size: 0.75rem; color: var(--muted); padding: 2rem; text-align: center; }
footer a { color: var(--brand); text-decoration: none; }
</style>
</head>
<body>
<header>
<div>
<h1>Veylant IA — PII Playground</h1>
<p>Enter text to see automatic PII detection and pseudonymization. No account required.</p>
</div>
</header>
<main>
<div class="card">
<label for="input-text">Input text</label>
<textarea id="input-text" placeholder="Type or paste text containing personal data…" spellcheck="false"></textarea>
<div class="examples">
<span style="font-size:0.78rem;color:var(--muted);align-self:center;">Try:</span>
<button class="example-btn" data-text="Bonjour, je m'appelle Jean Dupont et mon email est jean.dupont@acme.fr. Appelez-moi au +33 6 12 34 56 78.">French name + contact</button>
<button class="example-btn" data-text="Please transfer 1,500 EUR to IBAN FR76 3000 6000 0112 3456 7890 189, ref: invoice #2026-042.">IBAN + amount</button>
<button class="example-btn" data-text="The patient Alice Martin (SSN: 2 85 06 75 108 123 45) was seen at Hôpital Saint-Louis on 2026-01-15.">Medical record</button>
</div>
<div class="actions">
<button class="btn btn-primary" id="analyze-btn">Analyze</button>
<button class="btn btn-secondary" id="clear-btn">Clear</button>
<span class="status" id="status-msg"></span>
</div>
</div>
<div class="card results" id="results">
<div id="error-area"></div>
<div id="entities-area">
<div class="section-label">Detected entities</div>
<div class="entity-list" id="entity-list"></div>
</div>
<div id="anonymized-area" style="margin-top:1rem;">
<div class="section-label">Anonymized text</div>
<div class="anonymized-box" id="anonymized-text"></div>
</div>
</div>
</main>
<footer>
Veylant IA — <a href="/docs">API docs</a> &nbsp;·&nbsp;
<a href="/healthz">Health</a>
</footer>
<script>
const inputEl = document.getElementById('input-text');
const analyzeBtn = document.getElementById('analyze-btn');
const clearBtn = document.getElementById('clear-btn');
const statusEl = document.getElementById('status-msg');
const resultsEl = document.getElementById('results');
const entityList = document.getElementById('entity-list');
const anonText = document.getElementById('anonymized-text');
const errorArea = document.getElementById('error-area');
// Example buttons.
document.querySelectorAll('.example-btn').forEach(btn => {
btn.addEventListener('click', () => {
inputEl.value = btn.dataset.text;
inputEl.focus();
});
});
clearBtn.addEventListener('click', () => {
inputEl.value = '';
resultsEl.classList.remove('visible');
statusEl.textContent = '';
});
analyzeBtn.addEventListener('click', analyze);
inputEl.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'Enter') analyze();
});
async function analyze() {
const text = inputEl.value.trim();
if (!text) return;
analyzeBtn.disabled = true;
statusEl.textContent = 'Analyzing…';
errorArea.innerHTML = '';
try {
const resp = await fetch('/playground/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: { message: resp.statusText } }));
throw new Error((err.error && err.error.message) || resp.statusText);
}
const data = await resp.json();
renderResults(data);
statusEl.textContent = data.entities.length
? data.entities.length + ' entit' + (data.entities.length > 1 ? 'ies' : 'y') + ' detected'
: 'No PII detected';
} catch (err) {
errorArea.innerHTML =
'<div class="err-msg">Error: ' + escHtml(err.message) + '</div>';
resultsEl.classList.add('visible');
statusEl.textContent = '';
} finally {
analyzeBtn.disabled = false;
}
}
function renderResults(data) {
// Entities.
entityList.innerHTML = '';
if (!data.entities || data.entities.length === 0) {
entityList.innerHTML = '<span class="empty-notice">No PII entities found.</span>';
} else {
data.entities.forEach(e => {
const badge = document.createElement('span');
const cls = 'et-' + (e.type || 'default');
badge.className = 'entity-badge ' + cls;
badge.textContent = e.type + (e.value ? ': ' + e.value : '');
entityList.appendChild(badge);
});
}
// Anonymized text.
anonText.textContent = data.anonymized_text || data.anonymized || '';
resultsEl.classList.add('visible');
}
function escHtml(s) {
return String(s)
.replace(/&/g,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
</script>
</body>
</html>
`