nis2-agile/public/cross-analysis.html
DevEnv nis2-agile 19a9e5622d [FEAT] L4 AI Cross-Analysis — analisi aggregata multi-org per consulenti
- CrossAnalysisController.php: analyze/history/portfolio (k-anonymity min 2 org)
- AIService::crossOrgAnalysis(): aggregazione 9 dimensioni, zero PII nel prompt
- cross-analysis.html: chat UI purple theme, 3 tab, quick questions, portfolio stats
- index.php: routing /api/cross-analysis/{analyze,history,portfolio}
- common.js: link "AI Cross-Analysis" in sidebar sezione Gestione
- docs/AI_LEVELS_SCHEMA.md: schema architetturale L1-L5 con matrice privacy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 08:17:53 +01:00

673 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Cross-Analysis — NIS2 Agile</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
:root {
--nis2-cyan: #06B6D4;
--nis2-cyan-light: #ecfeff;
--l4-purple: #7c3aed;
--l4-purple-bg: #f5f3ff;
}
/* ── Chat container ─────────────────────────── */
.chat-wrap {
display: flex;
flex-direction: column;
height: calc(100vh - 200px);
min-height: 500px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.chat-input-area {
border-top: 1px solid var(--gray-200);
padding: 16px;
background: var(--card-bg);
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg);
}
.chat-input-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.chat-input-row textarea {
flex: 1;
min-height: 56px;
max-height: 140px;
resize: vertical;
padding: 10px 14px;
border: 1.5px solid var(--gray-300);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 0.9rem;
line-height: 1.5;
transition: border-color var(--transition);
}
.chat-input-row textarea:focus {
outline: none;
border-color: var(--l4-purple);
}
.btn-send {
background: var(--l4-purple);
color: white;
border: none;
border-radius: var(--border-radius);
padding: 14px 18px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
flex-shrink: 0;
}
.btn-send:hover:not(:disabled) { background: #6d28d9; }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Message bubbles ────────────────────────── */
.msg {
display: flex;
gap: 10px;
align-items: flex-start;
}
.msg.user { flex-direction: row-reverse; }
.msg-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
flex-shrink: 0;
}
.msg.user .msg-avatar { background: var(--l4-purple); color: white; }
.msg.ai .msg-avatar { background: var(--gray-800); color: white; }
.msg-body {
max-width: 78%;
flex: 1;
}
.msg.user .msg-body { display: flex; justify-content: flex-end; }
.msg-bubble {
padding: 10px 14px;
border-radius: var(--border-radius);
font-size: 0.88rem;
line-height: 1.55;
}
.msg.user .msg-bubble {
background: var(--l4-purple);
color: white;
border-radius: var(--border-radius) 0 var(--border-radius) var(--border-radius);
}
.msg.ai .msg-bubble {
background: var(--gray-100);
color: var(--gray-800);
border-radius: 0 var(--border-radius) var(--border-radius) var(--border-radius);
width: 100%;
}
/* ── AI response structured ─────────────────── */
.ai-answer { margin-bottom: 12px; font-size: 0.88rem; line-height: 1.6; }
.ai-section { margin-top: 12px; }
.ai-section-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-500);
margin-bottom: 5px;
}
.ai-section ul {
margin: 0; padding: 0 0 0 16px;
font-size: 0.84rem;
color: var(--gray-700);
}
.ai-section ul li { margin-bottom: 3px; }
.ai-benchmark {
background: var(--l4-purple-bg);
border-left: 3px solid var(--l4-purple);
padding: 8px 12px;
border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
font-size: 0.82rem;
color: var(--gray-700);
margin-top: 10px;
}
.ai-privacy-note {
background: #fef3c7;
border-left: 3px solid var(--warning);
padding: 6px 10px;
border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
font-size: 0.78rem;
color: #92400e;
margin-top: 8px;
}
.msg-time {
font-size: 0.68rem;
color: var(--gray-400);
margin-top: 3px;
text-align: right;
}
.msg.ai .msg-time { text-align: left; }
/* ── Thinking bubble ────────────────────────── */
.thinking-bubble {
display: flex;
gap: 5px;
padding: 12px 16px;
background: var(--gray-100);
border-radius: 0 var(--border-radius) var(--border-radius) var(--border-radius);
width: fit-content;
}
.thinking-dot {
width: 7px; height: 7px;
background: var(--gray-400);
border-radius: 50%;
animation: thinkBounce 1.2s infinite;
}
.thinking-dot:nth-child(2) { animation-delay: 0.2s; }
.thinking-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes thinkBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-8px); }
}
/* ── Portfolio summary card ─────────────────── */
.portfolio-header {
background: linear-gradient(135deg, #4c1d95, #6d28d9);
border-radius: var(--border-radius-lg);
padding: 18px 22px;
color: white;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.portfolio-stat {
text-align: center;
}
.portfolio-stat .val { font-size: 1.8rem; font-weight: 800; color: #c4b5fd; }
.portfolio-stat .lbl { font-size: 0.68rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.04em; }
/* ── Quick questions ────────────────────────── */
.quick-qs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.quick-q {
background: var(--l4-purple-bg);
color: var(--l4-purple);
border: 1px solid #ddd6fe;
border-radius: 20px;
padding: 5px 12px;
font-size: 0.77rem;
cursor: pointer;
transition: background 0.2s;
}
.quick-q:hover { background: #ede9fe; }
/* ── Privacy badge ──────────────────────────── */
.privacy-badge {
display: inline-flex;
align-items: center;
gap: 5px;
background: #d1fae5;
color: #065f46;
border-radius: 20px;
padding: 3px 10px;
font-size: 0.72rem;
font-weight: 600;
}
/* ── Empty state ────────────────────────────── */
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--gray-400);
text-align: center;
gap: 10px;
}
.chat-empty i { font-size: 2.5rem; color: #c4b5fd; }
.chat-empty h4 { color: var(--gray-600); font-size: 0.95rem; }
.chat-empty p { font-size: 0.82rem; max-width: 360px; }
/* ── Sidebar tab ────────────────────────────── */
.tab-bar { display: flex; border-bottom: 1px solid var(--gray-200); margin-bottom: 0; }
.tab-btn {
padding: 10px 18px;
border: none;
background: none;
font-size: 0.84rem;
font-weight: 600;
color: var(--gray-500);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s, border-color 0.2s;
}
.tab-btn.active { color: var(--l4-purple); border-bottom-color: var(--l4-purple); }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar"></aside>
<main class="main-content">
<header class="content-header">
<h2>
<i class="fa-solid fa-brain" style="color:var(--l4-purple);margin-right:8px;"></i>
AI Cross-Analysis
<span class="privacy-badge" style="margin-left:10px;">
<i class="fa-solid fa-lock"></i> Dati aggregati — privacy garantita
</span>
</h2>
<div class="content-header-actions">
<span class="badge" style="background:var(--l4-purple-bg);color:var(--l4-purple);font-size:0.75rem;padding:4px 10px;border-radius:12px;">
<i class="fa-solid fa-user-tie"></i> Solo Consulenti &amp; Admin
</span>
</div>
</header>
<div class="content-body">
<!-- Portfolio header -->
<div class="portfolio-header" id="portfolio-header">
<div>
<div style="font-size:0.72rem;opacity:0.6;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Il tuo portfolio</div>
<div style="font-size:1rem;font-weight:600;">Caricamento...</div>
</div>
</div>
<!-- Tabs -->
<div class="card" style="padding:0;overflow:hidden;">
<div class="tab-bar">
<button class="tab-btn active" onclick="switchTab('chat')">
<i class="fa-solid fa-message"></i> Chat AI
</button>
<button class="tab-btn" onclick="switchTab('history')">
<i class="fa-solid fa-clock-rotate-left"></i> Storico
</button>
<button class="tab-btn" onclick="switchTab('portfolio')">
<i class="fa-solid fa-chart-pie"></i> Portfolio dati
</button>
</div>
<!-- TAB: CHAT -->
<div class="tab-pane active" id="tab-chat">
<div class="chat-wrap">
<div class="chat-messages" id="chat-messages">
<div class="chat-empty" id="chat-empty">
<i class="fa-solid fa-brain"></i>
<h4>Analisi AI del tuo portfolio clienti</h4>
<p>Fai una domanda sui dati aggregati delle tue organizzazioni. I dati vengono trasmessi in forma anonima e aggregata.</p>
</div>
</div>
<div class="chat-input-area">
<div class="quick-qs" id="quick-qs">
<button class="quick-q" onclick="setQuestion('Quali sono i gap di compliance più comuni nel mio portfolio?')">Gap più frequenti</button>
<button class="quick-q" onclick="setQuestion('Qual è il livello medio di rischio nelle mie organizzazioni?')">Livello di rischio medio</button>
<button class="quick-q" onclick="setQuestion('Quali categorie NIS2 sono meno implementate?')">Categorie NIS2 deboli</button>
<button class="quick-q" onclick="setQuestion('Quali organizzazioni hanno bisogno di intervento urgente?')">Intervento urgente</button>
<button class="quick-q" onclick="setQuestion('Com\'è lo stato della formazione nei miei clienti?')">Stato formazione</button>
<button class="quick-q" onclick="setQuestion('Benchmark: dove si posiziona il mio portfolio rispetto agli standard NIS2?')">Benchmark NIS2</button>
</div>
<div class="chat-input-row">
<textarea id="chat-input" placeholder="Es: Quali sono le aree più critiche nel mio portfolio clienti?" rows="2"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage();}"></textarea>
<button class="btn-send" onclick="sendMessage()" id="btn-send">
<i class="fa-solid fa-paper-plane"></i>
</button>
</div>
<div style="font-size:0.7rem;color:var(--gray-400);margin-top:6px;text-align:center;">
<i class="fa-solid fa-shield-halved"></i>
Dati anonimizzati e aggregati — nessun nome organizzazione inviato all'AI
</div>
</div>
</div>
</div>
<!-- TAB: HISTORY -->
<div class="tab-pane" id="tab-history">
<div style="padding:16px;" id="history-content">
<div class="spinner" style="margin:30px auto;display:block;"></div>
</div>
</div>
<!-- TAB: PORTFOLIO DATI -->
<div class="tab-pane" id="tab-portfolio">
<div style="padding:16px;" id="portfolio-content">
<div class="spinner" style="margin:30px auto;display:block;"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="js/api.js"></script>
<script src="js/common.js"></script>
<script src="js/i18n.js"></script>
<script>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
let PORTFOLIO = null; // dati aggregati portfolio
// ── Init ──────────────────────────────────────────────────────────
loadPortfolio();
async function loadPortfolio() {
try {
const r = await api.get('/cross-analysis/portfolio');
if (!r.success) {
if (r.error_code === 'INSUFFICIENT_ROLE') {
document.getElementById('portfolio-header').innerHTML = `
<div style="color:white;text-align:center;width:100%;">
<i class="fa-solid fa-lock" style="font-size:1.5rem;opacity:0.6;"></i><br>
<strong>Funzione riservata a Consulenti e Super Admin</strong>
</div>`;
document.querySelector('.card').innerHTML = `
<div style="padding:40px;text-align:center;color:var(--gray-500);">
<i class="fa-solid fa-lock" style="font-size:2rem;color:var(--gray-300);"></i>
<p style="margin-top:12px;">Hai bisogno del ruolo <strong>Consultant</strong> o <strong>Super Admin</strong> per accedere all\'analisi cross-organizzazione.</p>
</div>`;
return;
}
throw new Error(r.message);
}
PORTFOLIO = r.data;
renderPortfolioHeader(PORTFOLIO);
renderPortfolioTab(PORTFOLIO);
} catch(e) {
console.error('Portfolio load error:', e);
document.getElementById('portfolio-header').innerHTML = `
<div style="color:white;opacity:0.7;">Errore caricamento portfolio</div>`;
}
}
function renderPortfolioHeader(d) {
const agg = d.aggregated || {};
const score = agg.avg_compliance_score ?? '—';
const totalRisks = agg.risks?.total_open ?? '—';
const trainPct = agg.training?.avg_completion_rate ?? '—';
document.getElementById('portfolio-header').innerHTML = `
<div>
<div style="font-size:0.72rem;opacity:0.6;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:2px;">Il tuo portfolio</div>
<div style="font-size:1.1rem;font-weight:700;">${d.org_count} organizzazioni monitorate</div>
</div>
<div class="portfolio-stat"><div class="val">${score}${score!=='—'?'%':''}</div><div class="lbl">Compliance medio</div></div>
<div class="portfolio-stat"><div class="val">${totalRisks}</div><div class="lbl">Rischi aperti</div></div>
<div class="portfolio-stat"><div class="val">${trainPct}${trainPct!=='—'?'%':''}</div><div class="lbl">Formazione media</div></div>
<div class="portfolio-stat"><div class="val">${d.org_count}</div><div class="lbl">Organizzazioni</div></div>`;
}
function renderPortfolioTab(d) {
const agg = d.aggregated;
if (!agg) {
document.getElementById('portfolio-content').innerHTML = '<p class="text-muted">Nessun dato disponibile.</p>';
return;
}
let html = '<div class="grid-2" style="gap:16px;">';
// Settori
if (agg.by_sector && Object.keys(agg.by_sector).length) {
html += `<div class="card" style="margin:0;"><div class="card-header"><h4>Distribuzione Settoriale</h4></div><div class="card-body">`;
for (const [sec, cnt] of Object.entries(agg.by_sector)) {
const pct = Math.round(cnt / d.org_count * 100);
html += `<div style="margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;font-size:0.82rem;margin-bottom:3px;">
<span>${sec}</span><span style="font-weight:600;">${cnt} (${pct}%)</span>
</div>
<div style="height:4px;background:var(--gray-200);border-radius:2px;">
<div style="height:100%;width:${pct}%;background:var(--l4-purple);border-radius:2px;"></div>
</div>
</div>`;
}
html += `</div></div>`;
}
// Compliance score
if (agg.score_distribution) {
html += `<div class="card" style="margin:0;"><div class="card-header"><h4>Distribuzione Score Compliance</h4></div><div class="card-body">`;
const colors = {'0-20%':'var(--danger)','21-40%':'#f97316','41-60%':'var(--warning)','61-80%':'#84cc16','81-100%':'var(--secondary)'};
for (const [range, cnt] of Object.entries(agg.score_distribution)) {
if (cnt === 0) continue;
html += `<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;font-size:0.82rem;">
<div style="width:8px;height:8px;border-radius:50%;background:${colors[range]||'var(--gray-400)'};flex-shrink:0;"></div>
<span style="flex:1;">${range}</span>
<strong>${cnt} org</strong>
</div>`;
}
html += `</div></div>`;
}
// Categorie NIS2 deboli
if (agg.assessments?.avg_by_category && Object.keys(agg.assessments.avg_by_category).length) {
const cats = Object.entries(agg.assessments.avg_by_category).slice(0, 5);
html += `<div class="card" style="margin:0;grid-column:1/-1;"><div class="card-header"><h4>Score Medio per Categoria NIS2 (dal più basso)</h4></div><div class="card-body">`;
html += '<div class="step-cards" style="grid-template-columns:repeat(auto-fill,minmax(180px,1fr));">';
for (const [cat, score] of cats) {
const color = score < 30 ? 'var(--danger)' : score < 60 ? 'var(--warning)' : 'var(--secondary)';
html += `<div class="step-card" style="cursor:default;">
<div style="font-size:0.75rem;color:var(--gray-500);margin-bottom:4px;">${cat}</div>
<div style="font-size:1.4rem;font-weight:800;color:${color};">${score}%</div>
<div style="height:3px;background:var(--gray-200);border-radius:2px;margin-top:6px;">
<div style="height:100%;width:${score}%;background:${color};border-radius:2px;"></div>
</div>
</div>`;
}
html += '</div></div></div>';
}
html += '</div>';
document.getElementById('portfolio-content').innerHTML = html;
}
// ── Tab switching ─────────────────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach((b, i) => {
const tabs = ['chat','history','portfolio'];
b.classList.toggle('active', tabs[i] === tab);
});
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
if (tab === 'history') loadHistory();
}
// ── Send message ──────────────────────────────────────────────────
let isThinking = false;
async function sendMessage() {
if (isThinking) return;
const input = document.getElementById('chat-input');
const question = input.value.trim();
if (!question) return;
// Rimuovi empty state
const empty = document.getElementById('chat-empty');
if (empty) empty.remove();
// Mostra messaggio utente
appendMessage('user', question);
input.value = '';
autoResizeTextarea(input);
// Thinking bubble
isThinking = true;
document.getElementById('btn-send').disabled = true;
const thinkId = 'think-' + Date.now();
appendThinking(thinkId);
try {
const r = await api.post('/cross-analysis/analyze', { question });
removeThinking(thinkId);
if (r.success && r.data?.result) {
appendAIResponse(r.data.result, r.data.org_count);
} else {
appendError(r.message || 'Errore sconosciuto');
}
} catch(e) {
removeThinking(thinkId);
appendError('Errore di connessione: ' + e.message);
} finally {
isThinking = false;
document.getElementById('btn-send').disabled = false;
input.focus();
}
}
function appendMessage(role, text) {
const msgs = document.getElementById('chat-messages');
const time = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'});
const icon = role === 'user' ? 'fa-user' : 'fa-brain';
msgs.innerHTML += `
<div class="msg ${role}">
<div class="msg-avatar"><i class="fa-solid ${icon}"></i></div>
<div class="msg-body">
<div class="msg-bubble">${escapeHtml(text)}</div>
<div class="msg-time">${time}</div>
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
function appendAIResponse(result, orgCount) {
const msgs = document.getElementById('chat-messages');
const time = new Date().toLocaleTimeString('it-IT', {hour:'2-digit',minute:'2-digit'});
let inner = `<div class="ai-answer">${escapeHtml(result.answer || 'Nessuna risposta')}</div>`;
if (result.key_findings?.length) {
inner += `<div class="ai-section">
<div class="ai-section-title"><i class="fa-solid fa-magnifying-glass"></i> Risultati chiave</div>
<ul>${result.key_findings.map(f => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
</div>`;
}
if (result.recommendations?.length) {
inner += `<div class="ai-section">
<div class="ai-section-title"><i class="fa-solid fa-lightbulb"></i> Raccomandazioni</div>
<ul>${result.recommendations.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
</div>`;
}
if (result.risk_areas?.length) {
inner += `<div class="ai-section">
<div class="ai-section-title"><i class="fa-solid fa-triangle-exclamation"></i> Aree di attenzione</div>
<ul>${result.risk_areas.map(r => `<li>${escapeHtml(r)}</li>`).join('')}</ul>
</div>`;
}
if (result.benchmark_note) {
inner += `<div class="ai-benchmark"><i class="fa-solid fa-chart-bar"></i> ${escapeHtml(result.benchmark_note)}</div>`;
}
if (result.privacy_note) {
inner += `<div class="ai-privacy-note"><i class="fa-solid fa-lock"></i> ${escapeHtml(result.privacy_note)}</div>`;
}
inner += `<div style="margin-top:8px;font-size:0.68rem;color:var(--gray-400);">Basato su ${orgCount} organizzazioni anonimizzate</div>`;
msgs.innerHTML += `
<div class="msg ai">
<div class="msg-avatar"><i class="fa-solid fa-brain"></i></div>
<div class="msg-body">
<div class="msg-bubble">${inner}</div>
<div class="msg-time">${time}</div>
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
function appendError(msg) {
const msgs = document.getElementById('chat-messages');
msgs.innerHTML += `
<div class="msg ai">
<div class="msg-avatar" style="background:var(--danger);"><i class="fa-solid fa-xmark"></i></div>
<div class="msg-body">
<div class="msg-bubble" style="background:var(--danger-bg);color:var(--danger);">
<i class="fa-solid fa-triangle-exclamation"></i> ${escapeHtml(msg)}
</div>
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
function appendThinking(id) {
const msgs = document.getElementById('chat-messages');
msgs.innerHTML += `
<div class="msg ai" id="${id}">
<div class="msg-avatar"><i class="fa-solid fa-brain"></i></div>
<div class="msg-body">
<div class="thinking-bubble">
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
</div>
</div>
</div>`;
msgs.scrollTop = msgs.scrollHeight;
}
function removeThinking(id) {
document.getElementById(id)?.remove();
}
// ── History ───────────────────────────────────────────────────────
async function loadHistory() {
const el = document.getElementById('history-content');
el.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
try {
const r = await api.get('/cross-analysis/history?limit=20');
if (!r.success) { el.innerHTML = '<p class="text-muted">Errore caricamento storico.</p>'; return; }
const rows = r.data?.history || [];
if (!rows.length) {
el.innerHTML = '<p class="text-muted" style="padding:20px;text-align:center;">Nessuna analisi precedente.</p>';
return;
}
el.innerHTML = rows.map(row => `
<div style="border-bottom:1px solid var(--gray-100);padding:12px 0;">
<div style="font-size:0.78rem;color:var(--gray-400);margin-bottom:4px;">
${new Date(row.created_at).toLocaleString('it-IT')}
</div>
<div style="font-size:0.84rem;font-weight:600;color:var(--gray-700);margin-bottom:4px;">
${escapeHtml((row.prompt_summary || '').replace(/^CROSS_ORG \[.*?\]: /, ''))}
</div>
<div style="font-size:0.8rem;color:var(--gray-500);">${escapeHtml(row.response_summary || '—')}</div>
</div>`).join('');
} catch(e) {
el.innerHTML = '<p class="text-muted">Errore: ' + escapeHtml(e.message) + '</p>';
}
}
// ── Helpers ───────────────────────────────────────────────────────
function setQuestion(q) {
document.getElementById('chat-input').value = q;
document.getElementById('chat-input').focus();
autoResizeTextarea(document.getElementById('chat-input'));
}
function autoResizeTextarea(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
document.getElementById('chat-input').addEventListener('input', function() {
autoResizeTextarea(this);
});
</script>
</body>
</html>