Promote develop → main (2026-05-01 12:48 UTC) #7

Zusammengeführt
IntelSight_Admin hat 5 Commits von develop nach main 2026-05-01 14:48:17 +02:00 zusammengeführt
10 geänderte Dateien mit 1452 neuen und 2 gelöschten Zeilen

Datei anzeigen

@@ -677,6 +677,7 @@ class AgentOrchestrator:
from agents.analyzer import AnalyzerAgent from agents.analyzer import AnalyzerAgent
from agents.factchecker import FactCheckerAgent from agents.factchecker import FactCheckerAgent
from feeds.rss_parser import RSSParser from feeds.rss_parser import RSSParser
from services import pipeline_tracker as _pipe
db = await get_db() db = await get_db()
try: try:
@@ -719,6 +720,47 @@ class AgentOrchestrator:
log_id = cursor.lastrowid log_id = cursor.lastrowid
usage_acc = UsageAccumulator() usage_acc = UsageAccumulator()
# --- Pipeline-Tracking (Analysepipeline-Visualisierung) ---
_pass_nr = (_pass_info or {}).get("nr", 1)
_step_ids: dict[str, Optional[int]] = {}
async def _pipe_start(step_key: str):
try:
sid = await _pipe.start_step(
db, self._ws_manager,
refresh_log_id=log_id, incident_id=incident_id, step_key=step_key,
pass_number=_pass_nr, tenant_id=tenant_id,
visibility=visibility, created_by=created_by,
)
_step_ids[step_key] = sid
return sid
except Exception as _e:
logger.debug(f"_pipe_start({step_key}) ignoriert: {_e}")
return None
async def _pipe_done(step_key: str, count_value=None, count_secondary=None):
try:
sid = _step_ids.pop(step_key, None)
await _pipe.complete_step(
db, self._ws_manager, step_id=sid,
refresh_log_id=log_id, incident_id=incident_id, step_key=step_key,
pass_number=_pass_nr, count_value=count_value, count_secondary=count_secondary,
tenant_id=tenant_id, visibility=visibility, created_by=created_by,
)
except Exception as _e:
logger.debug(f"_pipe_done({step_key}) ignoriert: {_e}")
async def _pipe_skip(step_key: str):
try:
await _pipe.skip_step(
db, self._ws_manager,
refresh_log_id=log_id, incident_id=incident_id, step_key=step_key,
pass_number=_pass_nr, tenant_id=tenant_id,
visibility=visibility, created_by=created_by,
)
except Exception as _e:
logger.debug(f"_pipe_skip({step_key}) ignoriert: {_e}")
research_status = "deep_researching" if incident_type == "research" else "researching" research_status = "deep_researching" if incident_type == "research" else "researching"
research_detail = "Hintergrundrecherche im Web läuft..." if incident_type == "research" else "RSS-Feeds und Web werden durchsucht..." research_detail = "Hintergrundrecherche im Web läuft..." if incident_type == "research" else "RSS-Feeds und Web werden durchsucht..."
# Multi-Pass: Detail-Text mit Durchlauf-Info versehen # Multi-Pass: Detail-Text mit Durchlauf-Info versehen
@@ -741,6 +783,23 @@ class AgentOrchestrator:
) )
existing_db_articles_full = await cursor.fetchall() existing_db_articles_full = await cursor.fetchall()
# Pipeline-Schritt 1: Quellen sichten (vorbereitet)
await _pipe_start("sources_review")
try:
if incident_type == "adhoc":
_src_cursor = await db.execute(
"SELECT COUNT(*) AS cnt FROM sources WHERE tenant_id = ? AND status = 'active'",
(tenant_id,),
)
_src_row = await _src_cursor.fetchone()
_src_total = _src_row["cnt"] if _src_row else 0
else:
_src_total = None
except Exception:
_src_total = None
# secondary wird später mit der Anzahl tatsächlich liefernder Quellen ergänzt
await _pipe_done("sources_review", count_value=_src_total, count_secondary=None)
# Schritt 1+2: RSS-Feeds und Claude-Recherche parallel ausführen # Schritt 1+2: RSS-Feeds und Claude-Recherche parallel ausführen
async def _rss_pipeline(): async def _rss_pipeline():
"""RSS-Feed-Suche (Feed-Selektion + dynamische Keywords + Parsing).""" """RSS-Feed-Suche (Feed-Selektion + dynamische Keywords + Parsing)."""
@@ -880,6 +939,9 @@ class AgentOrchestrator:
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten") logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
return articles, None return articles, None
# Pipeline-Schritt 2: Nachrichten sammeln (Start)
await _pipe_start("collect")
# Pipelines parallel starten (RSS + WebSearch + Podcasts + optional Telegram) # Pipelines parallel starten (RSS + WebSearch + Podcasts + optional Telegram)
pipelines = [_rss_pipeline(), _web_search_pipeline(), _podcast_pipeline()] pipelines = [_rss_pipeline(), _web_search_pipeline(), _podcast_pipeline()]
if include_telegram: if include_telegram:
@@ -910,6 +972,15 @@ class AgentOrchestrator:
# Alle Ergebnisse zusammenführen # Alle Ergebnisse zusammenführen
all_results = rss_articles + search_results + telegram_articles all_results = rss_articles + search_results + telegram_articles
# Pipeline-Schritt 2: Nachrichten sammeln (fertig)
try:
_delivering_sources = len({a.get("source", "") for a in all_results if a.get("source")})
except Exception:
_delivering_sources = None
await _pipe_done("collect", count_value=len(all_results), count_secondary=_delivering_sources)
# Pipeline-Schritt 3: Doppeltes filtern (Start)
await _pipe_start("dedup")
# Duplikate entfernen (normalisierte URL + Headline-Ähnlichkeit) # Duplikate entfernen (normalisierte URL + Headline-Ähnlichkeit)
seen_urls = set() seen_urls = set()
@@ -922,6 +993,7 @@ class AgentOrchestrator:
dupes_removed = len(all_results) - len(unique_results) dupes_removed = len(all_results) - len(unique_results)
if dupes_removed > 0: if dupes_removed > 0:
logger.info(f"Deduplizierung: {dupes_removed} Duplikate entfernt, {len(unique_results)} verbleibend") logger.info(f"Deduplizierung: {dupes_removed} Duplikate entfernt, {len(unique_results)} verbleibend")
await _pipe_done("dedup", count_value=dupes_removed, count_secondary=len(unique_results))
# Relevanz-Scoring und Sortierung # Relevanz-Scoring und Sortierung
for article in unique_results: for article in unique_results:
@@ -978,6 +1050,10 @@ class AgentOrchestrator:
new_candidates.append(article) new_candidates.append(article)
# Pipeline-Schritt 4: Relevanz bewerten (Start)
await _pipe_start("relevance")
_candidates_before_topic = len(new_candidates)
# --- Semantischer Topic-Filter (Haiku) --- # --- Semantischer Topic-Filter (Haiku) ---
# Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema # Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema
# der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten. # der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten.
@@ -988,6 +1064,7 @@ class AgentOrchestrator:
) )
if _tf_usage: if _tf_usage:
usage_acc.add(_tf_usage) usage_acc.add(_tf_usage)
await _pipe_done("relevance", count_value=len(new_candidates), count_secondary=_candidates_before_topic)
# --- Neue (thematisch gefilterte) Artikel speichern und für Analyse tracken --- # --- Neue (thematisch gefilterte) Artikel speichern und für Analyse tracken ---
new_count = 0 new_count = 0
@@ -1019,6 +1096,8 @@ class AgentOrchestrator:
# Geoparsing: Orte aus neuen Artikeln extrahieren und speichern # Geoparsing: Orte aus neuen Artikeln extrahieren und speichern
if new_articles_for_analysis: if new_articles_for_analysis:
# Pipeline-Schritt 5: Orte erkennen (Start)
await _pipe_start("geoparsing")
try: try:
from agents.geoparsing import geoparse_articles from agents.geoparsing import geoparse_articles
incident_context = f"{title} - {description}" incident_context = f"{title} - {description}"
@@ -1049,8 +1128,12 @@ class AgentOrchestrator:
) )
await db.commit() await db.commit()
logger.info(f"Category-Labels gespeichert fuer Incident {incident_id}: {category_labels}") logger.info(f"Category-Labels gespeichert fuer Incident {incident_id}: {category_labels}")
await _pipe_done("geoparsing", count_value=geo_count, count_secondary=len(geo_results) if geo_results else 0)
except Exception as e: except Exception as e:
logger.warning(f"Geoparsing fehlgeschlagen (Pipeline laeuft weiter): {e}") logger.warning(f"Geoparsing fehlgeschlagen (Pipeline laeuft weiter): {e}")
await _pipe_done("geoparsing", count_value=0, count_secondary=0)
else:
await _pipe_skip("geoparsing")
# Quellen-Statistiken aktualisieren # Quellen-Statistiken aktualisieren
if new_count > 0: if new_count > 0:
@@ -1196,6 +1279,10 @@ class AgentOrchestrator:
articles_for_check = [dict(row) for row in await cursor.fetchall()] articles_for_check = [dict(row) for row in await cursor.fetchall()]
return await factchecker.check(title, articles_for_check, incident_type) return await factchecker.check(title, articles_for_check, incident_type)
# Pipeline-Schritte 6+7: Lagebild verfassen + Fakten prüfen (Start, parallel)
await _pipe_start("summary")
await _pipe_start("factcheck")
# Beide Tasks PARALLEL starten # Beide Tasks PARALLEL starten
logger.info("Starte Analyse und Faktencheck parallel...") logger.info("Starte Analyse und Faktencheck parallel...")
analysis_result, factcheck_result = await asyncio.gather( analysis_result, factcheck_result = await asyncio.gather(
@@ -1205,6 +1292,8 @@ class AgentOrchestrator:
analysis, analysis_usage = analysis_result analysis, analysis_usage = analysis_result
fact_checks, fc_usage = factcheck_result fact_checks, fc_usage = factcheck_result
# Pipeline-Schritt 6: Lagebild verfassen (fertig, keine Zahl, nur Status)
await _pipe_done("summary", count_value=None, count_secondary=None)
# --- Analyse-Ergebnisse verarbeiten --- # --- Analyse-Ergebnisse verarbeiten ---
if analysis_usage: if analysis_usage:
@@ -1458,6 +1547,13 @@ class AgentOrchestrator:
await db.commit() await db.commit()
# Pipeline-Schritt 7: Fakten prüfen (fertig)
_new_facts_count = max(0, len(fact_checks) - len(existing_facts))
await _pipe_done("factcheck", count_value=_new_facts_count, count_secondary=len(fact_checks) if fact_checks else 0)
# Pipeline-Schritt 8: Qualitätscheck (Start, ohne Zahlen)
await _pipe_start("qc")
# Post-Refresh Quality Check: Duplikate und Karten-Kategorien pruefen # Post-Refresh Quality Check: Duplikate und Karten-Kategorien pruefen
try: try:
from services.post_refresh_qc import run_post_refresh_qc from services.post_refresh_qc import run_post_refresh_qc
@@ -1469,6 +1565,12 @@ class AgentOrchestrator:
) )
except Exception as qc_err: except Exception as qc_err:
logger.warning(f"Post-Refresh QC fehlgeschlagen: {qc_err}") logger.warning(f"Post-Refresh QC fehlgeschlagen: {qc_err}")
await _pipe_done("qc", count_value=None, count_secondary=None)
# Pipeline-Schritt 9: Benachrichtigen (Start)
await _pipe_start("notify")
_notify_count = 0
# Gebündelte Notification senden (nicht beim ersten Refresh) # Gebündelte Notification senden (nicht beim ersten Refresh)
if not is_first_refresh: if not is_first_refresh:
if self._ws_manager: if self._ws_manager:
@@ -1525,6 +1627,32 @@ class AgentOrchestrator:
db, incident_id, title, visibility, created_by, tenant_id, db_notifications, db, incident_id, title, visibility, created_by, tenant_id, db_notifications,
incident_type=incident_type, incident_type=incident_type,
) )
_notify_count = len(db_notifications)
# Pipeline-Schritt 9: Benachrichtigen (fertig)
await _pipe_done("notify", count_value=_notify_count, count_secondary=None)
# Falls Analyse-Block übersprungen wurde (kein neuer Artikel und Summary existiert),
# die noch offenen Pipeline-Schritte als übersprungen markieren.
for _skipped_key in ("summary", "factcheck", "qc", "notify"):
if _skipped_key in _step_ids or _skipped_key not in {"summary", "factcheck", "qc", "notify"}:
pass
# Saubere Variante: alle noch offenen Steps am Ende skippen
for _open_key in list(_step_ids.keys()):
await _pipe_skip(_open_key)
# Auch Steps die nie gestartet wurden (bei übersprungenem Outer-If)
_started_keys = set()
try:
_check_cursor = await db.execute(
"SELECT step_key FROM refresh_pipeline_steps WHERE refresh_log_id = ? AND pass_number = ?",
(log_id, _pass_nr),
)
_started_keys = {row[0] for row in await _check_cursor.fetchall()}
except Exception:
pass
for _missing_key in ("summary", "factcheck", "qc", "notify"):
if _missing_key not in _started_keys:
await _pipe_skip(_missing_key)
# Refresh-Log abschließen (mit Token-Statistiken) # Refresh-Log abschließen (mit Token-Statistiken)
await db.execute( await db.execute(

Datei anzeigen

@@ -117,6 +117,22 @@ CREATE TABLE IF NOT EXISTS refresh_log (
tenant_id INTEGER REFERENCES organizations(id) tenant_id INTEGER REFERENCES organizations(id)
); );
CREATE TABLE IF NOT EXISTS refresh_pipeline_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
refresh_log_id INTEGER REFERENCES refresh_log(id) ON DELETE CASCADE,
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
step_key TEXT NOT NULL,
pass_number INTEGER DEFAULT 1,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status TEXT DEFAULT 'pending',
count_value INTEGER,
count_secondary INTEGER,
tenant_id INTEGER REFERENCES organizations(id)
);
CREATE INDEX IF NOT EXISTS idx_pipeline_steps_incident ON refresh_pipeline_steps(incident_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_pipeline_steps_log ON refresh_pipeline_steps(refresh_log_id);
CREATE TABLE IF NOT EXISTS incident_snapshots ( CREATE TABLE IF NOT EXISTS incident_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE, incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
@@ -418,6 +434,29 @@ async def init_db():
await db.execute("ALTER TABLE refresh_log ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)") await db.execute("ALTER TABLE refresh_log ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
await db.commit() await db.commit()
# Migration: refresh_pipeline_steps-Tabelle (Analysepipeline-Visualisierung)
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_pipeline_steps'")
if not await cursor.fetchone():
await db.executescript("""
CREATE TABLE refresh_pipeline_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
refresh_log_id INTEGER REFERENCES refresh_log(id) ON DELETE CASCADE,
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
step_key TEXT NOT NULL,
pass_number INTEGER DEFAULT 1,
started_at TIMESTAMP,
completed_at TIMESTAMP,
status TEXT DEFAULT 'pending',
count_value INTEGER,
count_secondary INTEGER,
tenant_id INTEGER REFERENCES organizations(id)
);
CREATE INDEX IF NOT EXISTS idx_pipeline_steps_incident ON refresh_pipeline_steps(incident_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_pipeline_steps_log ON refresh_pipeline_steps(refresh_log_id);
""")
await db.commit()
logger.info("Migration: refresh_pipeline_steps-Tabelle erstellt")
# Migration: notifications-Tabelle (fuer bestehende DBs) # Migration: notifications-Tabelle (fuer bestehende DBs)
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'") cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'")
if not await cursor.fetchone(): if not await cursor.fetchone():

Datei anzeigen

@@ -613,6 +613,98 @@ async def get_factchecks(
return [dict(row) for row in rows] return [dict(row) for row in rows]
@router.get("/{incident_id}/pipeline")
async def get_pipeline(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Analysepipeline-Status der Lage: Definition aller Schritte + Stand des
letzten (oder gerade laufenden) Refreshs.
Antwort:
{
"is_research": bool,
"is_running": bool,
"last_refresh": {started_at, completed_at, duration_sec, status, pass_total} | null,
"steps_definition": [{key, label, icon, tooltip}, ...],
"steps": [{step_key, status, count_value, count_secondary, pass_number}, ...]
}
"""
from services.pipeline_tracker import PIPELINE_STEPS
tenant_id = current_user.get("tenant_id")
incident_row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
is_research = (incident_row["type"] or "adhoc") == "research"
# Jüngsten Refresh-Log wählen: bevorzugt running, sonst der letzte completed
cursor = await db.execute(
"""SELECT id, started_at, completed_at, status, retry_count
FROM refresh_log
WHERE incident_id = ? AND status = 'running'
ORDER BY started_at DESC LIMIT 1""",
(incident_id,),
)
row = await cursor.fetchone()
if not row:
cursor = await db.execute(
"""SELECT id, started_at, completed_at, status, retry_count
FROM refresh_log
WHERE incident_id = ?
ORDER BY started_at DESC LIMIT 1""",
(incident_id,),
)
row = await cursor.fetchone()
last_refresh = None
steps = []
is_running = False
if row:
is_running = row["status"] == "running"
# Pipeline-Steps zu diesem Refresh laden
sc = await db.execute(
"""SELECT step_key, pass_number, status, count_value, count_secondary,
started_at, completed_at
FROM refresh_pipeline_steps
WHERE refresh_log_id = ?
ORDER BY pass_number ASC, id ASC""",
(row["id"],),
)
steps = [dict(r) for r in await sc.fetchall()]
# Pass-Total: bei Research-Lagen mit Multi-Pass-Daten ermitteln
max_pass = 1
for s in steps:
if s["pass_number"] and s["pass_number"] > max_pass:
max_pass = s["pass_number"]
# Dauer berechnen (nur wenn completed)
duration_sec = None
try:
if row["started_at"] and row["completed_at"]:
t0 = datetime.strptime(row["started_at"], "%Y-%m-%d %H:%M:%S")
t1 = datetime.strptime(row["completed_at"], "%Y-%m-%d %H:%M:%S")
duration_sec = max(0, int((t1 - t0).total_seconds()))
except Exception:
duration_sec = None
last_refresh = {
"started_at": row["started_at"],
"completed_at": row["completed_at"],
"status": row["status"],
"duration_sec": duration_sec,
"pass_total": max_pass,
}
return {
"is_research": is_research,
"is_running": is_running,
"last_refresh": last_refresh,
"steps_definition": PIPELINE_STEPS,
"steps": steps,
}
@router.get("/{incident_id}/locations") @router.get("/{incident_id}/locations")
async def get_locations( async def get_locations(
incident_id: int, incident_id: int,

Datei anzeigen

@@ -0,0 +1,230 @@
"""Analysepipeline-Tracking: persistiert Pipeline-Schritte pro Refresh und sendet
Live-Status an die Frontend-Visualisierung.
Die Pipeline hat 9 Schritte und ist eine bewusst vereinfachte Außensicht der
internen Refresh-Pipeline (siehe orchestrator.py). Sie verschweigt Internas
(Modellnamen, Tools, Phasen, Multi-Pass-Labels) und beschreibt jeden Schritt in
verständlicher Sprache.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from config import TIMEZONE
logger = logging.getLogger("osint.pipeline")
# Single Source of Truth für die Pipeline-Definition.
# Reihenfolge bestimmt die Anzeige im Frontend.
PIPELINE_STEPS = [
{
"key": "sources_review",
"label": "Quellen sichten",
"icon": "search",
"tooltip": "Wir prüfen alle deine Nachrichtenquellen, ob sie aktuell erreichbar sind und was sie zu deiner Lage melden.",
},
{
"key": "collect",
"label": "Nachrichten sammeln",
"icon": "rss",
"tooltip": "Aus den passenden Quellen werden alle relevanten Meldungen eingesammelt - aus deinen RSS-Feeds, dem Web und optional Telegram-Kanälen.",
},
{
"key": "dedup",
"label": "Doppeltes filtern",
"icon": "copy-x",
"tooltip": "Mehrfach gemeldete Nachrichten werden zusammengefasst, damit nichts doppelt im Lagebild auftaucht.",
},
{
"key": "relevance",
"label": "Relevanz bewerten",
"icon": "scale",
"tooltip": "Jede Meldung wird darauf geprüft, ob sie wirklich zu deiner Lage passt. Themenfremdes wird aussortiert.",
},
{
"key": "geoparsing",
"label": "Orte erkennen",
"icon": "map-pin",
"tooltip": "Aus den Meldungen werden Ortsangaben erkannt und auf der Karte verortet.",
},
{
"key": "summary",
"label": "Lagebild verfassen",
"icon": "file-text",
"tooltip": "Aus allen geprüften Meldungen wird ein zusammenhängendes Lagebild geschrieben, mit Quellenangaben am Text.",
},
{
"key": "factcheck",
"label": "Fakten prüfen",
"icon": "shield",
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?",
},
{
"key": "qc",
"label": "Qualitätscheck",
"icon": "check-circle",
"tooltip": "Eine letzte Kontrollprüfung am Ergebnis: Doppelte Fakten zusammenführen, Karten-Verortung prüfen, bevor du benachrichtigt wirst.",
},
{
"key": "notify",
"label": "Benachrichtigen",
"icon": "bell",
"tooltip": "Wenn etwas Wichtiges dabei war, gehen Benachrichtigungen raus, im Glockensymbol oben rechts und optional per E-Mail.",
},
]
VALID_KEYS = {s["key"] for s in PIPELINE_STEPS}
def _now_db() -> str:
"""Aktuelle Zeit im DB-Format (lokal)."""
return datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
async def _broadcast(ws_manager, incident_id: int, payload: dict,
visibility: str, created_by: Optional[int], tenant_id: Optional[int]):
"""Sendet ein pipeline_step-Event an verbundene Clients der Lage."""
if not ws_manager:
return
try:
await ws_manager.broadcast_for_incident(
{"type": "pipeline_step", "incident_id": incident_id, "data": payload},
visibility, created_by, tenant_id,
)
except Exception as e:
logger.warning(f"Pipeline-WS-Broadcast fehlgeschlagen: {e}")
async def start_step(db, ws_manager, *, refresh_log_id: int, incident_id: int,
step_key: str, pass_number: int = 1, tenant_id: Optional[int] = None,
visibility: str = "public", created_by: Optional[int] = None) -> Optional[int]:
"""Markiert einen Pipeline-Schritt als aktiv.
Returns die DB-ID der Step-Zeile (für späteres Update via complete_step), oder None bei Fehler.
"""
if step_key not in VALID_KEYS:
logger.warning(f"Unbekannter Pipeline-Schritt: {step_key}")
return None
try:
cursor = await db.execute(
"""INSERT INTO refresh_pipeline_steps
(refresh_log_id, incident_id, step_key, pass_number, started_at, status, tenant_id)
VALUES (?, ?, ?, ?, ?, 'active', ?)""",
(refresh_log_id, incident_id, step_key, pass_number, _now_db(), tenant_id),
)
await db.commit()
step_id = cursor.lastrowid
except Exception as e:
logger.warning(f"Pipeline start_step({step_key}) DB-Fehler: {e}")
step_id = None
await _broadcast(ws_manager, incident_id, {
"step_key": step_key,
"status": "active",
"pass_number": pass_number,
}, visibility, created_by, tenant_id)
return step_id
async def complete_step(db, ws_manager, *, step_id: Optional[int], refresh_log_id: int,
incident_id: int, step_key: str, pass_number: int = 1,
count_value: Optional[int] = None, count_secondary: Optional[int] = None,
tenant_id: Optional[int] = None, visibility: str = "public",
created_by: Optional[int] = None):
"""Markiert einen Pipeline-Schritt als abgeschlossen, mit Zahlen."""
if step_key not in VALID_KEYS:
return
try:
if step_id:
await db.execute(
"""UPDATE refresh_pipeline_steps
SET status = 'done', completed_at = ?, count_value = ?, count_secondary = ?
WHERE id = ?""",
(_now_db(), count_value, count_secondary, step_id),
)
else:
# Fallback wenn start_step keine ID lieferte
await db.execute(
"""INSERT INTO refresh_pipeline_steps
(refresh_log_id, incident_id, step_key, pass_number, started_at, completed_at,
status, count_value, count_secondary, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, 'done', ?, ?, ?)""",
(refresh_log_id, incident_id, step_key, pass_number, _now_db(), _now_db(),
count_value, count_secondary, tenant_id),
)
await db.commit()
except Exception as e:
logger.warning(f"Pipeline complete_step({step_key}) DB-Fehler: {e}")
await _broadcast(ws_manager, incident_id, {
"step_key": step_key,
"status": "done",
"pass_number": pass_number,
"count_value": count_value,
"count_secondary": count_secondary,
}, visibility, created_by, tenant_id)
async def skip_step(db, ws_manager, *, refresh_log_id: int, incident_id: int,
step_key: str, pass_number: int = 1, tenant_id: Optional[int] = None,
visibility: str = "public", created_by: Optional[int] = None):
"""Markiert einen Schritt als übersprungen (z.B. Geoparsing ohne neue Artikel)."""
if step_key not in VALID_KEYS:
return
try:
await db.execute(
"""INSERT INTO refresh_pipeline_steps
(refresh_log_id, incident_id, step_key, pass_number, started_at, completed_at,
status, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, 'skipped', ?)""",
(refresh_log_id, incident_id, step_key, pass_number, _now_db(), _now_db(), tenant_id),
)
await db.commit()
except Exception as e:
logger.warning(f"Pipeline skip_step({step_key}) DB-Fehler: {e}")
await _broadcast(ws_manager, incident_id, {
"step_key": step_key,
"status": "skipped",
"pass_number": pass_number,
}, visibility, created_by, tenant_id)
async def error_step(db, ws_manager, *, step_id: Optional[int], refresh_log_id: int,
incident_id: int, step_key: str, pass_number: int = 1,
tenant_id: Optional[int] = None, visibility: str = "public",
created_by: Optional[int] = None):
"""Markiert einen Schritt als fehlgeschlagen."""
if step_key not in VALID_KEYS:
return
try:
if step_id:
await db.execute(
"""UPDATE refresh_pipeline_steps
SET status = 'error', completed_at = ?
WHERE id = ?""",
(_now_db(), step_id),
)
else:
await db.execute(
"""INSERT INTO refresh_pipeline_steps
(refresh_log_id, incident_id, step_key, pass_number, started_at, completed_at,
status, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, 'error', ?)""",
(refresh_log_id, incident_id, step_key, pass_number, _now_db(), _now_db(), tenant_id),
)
await db.commit()
except Exception as e:
logger.warning(f"Pipeline error_step({step_key}) DB-Fehler: {e}")
await _broadcast(ws_manager, incident_id, {
"step_key": step_key,
"status": "error",
"pass_number": pass_number,
}, visibility, created_by, tenant_id)

Datei anzeigen

@@ -5638,3 +5638,420 @@ body.tutorial-active .tutorial-cursor {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(var(--accent-rgb, 59, 130, 246), 0.15); box-shadow: 0 0 0 2px rgba(var(--accent-rgb, 59, 130, 246), 0.15);
} }
/* === Analysepipeline (Visualisierung n8n-Stil) === */
.pipeline-card { padding: 0; overflow: hidden; }
.pipeline-card .card-header { padding: var(--sp-lg) var(--sp-xl); border-bottom: 1px solid var(--border); }
.pipeline-header-meta { font-size: 12px; color: var(--text-secondary); }
.pipeline-body {
position: relative;
padding: var(--sp-3xl) var(--sp-xl);
background-color: var(--bg-card);
background-image:
linear-gradient(var(--pipeline-circuit, rgba(150, 121, 26, 0.045)) 1px, transparent 1px),
linear-gradient(90deg, var(--pipeline-circuit, rgba(150, 121, 26, 0.045)) 1px, transparent 1px),
radial-gradient(circle at 30px 30px, var(--pipeline-circuit-dot, rgba(150, 121, 26, 0.10)) 1.5px, transparent 2px);
background-size: 60px 60px, 60px 60px, 60px 60px;
}
[data-theme="light"] .pipeline-body {
--pipeline-circuit: rgba(31, 51, 89, 0.05);
--pipeline-circuit-dot: rgba(31, 51, 89, 0.10);
}
.pipeline-stage {
position: relative;
overflow: visible;
display: flex;
justify-content: center;
}
.pipeline-track {
display: inline-flex;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: var(--sp-md) 0;
}
.pipeline-row {
display: flex;
align-items: stretch;
gap: var(--sp-md);
flex-wrap: nowrap;
justify-content: flex-start;
}
.pipeline-row[data-direction="rtl"] {
flex-direction: row-reverse;
}
.pipeline-empty {
text-align: center;
color: var(--text-secondary);
padding: var(--sp-4xl) var(--sp-xl);
font-style: italic;
}
.pipeline-sidenote {
margin-top: var(--sp-xl);
padding: var(--sp-lg) var(--sp-xl);
border-left: 3px solid var(--accent);
background: var(--tint-accent-faint);
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
font-size: 13px;
color: var(--text-secondary);
max-width: 720px;
}
.pipeline-block {
position: relative;
flex: 0 0 168px;
min-height: 132px;
padding: var(--sp-lg) var(--sp-md);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
text-align: center;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
outline: none;
}
.pipeline-block:hover { transform: translateY(-2px); border-color: var(--accent); }
.pipeline-block:focus-visible { box-shadow: 0 0 0 3px var(--tint-accent-strong); }
.pipeline-block-icon {
width: 36px;
height: 36px;
color: var(--text-secondary);
margin-bottom: var(--sp-sm);
transition: color 0.3s ease;
}
.pipeline-block-icon svg { width: 100%; height: 100%; }
.pipeline-block-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--sp-xs);
line-height: 1.2;
}
.pipeline-block-count {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.3;
}
.pipeline-block-count small { display: block; opacity: 0.75; font-size: 10px; }
.pipeline-block-count .count-status { font-style: italic; opacity: 0.7; }
.pipeline-block-check {
position: absolute;
top: 6px;
right: 6px;
width: 18px;
height: 18px;
color: var(--success);
opacity: 0;
transform: scale(0.6);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.pipeline-block-check svg { width: 100%; height: 100%; }
.pipeline-block.status-pending { opacity: 0.55; }
.pipeline-block.status-pending .pipeline-block-icon { color: var(--text-tertiary); }
.pipeline-block.status-active {
border-color: var(--accent);
box-shadow: var(--glow-accent-strong);
animation: pipelinePulse 1.6s ease-in-out infinite;
}
.pipeline-block.status-active .pipeline-block-icon { color: var(--accent); }
@keyframes pipelinePulse {
0%, 100% { box-shadow: 0 0 8px rgba(150, 121, 26, 0.35), 0 0 0 1px var(--accent); }
50% { box-shadow: 0 0 22px rgba(150, 121, 26, 0.65), 0 0 0 2px var(--accent); }
}
.pipeline-block.status-done {
border-color: var(--success);
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--tint-success) 100%);
}
.pipeline-block.status-done .pipeline-block-icon { color: var(--success); }
.pipeline-block.status-done .pipeline-block-check { opacity: 1; transform: scale(1); }
.pipeline-block.status-error {
border-color: var(--error);
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--tint-error) 100%);
}
.pipeline-block.status-error .pipeline-block-icon { color: var(--error); }
.pipeline-arrow {
flex: 0 0 28px;
align-self: center;
height: 2px;
position: relative;
background: var(--border);
}
.pipeline-arrow::after {
content: "";
position: absolute;
right: -4px;
top: 50%;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid var(--border);
transform: translateY(-50%);
}
.pipeline-arrow.is-flowing {
background: linear-gradient(90deg, var(--accent), var(--accent) 50%, transparent 50%, transparent);
background-size: 12px 100%;
animation: pipelineFlow 0.8s linear infinite;
}
.pipeline-arrow.is-flowing::after { border-left-color: var(--accent); }
@keyframes pipelineFlow {
from { background-position: 0 0; }
to { background-position: 12px 0; }
}
/* Pfeil in rtl-Reihe: Pfeilkopf nach links, Animation rückwärts */
.pipeline-row[data-direction="rtl"] .pipeline-arrow::after {
border-left: none;
border-right: 6px solid var(--border);
right: auto;
left: -4px;
}
.pipeline-row[data-direction="rtl"] .pipeline-arrow.is-flowing::after {
border-right-color: var(--accent);
border-left-color: transparent;
}
.pipeline-row[data-direction="rtl"] .pipeline-arrow.is-flowing {
animation: pipelineFlowReverse 0.8s linear infinite;
}
@keyframes pipelineFlowReverse {
from { background-position: 12px 0; }
to { background-position: 0 0; }
}
/* Reihenwechsel-Pfeil (kompakter ↓ direkt unter dem letzten Block) */
.pipeline-uturn {
display: flex;
gap: var(--sp-md);
align-items: stretch;
height: 32px;
width: 100%;
margin: var(--sp-xs) 0;
pointer-events: none;
}
.uturn-spacer { flex: 0 0 168px; }
.uturn-arrow {
flex: 0 0 168px;
display: flex;
justify-content: center;
align-items: stretch;
}
.uturn-arrow svg {
width: 24px;
height: 100%;
overflow: visible;
}
.pipeline-uturn-path,
.pipeline-uturn-head {
fill: none;
stroke: var(--border);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.pipeline-uturn.is-flowing .pipeline-uturn-path {
stroke: var(--accent);
stroke-dasharray: 6 4;
animation: pipelineUturnDash 0.7s linear infinite;
}
.pipeline-uturn.is-flowing .pipeline-uturn-head { stroke: var(--accent); }
@keyframes pipelineUturnDash {
to { stroke-dashoffset: -20; }
}
.pipeline-loop {
position: absolute;
bottom: -10px;
right: -10px;
width: 26px;
height: 26px;
color: var(--accent);
background: var(--bg-card);
border-radius: 50%;
padding: 4px;
border: 1px solid var(--border);
opacity: 0.5;
transition: opacity 0.3s ease;
}
.pipeline-loop svg { width: 100%; height: 100%; }
.pipeline-stage.is-looping .pipeline-loop {
opacity: 1;
animation: pipelineLoop 1.2s ease-in-out;
}
@keyframes pipelineLoop {
0% { transform: rotate(0deg) scale(1); }
50% { transform: rotate(180deg) scale(1.3); }
100% { transform: rotate(360deg) scale(1); }
}
.pipeline-tooltip {
position: fixed;
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--accent);
padding: var(--sp-md) var(--sp-lg);
border-radius: var(--radius);
font-size: 12px;
line-height: 1.4;
width: 280px;
box-shadow: var(--shadow-md);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 9999;
}
.pipeline-tooltip.visible { opacity: 1; }
.pipeline-popup {
position: fixed;
inset: 0;
background: var(--backdrop);
display: flex;
align-items: center;
justify-content: center;
z-index: 9998;
}
.pipeline-popup-inner {
background: var(--bg-card);
border: 1px solid var(--accent);
border-radius: var(--radius-lg);
padding: var(--sp-3xl);
max-width: 480px;
width: 90%;
box-shadow: var(--shadow-lg);
position: relative;
}
.pipeline-popup-title {
font-family: var(--font-title);
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--sp-lg);
}
.pipeline-popup-text { color: var(--text-secondary); line-height: 1.6; font-size: 14px; }
.pipeline-popup-close {
position: absolute;
top: 8px;
right: 8px;
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 22px;
cursor: pointer;
border-radius: var(--radius);
}
.pipeline-popup-close:hover { background: var(--bg-hover); color: var(--text-primary); }
.pipeline-mini {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--sp-xs);
padding: var(--sp-md) 0;
margin-bottom: var(--sp-md);
}
.pipeline-mini-block {
width: 28px;
height: 28px;
padding: 5px;
border: 1px solid var(--border);
border-radius: 50%;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.pipeline-mini-block svg { width: 100%; height: 100%; }
.pipeline-mini-block.status-pending { opacity: 0.4; }
.pipeline-mini-block.status-active {
color: var(--accent);
border-color: var(--accent);
box-shadow: var(--glow-accent);
animation: pipelinePulse 1.6s ease-in-out infinite;
}
.pipeline-mini-block.status-done {
color: var(--success);
border-color: var(--success);
background: var(--tint-success);
}
.pipeline-mini-block.status-error {
color: var(--error);
border-color: var(--error);
background: var(--tint-error);
}
.pipeline-mini-sep {
width: 12px;
height: 1px;
background: var(--border);
}
@media (max-width: 900px) {
/* Snake auflösen, alle Reihen werden vertikal gestapelt */
.pipeline-row,
.pipeline-row[data-direction="rtl"] {
flex-direction: column;
align-items: stretch;
}
.pipeline-uturn { display: none; }
.pipeline-block { flex: 0 0 auto; width: 100%; min-height: auto; flex-direction: row; padding: var(--sp-md); text-align: left; gap: var(--sp-md); }
.pipeline-block-icon { width: 28px; height: 28px; margin-bottom: 0; flex-shrink: 0; }
.pipeline-block-title { margin-bottom: 2px; }
.pipeline-block-count { font-size: 11px; }
.pipeline-arrow {
flex: 0 0 18px;
width: 2px;
height: 18px;
margin: 0 auto;
align-self: center;
background: var(--border);
}
.pipeline-arrow::after,
.pipeline-row[data-direction="rtl"] .pipeline-arrow::after {
right: 50%;
left: auto;
top: auto;
bottom: -4px;
border-top: 6px solid var(--border);
border-bottom: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
transform: translateX(50%);
}
.pipeline-arrow.is-flowing,
.pipeline-row[data-direction="rtl"] .pipeline-arrow.is-flowing {
background: linear-gradient(180deg, var(--accent), var(--accent) 50%, transparent 50%, transparent);
background-size: 100% 12px;
animation: pipelineFlowVertical 0.8s linear infinite;
}
.pipeline-arrow.is-flowing::after,
.pipeline-row[data-direction="rtl"] .pipeline-arrow.is-flowing::after {
border-top-color: var(--accent);
border-right-color: transparent;
border-left-color: transparent;
}
@keyframes pipelineFlowVertical {
from { background-position: 0 0; }
to { background-position: 0 12px; }
}
}
@media (prefers-reduced-motion: reduce) {
.pipeline-block,
.pipeline-mini-block { animation: none !important; }
.pipeline-arrow.is-flowing { animation: none !important; }
.pipeline-block.status-active { box-shadow: var(--glow-accent); }
.pipeline-stage.is-looping .pipeline-loop { animation: none !important; opacity: 1; }
}

Datei anzeigen

@@ -198,6 +198,7 @@
<button class="tab-btn" data-tab="timeline">Ereignis-Timeline</button> <button class="tab-btn" data-tab="timeline">Ereignis-Timeline</button>
<button class="tab-btn" data-tab="karte">Geografische Verteilung</button> <button class="tab-btn" data-tab="karte">Geografische Verteilung</button>
<button class="tab-btn" data-tab="faktencheck">Faktencheck</button> <button class="tab-btn" data-tab="faktencheck">Faktencheck</button>
<button class="tab-btn" data-tab="pipeline">Analysepipeline</button>
<button class="tab-btn" data-tab="quellen">Quellenübersicht</button> <button class="tab-btn" data-tab="quellen">Quellenübersicht</button>
</div> </div>
@@ -281,6 +282,23 @@
</div> </div>
</div> </div>
<div class="tab-panel" id="panel-pipeline">
<div class="card pipeline-card" id="pipeline-card">
<div class="card-header">
<div class="card-title">Analysepipeline</div>
<span class="pipeline-header-meta" id="pipeline-header-meta"></span>
</div>
<div class="pipeline-body">
<div class="pipeline-stage" id="pipeline-stage" aria-label="Analysepipeline-Visualisierung">
<div class="pipeline-empty" id="pipeline-empty">Noch nie aktualisiert. Starte den ersten Refresh.</div>
</div>
<aside class="pipeline-sidenote" id="pipeline-sidenote" hidden>
Recherche-Lagen werden mehrfach evaluiert, um das Bild Schritt für Schritt aufzubauen.
</aside>
</div>
</div>
</div>
<div class="tab-panel" id="panel-quellen"> <div class="tab-panel" id="panel-quellen">
<div class="card source-overview-card"> <div class="card source-overview-card">
<div class="card-header"> <div class="card-header">
@@ -628,6 +646,7 @@
<script src="/static/js/ws.js?v=20260316b"></script> <script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260427a"></script> <script src="/static/js/components.js?v=20260427a"></script>
<script src="/static/js/layout.js?v=20260316b"></script> <script src="/static/js/layout.js?v=20260316b"></script>
<script src="/static/js/pipeline.js?v=20260501a"></script>
<script src="/static/js/app.js?v=20260427c"></script> <script src="/static/js/app.js?v=20260427c"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script> <script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script> <script src="/static/js/tutorial.js?v=20260316z"></script>
@@ -687,7 +706,8 @@
</div> </div>
<div class="progress-popup-body"> <div class="progress-popup-body">
<div class="progress-popup-pass" id="progress-popup-pass" style="display:none;"></div> <div class="progress-popup-pass" id="progress-popup-pass" style="display:none;"></div>
<div class="progress-checklist" id="progress-checklist"> <div class="pipeline-mini" id="progress-pipeline-mini" aria-label="Analyseschritte"></div>
<div class="progress-checklist" id="progress-checklist" style="display:none;">
<div class="progress-check-item" data-step="queued"> <div class="progress-check-item" data-step="queued">
<span class="progress-check-icon"></span> <span class="progress-check-icon"></span>
<span class="progress-check-label">In Warteschlange</span> <span class="progress-check-label">In Warteschlange</span>

Datei anzeigen

@@ -133,6 +133,10 @@ const API = {
return this._request('GET', `/incidents/${incidentId}/factchecks`); return this._request('GET', `/incidents/${incidentId}/factchecks`);
}, },
getPipeline(incidentId) {
return this._request('GET', `/incidents/${incidentId}/pipeline`);
},
getSnapshots(incidentId) { getSnapshots(incidentId) {
return this._request('GET', `/incidents/${incidentId}/snapshots`); return this._request('GET', `/incidents/${incidentId}/snapshots`);
}, },

