Analysepipeline: Visualisierung der Refresh-Schritte

Neuer Tab "Analysepipeline" zwischen Faktencheck und Quellenuebersicht.
Zeigt 9 Verarbeitungsschritte als n8n-artige Blockkette: Quellen sichten,
Nachrichten sammeln, Doppeltes filtern, Relevanz bewerten, Orte erkennen,
Lagebild verfassen, Fakten pruefen, Qualitaetscheck, Benachrichtigen.

- Backend: refresh_pipeline_steps-Tabelle persistiert pro Refresh+Pass die
  Status- und Zahlen-Werte. pipeline_tracker.py kapselt Start/Done/Skip/Error
  inkl. WebSocket-Broadcast (Event-Typ pipeline_step). 9 Hooks im Orchestrator
  speisen die Anzeige.
- API: GET /api/incidents/{id}/pipeline liefert Definition + letzten Stand
  (Zahlen aus letztem Refresh, Multi-Pass-Konsolidierung).
- Frontend: pipeline.js rendert Vollbild-Blockkette mit pulsierendem Glow am
  aktiven Block, animierten Pfeilen bei Datenfluss, Haekchen am fertigen Block.
  Hover-Tooltip mit Erklaerung in Nutzersprache, Klick oeffnet Detail-Popup.
  Bei Research-Lagen leuchtet ein Schleifen-Pfeil pro Mehrfach-Durchlauf auf.
  Mini-Variante (nur Icons) im Refresh-Progress-Popup.
- CSS: Light/Dark-Theme-fest, dezenter Circuit-Hintergrund (5% Opacity),
  Mobile-vertikale Stapelung unter 900px, prefers-reduced-motion respektiert.
- Uebersprungene Schritte (z.B. Geoparsing ohne neue Artikel) werden
  ausgeblendet, brandneue Lagen ohne Refresh zeigen Hinweis.

Tooltips bewusst in normaler Sprache ohne Internas (keine Modellnamen,
keine Toolnamen, keine Phasen-Labels).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
2026-05-01 13:53:44 +02:00
Ursprung 2b51e49d0d
Commit 3a346ba2ec
10 geänderte Dateien mit 1326 neuen und 2 gelöschten Zeilen

474
src/static/js/pipeline.js Normale Datei
Datei anzeigen

