287 lines
10 KiB
Go
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> ·
|
|
<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,'&')
|
|
.replace(/</g,'<')
|
|
.replace(/>/g,'>')
|
|
.replace(/"/g,'"');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`
|