Datei anzeigen

@@ -833,6 +833,11 @@ const App = {
this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels); this.renderIncidentDetail(incident, articles, factchecks, snapshots, locations, categoryLabels);
// Pipeline an die geladene Lage binden (laedt /api/incidents/{id}/pipeline)
if (typeof Pipeline !== 'undefined' && Pipeline.bindToIncident) {
Pipeline.bindToIncident(id).catch(err => console.warn('pipeline-bind:', err));
}
// Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite) // Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite)
this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err)); this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err));

Datei anzeigen

@@ -3,7 +3,7 @@
* Nur ein Tab-Panel gleichzeitig sichtbar, pro Lage gemerkt in localStorage. * Nur ein Tab-Panel gleichzeitig sichtbar, pro Lage gemerkt in localStorage.
*/ */
const LayoutManager = { const LayoutManager = {
TAB_ORDER: ['zusammenfassung', 'lagebild', 'timeline', 'karte', 'faktencheck', 'quellen'], TAB_ORDER: ['zusammenfassung', 'lagebild', 'timeline', 'karte', 'faktencheck', 'pipeline', 'quellen'],
_currentIncidentId: null, _currentIncidentId: null,
_initialized: false, _initialized: false,

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

@@ -0,0 +1,515 @@
/**
* 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") als 3x3-Snake rendern. */
_render() {
const stage = document.getElementById('pipeline-stage');
const meta = document.getElementById('pipeline-header-meta');
const sidenote = document.getElementById('pipeline-sidenote');
if (!stage) return;
if (meta) meta.textContent = this._formatHeader();
if (sidenote) sidenote.hidden = !this._isResearch;
// Brandneue Lage ohne Refresh
if (!this._lastRefreshHeader) {
this._renderEmpty('Noch nie aktualisiert. Starte den ersten Refresh.');
return;
}
// Sichtbare Blöcke (skipped komplett ausgeblendet, Anforderung 4b)
const visible = (this._definition || []).filter(s => {
const st = this._stateByKey[s.key];
return !st || st.status !== 'skipped';
});
// In Dreier-Reihen aufteilen, Snake-Direction abwechselnd
const ROW_SIZE = 3;
const rows = [];
for (let i = 0; i < visible.length; i += ROW_SIZE) {
rows.push({
steps: visible.slice(i, i + ROW_SIZE),
direction: (rows.length % 2 === 0) ? 'ltr' : 'rtl',
});
}
let trackHtml = '';
rows.forEach((row, rowIdx) => {
const isLastRow = rowIdx === rows.length - 1;
let rowHtml = `<div class="pipeline-row" data-direction="${row.direction}">`;
row.steps.forEach((s, i) => {
const isLastBlockOverall = isLastRow && i === row.steps.length - 1;
rowHtml += this._renderBlock(s, isLastBlockOverall);
// Inner-Pfeil zwischen Blöcken einer Reihe (nicht hinter dem letzten)
if (i < row.steps.length - 1) {
rowHtml += `<div class="pipeline-arrow" data-from="${s.key}" data-arrow-type="inner"></div>`;
}
});
rowHtml += '</div>';
trackHtml += rowHtml;
// U-Turn-Pfeil zwischen dieser und der nächsten Reihe
if (!isLastRow) {
const lastInRow = row.steps[row.steps.length - 1];
const side = row.direction === 'ltr' ? 'right' : 'left';
trackHtml += this._renderUturn(side, lastInRow.key);
}
});
stage.innerHTML = `<div class="pipeline-track">${trackHtml}</div>`;
this._bindBlockEvents(stage);
},
_renderBlock(stepDef, isLastOverall) {
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 loopMark = isLastOverall && 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>
`;
},
/** Kompakter Reihenwechsel-Pfeil: kurzer ↓ direkt unter dem letzten Block der oberen Reihe. */
_renderUturn(side, fromKey) {
const arrowSvg = `
<div class="uturn-arrow">
<svg viewBox="0 0 24 32" preserveAspectRatio="xMidYMid meet">
<path d="M 12 2 L 12 24" class="pipeline-uturn-path"/>
<polyline points="6,18 12,24 18,18" class="pipeline-uturn-head"/>
</svg>
</div>`;
const spacers = '<span class="uturn-spacer"></span><span class="uturn-spacer"></span>';
const inner = side === 'right' ? (spacers + arrowSvg) : (arrowSvg + spacers);
return `
<div class="pipeline-uturn" data-side="${side}" data-from="${fromKey}" data-arrow-type="uturn" aria-hidden="true">
${inner}
</div>
`;
},
/** 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/U-Turn zum nächsten Block markieren (alles mit data-from)
stage.querySelectorAll('.pipeline-arrow, .pipeline-uturn')
.forEach(a => a.classList.remove('is-flowing'));
if (status === 'done') {
const next = stage.querySelector(`[data-from="${stepKey}"]`);
if (next) next.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());