55 lines
20 KiB
HTML
55 lines
20 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark">
|
||
<title>GLPI AI Agent · Entscheidungsdiagnose</title>
|
||
<style>
|
||
:root{color-scheme:dark;--bg:#07101b;--panel:#0e1a2a;--panel2:#122238;--line:#243a55;--text:#eef6ff;--muted:#91a6be;--blue:#66a0ff;--green:#44d69b;--amber:#f3bd62;--red:#ff7878;--violet:#9b83ff;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 70% -10%,rgba(90,130,255,.16),transparent 35%),var(--bg);color:var(--text)}button,input{font:inherit}.shell{min-height:100vh;display:grid;grid-template-rows:auto 1fr}.top{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:18px 24px;border-bottom:1px solid var(--line);background:rgba(7,16,27,.92);position:sticky;top:0;z-index:9;backdrop-filter:blur(14px)}.brand{display:flex;align-items:center;gap:12px}.mark{width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,#4f82f4,#835be8);display:grid;place-items:center;font-weight:800}.title{font-weight:780}.sub{font-size:12px;color:var(--muted);margin-top:2px}.btn{border:1px solid var(--line);background:var(--panel2);color:var(--text);padding:8px 11px;border-radius:9px;cursor:pointer;text-decoration:none}.btn:hover{border-color:#3d6088}.layout{display:grid;grid-template-columns:330px minmax(0,1fr);min-height:0}.sidebar{border-right:1px solid var(--line);background:rgba(9,19,32,.72);min-height:calc(100vh - 75px);padding:16px;position:sticky;top:75px;height:calc(100vh - 75px);overflow:auto}.search{width:100%;border:1px solid var(--line);background:#091523;color:var(--text);border-radius:10px;padding:10px 11px;outline:none}.search:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(102,160,255,.12)}.runlist{display:grid;gap:8px;margin-top:13px}.run{border:1px solid var(--line);background:rgba(14,26,42,.8);border-radius:11px;padding:11px;cursor:pointer}.run:hover,.run.active{border-color:#4771a3;background:#12243b}.run-title{font-weight:680;font-size:13px}.run-meta{color:var(--muted);font-size:11px;margin-top:5px;display:flex;justify-content:space-between;gap:10px}.main{padding:24px;max-width:1580px;width:100%;margin:0 auto}.hero{display:flex;justify-content:space-between;gap:18px;align-items:flex-start;margin-bottom:18px}.eyebrow{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--blue);font-weight:750}.hero h1{font-size:28px;margin:4px 0 6px;letter-spacing:-.035em}.muted{color:var(--muted)}.chips{display:flex;gap:7px;flex-wrap:wrap}.chip{display:inline-flex;gap:6px;align-items:center;padding:5px 9px;border:1px solid var(--line);border-radius:999px;color:#c8d6e6;font-size:11px;background:rgba(18,34,56,.8)}.chip.good{color:#a7f0d1;border-color:rgba(68,214,155,.35);background:rgba(68,214,155,.08)}.chip.bad{color:#ffb7b7;border-color:rgba(255,120,120,.35);background:rgba(255,120,120,.08)}.chip.warn{color:#f8d99d;border-color:rgba(243,189,98,.35);background:rgba(243,189,98,.08)}.chip.info{color:#c8d7ff;border-color:rgba(102,160,255,.35);background:rgba(102,160,255,.08)}.summary{display:grid;grid-template-columns:repeat(4,minmax(160px,1fr));gap:12px;margin-bottom:16px}.card,.panel{border:1px solid var(--line);background:linear-gradient(180deg,rgba(16,30,49,.96),rgba(11,22,37,.96));border-radius:14px}.card{padding:14px}.label{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.value{font-size:21px;font-weight:760;margin-top:8px}.foot{font-size:11px;color:var(--muted);margin-top:4px}.grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.panel-head{padding:14px 16px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px}.panel-title{font-size:14px;font-weight:720}.panel-sub{font-size:11px;color:var(--muted);margin-top:2px}.panel-body{padding:14px 16px}.rule{display:grid;grid-template-columns:26px minmax(0,1fr) auto;gap:10px;padding:10px 0;border-bottom:1px solid rgba(36,58,85,.65);align-items:start}.rule:last-child{border-bottom:0}.dot{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:11px;font-weight:800;margin-top:1px}.dot.pass{background:rgba(68,214,155,.14);color:var(--green)}.dot.fail{background:rgba(255,120,120,.14);color:var(--red)}.dot.warn{background:rgba(243,189,98,.13);color:var(--amber)}.dot.info{background:rgba(102,160,255,.13);color:var(--blue)}.dot.na{background:rgba(145,166,190,.12);color:var(--muted)}.rule-name{font-size:12px;font-weight:650}.rule-detail{font-size:11px;color:var(--muted);line-height:1.5;margin-top:3px}.rule-values{text-align:right;font-size:10px;color:var(--muted);min-width:135px}.rule-values strong{display:block;color:#d9e5f4;font-size:11px}.section{margin-top:14px}.kbtoolbar{display:flex;gap:9px;align-items:center}.kbtoolbar .search{max-width:520px}.results{position:absolute;background:#0c1828;border:1px solid var(--line);border-radius:10px;box-shadow:0 20px 45px rgba(0,0,0,.35);width:min(620px,calc(100vw - 40px));max-height:330px;overflow:auto;z-index:20;margin-top:4px}.result{padding:10px 11px;border-bottom:1px solid var(--line);cursor:pointer}.result:hover{background:#13243a}.result:last-child{border-bottom:0}.scoregrid{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr));gap:8px;margin:11px 0}.scorebox{border:1px solid var(--line);border-radius:10px;padding:9px;background:#091522}.scorebox .n{font-weight:750;font-size:17px;margin-top:4px}.bar{height:6px;background:#07101b;border:1px solid rgba(36,58,85,.65);border-radius:999px;overflow:hidden;margin-top:7px}.bar i{display:block;height:100%;background:linear-gradient(90deg,#557ff0,#64c7e9)}.kbmeta{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 12px}.excerpt{border:1px solid var(--line);background:#08131f;border-radius:9px;padding:10px;font-size:11px;line-height:1.5;color:#bacadc;white-space:pre-wrap}.candidate-table{width:100%;border-collapse:collapse}.candidate-table th,.candidate-table td{font-size:11px;text-align:left;padding:9px;border-bottom:1px solid var(--line)}.candidate-table th{color:var(--muted);text-transform:uppercase;letter-spacing:.05em}.candidate-row{cursor:pointer}.candidate-row:hover{background:rgba(102,160,255,.05)}.empty{padding:32px;text-align:center;color:var(--muted)}.warning{border-left:3px solid var(--amber);padding:10px 12px;border-radius:8px;background:rgba(243,189,98,.08);font-size:11px;line-height:1.55;color:#edd7ab;margin:10px 0}.raw{font:11px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;background:#06101b;border:1px solid var(--line);border-radius:9px;padding:10px;max-height:260px;overflow:auto;color:#b8c9da}.hidden{display:none!important}
|
||
@media(max-width:1050px){.layout{grid-template-columns:1fr}.sidebar{position:static;height:auto;min-height:0;border-right:0;border-bottom:1px solid var(--line)}.runlist{grid-template-columns:repeat(auto-fill,minmax(230px,1fr));max-height:300px;overflow:auto}.summary{grid-template-columns:repeat(2,1fr)}.grid2{grid-template-columns:1fr}}@media(max-width:620px){.top,.main{padding:14px}.summary{grid-template-columns:1fr}.scoregrid{grid-template-columns:repeat(2,1fr)}.hero{flex-direction:column}.rule{grid-template-columns:24px 1fr}.rule-values{grid-column:2;text-align:left}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="shell">
|
||
<header class="top"><div class="brand"><div class="mark">AI</div><div><div class="title">Entscheidungsdiagnose</div><div class="sub">Regeln, Retrieval und Auto-Reply nachvollziehbar prüfen</div></div></div><div style="display:flex;gap:8px"><a class="btn" href="/">← Control Center</a><button class="btn" id="refreshBtn">Aktualisieren</button></div></header>
|
||
<div class="layout">
|
||
<aside class="sidebar"><input class="search" id="runSearch" placeholder="Ticket, Run-ID, Entscheidung …"><div class="runlist" id="runList"><div class="empty">Lade Läufe …</div></div></aside>
|
||
<main class="main">
|
||
<div id="emptyState" class="empty"><h2>Ticketlauf auswählen</h2><p>Links einen Lauf wählen, um alle Entscheidungsregeln und Knowledge-Kandidaten zu prüfen.</p></div>
|
||
<div id="content" class="hidden">
|
||
<div class="hero"><div><div class="eyebrow">Policy Replay</div><h1 id="ticketTitle">–</h1><div class="muted" id="runMeta">–</div></div><div class="chips" id="heroChips"></div></div>
|
||
<div class="summary" id="summary"></div>
|
||
<div class="grid2">
|
||
<section class="panel"><div class="panel-head"><div><div class="panel-title">Kategorieentscheidung</div><div class="panel-sub">Deterministische Gates vor einer GLPI-Änderung</div></div></div><div class="panel-body" id="categoryRules"></div></section>
|
||
<section class="panel"><div class="panel-head"><div><div class="panel-title">Auto-Reply-Regelwerk</div><div class="panel-sub">Alle Bedingungen, auch nach dem ersten Blocker</div></div></div><div class="panel-body" id="replyRules"></div></section>
|
||
</div>
|
||
<section class="panel section"><div class="panel-head"><div><div class="panel-title">Ausführung & Race-Protection</div><div class="panel-sub">Followups, Dry-Run und Schreibschutz</div></div></div><div class="panel-body" id="executionRules"></div></section>
|
||
<section class="panel section"><div class="panel-head"><div><div class="panel-title">Knowledge-Kandidaten des Laufs</div><div class="panel-sub">Warum ein Artikel an die KI ging – oder nur im Audit blieb</div></div></div><div class="panel-body"><div id="candidateInfo" class="muted" style="font-size:11px;margin-bottom:10px"></div><div style="overflow:auto"><table class="candidate-table"><thead><tr><th>Rang</th><th>Artikel</th><th>Retrieval</th><th>Semantik</th><th>Titel</th><th>Lexikalisch</th><th>Status</th></tr></thead><tbody id="candidateRows"></tbody></table></div></div></section>
|
||
<section class="panel section"><div class="panel-head"><div><div class="panel-title">Beliebige KB prüfen</div><div class="panel-sub">Auch Artikel untersuchen, die nicht in den Audit-Top-K lagen</div></div></div><div class="panel-body"><div class="kbtoolbar"><div style="position:relative;flex:1"><input class="search" id="kbSearch" placeholder="KB-ID, Titel, Keyword oder externe Kategorie …"><div id="kbResults" class="results hidden"></div></div></div><div id="kbDiagnostic" class="empty">Knowledge-Artikel suchen oder oben einen Kandidaten anklicken.</div></div></section>
|
||
<section class="panel section"><div class="panel-head"><div><div class="panel-title">KI-Begründung & Policy-Code</div><div class="panel-sub">Freitext ist nur Erklärung; maßgeblich sind die Regeln oben</div></div></div><div class="panel-body"><div class="grid2"><div><div class="label">KI-Begründung</div><div class="raw" id="aiReason"></div></div><div><div class="label">Policy</div><div class="raw" id="policyReason"></div><div class="label" style="margin-top:10px">Fehler</div><div class="raw" id="errorText"></div></div></div></div></section>
|
||
</div>
|
||
</main></div></div>
|
||
<script>
|
||
'use strict';
|
||
const $=q=>document.querySelector(q), esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const pct=v=>`${(Number(v||0)*100).toLocaleString('de-DE',{maximumFractionDigits:1})} %`;const date=v=>v?new Date(v).toLocaleString('de-DE'):'–';
|
||
let runs=[],current=null,kbTimer=null;
|
||
async function api(u){const r=await fetch(u,{headers:{'X-Requested-With':'GLPI-AI-Agent'}});if(!r.ok)throw new Error((await r.text()).trim()||`HTTP ${r.status}`);return r.json()}
|
||
function statusChip(text,kind=''){return `<span class="chip ${kind}">${esc(text)}</span>`}
|
||
function icon(s){return s==='pass'?'✓':s==='fail'?'×':s==='warn'?'!':s==='info'?'i':'–'}
|
||
function ruleHTML(r){return `<div class="rule"><div class="dot ${esc(r.status||'na')}">${icon(r.status)}</div><div><div class="rule-name">${esc(r.label||r.code)}</div>${r.detail?`<div class="rule-detail">${esc(r.detail)}</div>`:''}${r.blocking?'<div class="rule-detail" style="color:var(--red)">Blockierende Bedingung</div>':''}</div><div class="rule-values"><strong>${esc(r.actual||'–')}</strong>${r.expected?`Soll: ${esc(r.expected)}`:''}</div></div>`}
|
||
function renderRules(el,rows){$(el).innerHTML=rows?.length?rows.map(ruleHTML).join(''):'<div class="empty">Für diesen älteren Lauf wurden noch keine strukturierten Regelchecks gespeichert.</div>'}
|
||
function filterRuns(){const q=$('#runSearch').value.trim().toLowerCase();const rows=runs.filter(r=>!q||[r.ticket_id,r.ticket_name,r.run_id,r.category_decision,r.reply_decision,r.policy_reason].join(' ').toLowerCase().includes(q));$('#runList').innerHTML=rows.map(r=>`<div class="run ${current?.run_id===r.run_id?'active':''}" data-run="${esc(r.run_id)}"><div class="run-title">#${r.ticket_id} ${esc(r.ticket_name||'')}</div><div class="run-meta"><span>${esc(date(r.finished_at))}</span><span>${esc(r.outcome)}</span></div><div style="margin-top:7px">${statusChip(r.category_decision||'–',r.category_changed?'good':'info')} ${statusChip(r.reply_decision||'–',r.reply_written?'good':r.reply_proposed?'info':'warn')}</div></div>`).join('')||'<div class="empty">Keine Läufe.</div>';document.querySelectorAll('[data-run]').forEach(x=>x.onclick=()=>loadRun(x.dataset.run))}
|
||
async function loadRuns(){runs=await api('/api/runs?limit=200');filterRuns();if(!current&&runs.length)loadRun(runs[0].run_id)}
|
||
async function loadRun(id){current=await api(`/api/diagnostics/run/${encodeURIComponent(id)}`);$('#emptyState').classList.add('hidden');$('#content').classList.remove('hidden');filterRuns();renderRun()}
|
||
function renderRun(){const r=current;$('#ticketTitle').textContent=`#${r.ticket_id} ${r.ticket_name||''}`;$('#runMeta').textContent=`Run ${r.run_id} · ${date(r.finished_at)} · ${r.outcome}`;$('#heroChips').innerHTML=[statusChip(r.dry_run?'DRY RUN':'LIVE',r.dry_run?'warn':'good'),statusChip(r.category_decision||'Kategorie –','info'),statusChip(r.reply_decision||'Reply –',r.reply_written?'good':r.reply_proposed?'info':'warn')].join('');
|
||
const cards=[['Kategorie',r.ai_recommended_category_name?`${r.ai_recommended_category_name} · ${pct(r.ai_category_confidence)}`:'keine Empfehlung',`Schwellwert ${pct(r.category_threshold)}`],['Knowledge',r.knowledge_top_title||'kein Treffer',r.knowledge_top_id?`Retrieval ${pct(r.knowledge_score)} · Evidenz ${pct(r.knowledge_evidence_score)}`:'–'],['Auto-Reply',r.ai_reply_recommended?`KI: Ja · ${pct(r.ai_reply_confidence)}`:'KI: Nein',r.reply_decision||'–'],['Kontext',`C:${r.context_changes||0} I:${r.context_incidents||0} U:${r.context_issues||0} D:${r.context_devices||0}`,(r.context_warnings||[]).length?`${r.context_warnings.length} Warnungen`:'vollständig']];$('#summary').innerHTML=cards.map(c=>`<div class="card"><div class="label">${esc(c[0])}</div><div class="value">${esc(c[1])}</div><div class="foot">${esc(c[2])}</div></div>`).join('');
|
||
renderRules('#categoryRules',r.category_checks);renderRules('#replyRules',r.reply_checks);renderRules('#executionRules',r.execution_checks);
|
||
$('#candidateInfo').textContent=`An KI: ${r.knowledge_llm_candidates||0} · Cutoff ${pct(r.knowledge_candidate_cutoff)} · Max. Abstand ${pct(r.knowledge_candidate_max_gap)} · Audit Top K ${r.knowledge_audit_top_k||0}`;
|
||
const cs=r.knowledge_candidates||[];$('#candidateRows').innerHTML=cs.map((c,i)=>`<tr class="candidate-row" data-kb="${esc(c.id)}"><td>${c.retrieval_rank||i+1}</td><td><strong>${esc(c.title)}</strong><div class="muted">${esc(c.id)} · ${esc(c.source)}</div></td><td>${pct(c.score)}</td><td>${pct(c.semantic_score)}</td><td>${pct(c.title_score)}</td><td>${pct(c.lexical_score)}</td><td>${c.sent_to_ai?statusChip('an KI','good'):statusChip(c.selection_reason||'nur Audit','warn')}</td></tr>`).join('')||'<tr><td colspan="7" class="empty">Keine Kandidaten im Audit.</td></tr>';document.querySelectorAll('[data-kb]').forEach(x=>x.onclick=()=>diagnoseKB(x.dataset.kb));$('#aiReason').textContent=r.ai_reason||r.reason||'–';$('#policyReason').textContent=r.policy_reason||'–';$('#errorText').textContent=r.error||'–';$('#kbDiagnostic').innerHTML='<div class="empty">Knowledge-Artikel suchen oder einen Kandidaten anklicken.</div>'}
|
||
function selectionText(code){return({sent_to_ai:'An die KI gesendet',below_retrieval_floor:'Unter Retrieval-Floor',outside_candidate_gap:'Außerhalb des dynamischen Abstands',max_candidates_reached:'Maximale Kandidatenzahl erreicht',not_selected:'Nicht ausgewählt'})[code]||code||'–'}
|
||
async function diagnoseKB(id){if(!current)return;$('#kbDiagnostic').innerHTML='<div class="empty">Berechne Diagnose …</div>';try{const d=await api(`/api/diagnostics/run/${encodeURIComponent(current.run_id)}/knowledge?knowledge_id=${encodeURIComponent(id)}`);const warn=d.current_ticket_changed?'<div class="warning">Das Ticket wurde seit diesem historischen Lauf verändert. Die KB-Bewertung unten ist eine aktuelle Neu-Berechnung gegen den jetzigen Ticketstand/Index; die gespeicherten Laufregeln oben bleiben historisch verbindlich.</div>':'';const scores=[['Retrieval',d.retrieval_score],['Semantik',d.semantic_score],['Titel',d.title_score],['Lexikalisch',d.lexical_score],['Kategorie',d.category_score]];$('#kbDiagnostic').innerHTML=`${warn}<div style="display:flex;justify-content:space-between;gap:12px;align-items:flex-start"><div><div class="eyebrow">KB Inspector</div><h2 style="margin:4px 0">${esc(d.title)}</h2><div class="muted">${esc(d.knowledge_id)} · ${esc(d.source)}</div></div><div class="chips">${statusChip(`Rang #${d.retrieval_rank}`,'info')}${d.sent_to_ai?statusChip('an KI gesendet','good'):statusChip(selectionText(d.selection_reason),'warn')}${d.ai_selected?statusChip('von KI gewählt','good'):''}</div></div><div class="scoregrid">${scores.map(x=>`<div class="scorebox"><div class="label">${esc(x[0])}</div><div class="n">${pct(x[1])}</div><div class="bar"><i style="width:${Math.max(0,Math.min(100,Number(x[1]||0)*100))}%"></i></div></div>`).join('')}</div><div class="kbmeta">${statusChip(`Cutoff ${pct(d.candidate_cutoff)}`,'info')}${statusChip(`Evidenz ${pct(d.evidence_score)}`,d.evidence_score>=d.required_score?'good':'warn')}${statusChip(`erforderlich ${pct(d.required_score)}`,'info')}${d.document.auto_reply?statusChip('auto_reply=true','good'):statusChip('auto_reply=false','bad')}${(d.unmapped_categories||[]).length?statusChip(`ungemappt: ${d.unmapped_categories.join(', ')}`,'bad'):''}</div><div class="grid2"><div><div class="label">Passender Ticketabschnitt</div><div class="excerpt">${esc(d.best_query_excerpt||'–')}</div></div><div><div class="label">Passender KB-Abschnitt</div><div class="excerpt">${esc(d.best_chunk_excerpt||'–')}</div></div></div><div style="margin-top:13px"><div class="panel-title">Regelprüfung für diesen Artikel</div>${(d.checks||[]).map(ruleHTML).join('')}</div>`}catch(e){$('#kbDiagnostic').innerHTML=`<div class="warning">${esc(e.message)}</div>`}}
|
||
async function searchKB(){const q=$('#kbSearch').value.trim();if(q.length<2){$('#kbResults').classList.add('hidden');return}try{const rows=await api(`/api/diagnostics/knowledge?q=${encodeURIComponent(q)}&limit=30`);$('#kbResults').innerHTML=rows.map(x=>`<div class="result" data-search-kb="${esc(x.id)}"><strong>${esc(x.title)}</strong><div class="muted" style="font-size:11px;margin-top:3px">${esc(x.id)} · ${esc(x.source)} · auto_reply=${x.auto_reply?'true':'false'}${(x.unmapped_categories||[]).length?` · ungemappt: ${esc(x.unmapped_categories.join(', '))}`:''}</div></div>`).join('')||'<div class="empty">Keine Treffer.</div>';$('#kbResults').classList.remove('hidden');document.querySelectorAll('[data-search-kb]').forEach(x=>x.onclick=()=>{$('#kbResults').classList.add('hidden');$('#kbSearch').value=x.dataset.searchKb;diagnoseKB(x.dataset.searchKb)})}catch(e){$('#kbResults').innerHTML=`<div class="warning">${esc(e.message)}</div>`;$('#kbResults').classList.remove('hidden')}}
|
||
$('#runSearch').addEventListener('input',filterRuns);$('#kbSearch').addEventListener('input',()=>{clearTimeout(kbTimer);kbTimer=setTimeout(searchKB,220)});$('#refreshBtn').onclick=()=>loadRuns();document.addEventListener('click',e=>{if(!e.target.closest('#kbSearch')&&!e.target.closest('#kbResults'))$('#kbResults').classList.add('hidden')});loadRuns().catch(e=>{$('#runList').innerHTML=`<div class="warning">${esc(e.message)}</div>`});
|
||
</script>
|
||
</body></html>
|