@@ -0,0 +1,474 @@
/**
* Pipeline-Modul: Visualisierung der Analysepipeline pro Lage.
*
* - Liest Pipeline-Definition + letzten Refresh-Stand vom Backend
* (GET /api/incidents/{id}/pipeline)
* - Hört auf WebSocket-Events vom Typ "pipeline_step" und animiert Live
* den jeweils aktiven Schritt
* - Bei Lagen-Wechsel wird die Visualisierung an die neue Lage neu gebunden
*
* Stilkonzept:
* - Blöcke = Karten mit Icon + Titel + Zahl
* - Verbindungspfeile als SVG zwischen den Blöcken
* - Aktiver Block: pulsierender Glow (CSS-Klasse .is-active)
* - Fertiger Block: Häkchen + dezente Outline (.is-done)
* - Übersprungener Block: ausgeblendet (laut Anforderung)
* - Multi-Pass (Research): am letzten Block leuchtet ein Schleifen-Pfeil auf
*/
const Pipeline = {
_incidentId: null,
_definition: null, // PIPELINE_STEPS vom Backend
_stateByKey: {}, // step_key -> {status, count_value, count_secondary, pass_number}
_isResearch: false,
_passTotal: 1,
_lastRefreshHeader: null,
_hoverTooltipEl: null,
_isLoading: false,
_wsBound: false,
_icons: {
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>',
rss: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.5"/></svg>',
'copy-x': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="13" height="13" rx="2"/><path d="M8 21h11a2 2 0 0 0 2-2V8"/><path d="M11 11l4 4M15 11l-4 4"/></svg>',
scale: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 8h14"/><path d="M5 8l-3 7h6z"/><path d="M19 8l-3 7h6z"/></svg>',
'map-pin': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s7-7 7-13a7 7 0 0 0-14 0c0 6 7 13 7 13z"/><circle cx="12" cy="9" r="2.5"/></svg>',
'file-text': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/><path d="M8 13h8M8 17h8M8 9h2"/></svg>',
shield: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>',
'check-circle': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 12l3 3 5-6"/></svg>',
bell: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>',
},
/** Wird einmal beim Seitenstart aufgerufen, hängt sich an WebSocket. */
init() {
if (this._wsBound) return;
if (typeof WS !== 'undefined' && WS.on) {
WS.on('pipeline_step', (msg) => this._onWsStep(msg));
// Bei Refresh-Complete den finalen Stand neu laden, damit Zahlen gefroren sichtbar bleiben
WS.on('refresh_complete', (msg) => this._onRefreshDone(msg));
WS.on('refresh_cancelled', (msg) => this._onRefreshDone(msg));
WS.on('refresh_error', (msg) => this._onRefreshDone(msg));
this._wsBound = true;
}
// Hover-Tooltip-Element vorbereiten
if (!this._hoverTooltipEl) {
const t = document.createElement('div');
t.className = 'pipeline-tooltip';
t.setAttribute('role', 'tooltip');
document.body.appendChild(t);
this._hoverTooltipEl = t;
}
// Klick auf Body schliesst Tooltip-Popup
document.addEventListener('click', (e) => {
if (!e.target.closest('.pipeline-block') && !e.target.closest('.pipeline-popup')) {
this._closePopup();
}
});
},
/** Bindet die Pipeline an eine Lage. Lädt Daten und rendert. */
async bindToIncident(incidentId) {
this._incidentId = incidentId;
this._stateByKey = {};
this._isResearch = false;
this._passTotal = 1;
this._lastRefreshHeader = null;
this._renderEmpty('Lade...');
if (incidentId == null) return;
this._isLoading = true;
try {
const data = await API.getPipeline(incidentId);
// Lagen-Wechsel waehrend Request: alte Antwort verwerfen
if (this._incidentId !== incidentId) return;
this._definition = data.steps_definition || [];
this._isResearch = !!data.is_research;
this._lastRefreshHeader = data.last_refresh || null;
this._passTotal = (data.last_refresh && data.last_refresh.pass_total) || 1;
// Letzten Stand pro step_key konsolidieren (bei Multi-Pass: letzter Pass-Eintrag gewinnt)
(data.steps || []).forEach(s => {
const key = s.step_key;
const prev = this._stateByKey[key];
if (!prev || (s.pass_number || 1) >= (prev.pass_number || 1)) {
this._stateByKey[key] = {
status: s.status,
count_value: s.count_value,
count_secondary: s.count_secondary,
pass_number: s.pass_number || 1,
};
}
});
this._render();
this._renderMini();
} catch (e) {
console.warn('Pipeline laden fehlgeschlagen:', e);
this._renderEmpty('Pipeline-Daten konnten nicht geladen werden.');
} finally {
this._isLoading = false;
}
},
/** WebSocket: einzelner Pipeline-Schritt-Status. */
_onWsStep(msg) {
if (!msg || !msg.data) return;
if (this._incidentId == null || msg.incident_id !== this._incidentId) return;
const d = msg.data;
const key = d.step_key;
if (!key) return;
// State aktualisieren — letzter Pass gewinnt
const prev = this._stateByKey[key];
const passNr = d.pass_number || 1;
if (!prev || passNr >= (prev.pass_number || 1)) {
this._stateByKey[key] = {
status: d.status,
count_value: d.count_value !== undefined ? d.count_value : (prev ? prev.count_value : null),
count_secondary: d.count_secondary !== undefined ? d.count_secondary : (prev ? prev.count_secondary : null),
pass_number: passNr,
};
}
// Multi-Pass-Erkennung: pass_number > _passTotal -> erweitern + Loop-Animation triggern
if (passNr > this._passTotal) {
this._passTotal = passNr;
// Schleifen-Pfeil aufflackern
const stage = document.getElementById('pipeline-stage');
if (stage) {
stage.classList.add('is-looping');
setTimeout(() => stage.classList.remove('is-looping'), 1500);
}
}
// Wenn ein neuer Pass startet (pass_number > prev und status="active" beim ERSTEN step):
// alle Schritte zurück auf pending setzen, damit die Animation neu durchläuft.
if (d.status === 'active' && this._definition && this._definition.length
&& key === this._definition[0].key && passNr > 1 && (!prev || prev.pass_number < passNr)) {
// Alle anderen Steps in "pending" zurueck (visuell), Werte behalten wir
this._definition.forEach(s => {
if (s.key !== key && this._stateByKey[s.key]) {
this._stateByKey[s.key].status = 'pending';
}
});
}
this._patchBlock(key);
this._patchMiniBlock(key);
},
_onRefreshDone(msg) {
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
// Daten frisch nachladen, damit Header (Dauer) und finale Zahlen passen
setTimeout(() => {
if (this._incidentId != null) this.bindToIncident(this._incidentId);
}, 600);
},
/** Vollbild-Pipeline (Tab "Analysepipeline") rendern. */
_render() {
const stage = document.getElementById('pipeline-stage');
const meta = document.getElementById('pipeline-header-meta');
const sidenote = document.getElementById('pipeline-sidenote');
if (!stage) return;
// Header: letzter Refresh
if (meta) {
meta.textContent = this._formatHeader();
}
if (sidenote) {
sidenote.hidden = !this._isResearch;
}
// Brandneue Lage ohne Refresh
if (!this._lastRefreshHeader) {
this._renderEmpty('Noch nie aktualisiert &mdash; starte den ersten Refresh.');
return;
}
// Steps + Pfeile
const visible = (this._definition || []).filter(s => {
const st = this._stateByKey[s.key];
// Übersprungene komplett ausblenden (laut Anforderung 4b)
return !st || st.status !== 'skipped';
});
const blocksHtml = visible.map((s, i) => this._renderBlock(s, i, visible.length)).join('');
stage.innerHTML = `<div class="pipeline-track">${blocksHtml}</div>`;
this._bindBlockEvents(stage);
},
_renderBlock(stepDef, index, total) {
const st = this._stateByKey[stepDef.key];
const status = (st && st.status) || 'pending';
const cv = st ? st.count_value : null;
const cs = st ? st.count_secondary : null;
const isLast = (index === total - 1);
const arrow = isLast ? '' : `<div class="pipeline-arrow" data-from="${stepDef.key}"></div>`;
const loopMark = isLast && this._isResearch
? `<div class="pipeline-loop" title="Mehrfach-Durchlauf"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg></div>`
: '';
const icon = this._icons[stepDef.icon] || this._icons.search;
return `
<div class="pipeline-block status-${status}" data-step-key="${stepDef.key}" tabindex="0" aria-label="${this._escape(stepDef.label)}">
<div class="pipeline-block-icon">${icon}</div>
<div class="pipeline-block-title">${this._escape(stepDef.label)}</div>
<div class="pipeline-block-count">${this._formatCount(stepDef.key, cv, cs, status)}</div>
<div class="pipeline-block-check" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-11"/></svg>
</div>
${loopMark}
</div>
${arrow}
`;
},
/** Einzelnen Block neu zeichnen (ohne kompletten Re-Render). */
_patchBlock(stepKey) {
const stage = document.getElementById('pipeline-stage');
if (!stage) return;
const def = (this._definition || []).find(s => s.key === stepKey);
if (!def) return;
const st = this._stateByKey[stepKey];
const status = (st && st.status) || 'pending';
// Übersprungene komplett ausblenden -> kompletter Re-Render
if (status === 'skipped') {
this._render();
return;
}
const block = stage.querySelector(`.pipeline-block[data-step-key="${stepKey}"]`);
if (!block) {
// Block fehlt im DOM (z.B. vorher skipped): kompletter Re-Render
this._render();
return;
}
block.className = `pipeline-block status-${status}`;
block.setAttribute('tabindex', '0');
const cv = st ? st.count_value : null;
const cs = st ? st.count_secondary : null;
const cEl = block.querySelector('.pipeline-block-count');
if (cEl) cEl.innerHTML = this._formatCount(stepKey, cv, cs, status);
// Aktiven Pfeil zum nächsten Block markieren
const arrows = stage.querySelectorAll('.pipeline-arrow');
arrows.forEach(a => a.classList.remove('is-flowing'));
if (status === 'done') {
const arrow = stage.querySelector(`.pipeline-arrow[data-from="${stepKey}"]`);
if (arrow) arrow.classList.add('is-flowing');
}
},
_bindBlockEvents(stage) {
stage.querySelectorAll('.pipeline-block').forEach(block => {
const key = block.getAttribute('data-step-key');
const def = (this._definition || []).find(s => s.key === key);
if (!def) return;
block.addEventListener('mouseenter', (e) => this._showTooltip(e, def));
block.addEventListener('mouseleave', () => this._hideTooltip());
block.addEventListener('focus', (e) => this._showTooltip(e, def));
block.addEventListener('blur', () => this._hideTooltip());
block.addEventListener('click', (e) => {
e.stopPropagation();
this._openPopup(def);
});
block.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._openPopup(def);
}
});
});
},
_showTooltip(evt, def) {
if (!this._hoverTooltipEl) return;
this._hoverTooltipEl.textContent = def.tooltip || def.label;
this._hoverTooltipEl.classList.add('visible');
const rect = evt.currentTarget.getBoundingClientRect();
const tipW = 280;
let left = rect.left + rect.width / 2 - tipW / 2;
if (left < 8) left = 8;
if (left + tipW > window.innerWidth - 8) left = window.innerWidth - tipW - 8;
this._hoverTooltipEl.style.left = left + 'px';
this._hoverTooltipEl.style.top = (rect.top - 8) + 'px';
this._hoverTooltipEl.style.transform = 'translateY(-100%)';
},
_hideTooltip() {
if (!this._hoverTooltipEl) return;
this._hoverTooltipEl.classList.remove('visible');
},
_openPopup(def) {
this._closePopup();
const popup = document.createElement('div');
popup.className = 'pipeline-popup';
popup.setAttribute('role', 'dialog');
popup.innerHTML = `
<div class="pipeline-popup-inner">
<div class="pipeline-popup-title">${this._escape(def.label)}</div>
<div class="pipeline-popup-text">${this._escape(def.tooltip || '')}</div>
<button class="pipeline-popup-close" aria-label="Schliessen">&times;</button>
</div>
`;
popup.querySelector('.pipeline-popup-close').addEventListener('click', () => this._closePopup());
document.body.appendChild(popup);
// ESC schliesst
this._escListener = (e) => { if (e.key === 'Escape') this._closePopup(); };
document.addEventListener('keydown', this._escListener);
},
_closePopup() {
const existing = document.querySelector('.pipeline-popup');
if (existing) existing.remove();
if (this._escListener) {
document.removeEventListener('keydown', this._escListener);
this._escListener = null;
}
},
/** Mini-Variante (Refresh-Popup): Icons + Status, keine Zahlen, keine Tooltips. */
_renderMini() {
const mini = document.getElementById('progress-pipeline-mini');
if (!mini) return;
if (!this._definition || !this._definition.length) {
mini.innerHTML = '';
return;
}
const visible = this._definition.filter(s => {
const st = this._stateByKey[s.key];
return !st || st.status !== 'skipped';
});
const html = visible.map((s, i) => {
const st = this._stateByKey[s.key];
const status = (st && st.status) || 'pending';
const icon = this._icons[s.icon] || this._icons.search;
const sep = (i < visible.length - 1) ? '<span class="pipeline-mini-sep" aria-hidden="true"></span>' : '';
return `<span class="pipeline-mini-block status-${status}" data-step-key="${s.key}" title="${this._escape(s.label)}">${icon}</span>${sep}`;
}).join('');
mini.innerHTML = html;
},
_patchMiniBlock(stepKey) {
const mini = document.getElementById('progress-pipeline-mini');
if (!mini) return;
const st = this._stateByKey[stepKey];
const status = (st && st.status) || 'pending';
if (status === 'skipped') {
this._renderMini();
return;
}
const el = mini.querySelector(`.pipeline-mini-block[data-step-key="${stepKey}"]`);
if (!el) {
this._renderMini();
return;
}
el.className = `pipeline-mini-block status-${status}`;
},
_renderEmpty(msg) {
const stage = document.getElementById('pipeline-stage');
const meta = document.getElementById('pipeline-header-meta');
const sidenote = document.getElementById('pipeline-sidenote');
if (meta) meta.textContent = '';
if (sidenote) sidenote.hidden = true;
if (stage) stage.innerHTML = `<div class="pipeline-empty">${msg}</div>`;
// Mini im Refresh-Popup zuruecksetzen
const mini = document.getElementById('progress-pipeline-mini');
if (mini) mini.innerHTML = '';
},
_formatHeader() {
const r = this._lastRefreshHeader;
if (!r) return '';
let parts = [];
if (r.started_at) {
const rel = this._relativeTime(r.started_at);
parts.push(rel ? `Letzter Refresh: ${rel}` : `Letzter Refresh: ${r.started_at}`);
}
if (r.duration_sec != null) {
parts.push(`Dauer: ${r.duration_sec} s`);
}
if (r.status === 'running') {
parts = ['Aktualisierung läuft...'];
} else if (r.status === 'cancelled') {
parts.push('abgebrochen');
} else if (r.status === 'error') {
parts.push('mit Fehler beendet');
}
return parts.join(' · ');
},
_relativeTime(dbStr) {
try {
// dbStr ist lokal "YYYY-MM-DD HH:MM:SS"
const d = new Date(dbStr.replace(' ', 'T'));
if (isNaN(d.getTime())) return '';
const diffMs = Date.now() - d.getTime();
const min = Math.floor(diffMs / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min`;
const h = Math.floor(min / 60);
if (h < 24) return `vor ${h} Std`;
const days = Math.floor(h / 24);
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
} catch (e) {
return '';
}
},
_formatCount(stepKey, cv, cs, status) {
// Qualitaetscheck: KEINE Zahlen, nur Status (Anforderung 3 vom User)
if (stepKey === 'qc' || stepKey === 'summary') {
if (status === 'done') return '<span class="count-status">erledigt</span>';
if (status === 'active') return '<span class="count-status">läuft...</span>';
if (status === 'error') return '<span class="count-status">Fehler</span>';
return '<span class="count-status">—</span>';
}
if (status === 'pending') return '<span class="count-status">—</span>';
if (status === 'active') return '<span class="count-status">läuft...</span>';
if (status === 'error') return '<span class="count-status">Fehler</span>';
if (cv == null) return '<span class="count-status">—</span>';
switch (stepKey) {
case 'sources_review':
return `${cv} Quellen geprüft`;
case 'collect':
return cs != null
? `${cv} Meldungen<small> aus ${cs} Quellen</small>`
: `${cv} Meldungen`;
case 'dedup':
return cs != null
? `${cv} Duplikate<small> (${cs} verbleiben)</small>`
: `${cv} Duplikate`;
case 'relevance':
return cs != null && cs > 0
? `${cv} relevant<small> von ${cs}</small>`
: `${cv} relevant`;
case 'geoparsing':
return cs != null
? `${cv} Orte<small> aus ${cs} Meldungen</small>`
: `${cv} Orte erkannt`;
case 'factcheck':
return cs != null
? `${cv} neue Fakten<small> (${cs} gesamt)</small>`
: `${cv} Fakten geprüft`;
case 'notify':
return cv === 0 ? 'keine versendet' : `${cv} Hinweis${cv === 1 ? '' : 'e'} versendet`;
default:
return `${cv}`;
}
},
_escape(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
},
};
document.addEventListener('DOMContentLoaded', () => Pipeline.init());