Promote develop → main (2026-05-01 12:48 UTC) #7
@@ -677,6 +677,7 @@ class AgentOrchestrator:
|
||||
from agents.analyzer import AnalyzerAgent
|
||||
from agents.factchecker import FactCheckerAgent
|
||||
from feeds.rss_parser import RSSParser
|
||||
from services import pipeline_tracker as _pipe
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
@@ -719,6 +720,47 @@ class AgentOrchestrator:
|
||||
log_id = cursor.lastrowid
|
||||
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_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
|
||||
@@ -741,6 +783,23 @@ class AgentOrchestrator:
|
||||
)
|
||||
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
|
||||
async def _rss_pipeline():
|
||||
"""RSS-Feed-Suche (Feed-Selektion + dynamische Keywords + Parsing)."""
|
||||
@@ -880,6 +939,9 @@ class AgentOrchestrator:
|
||||
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
|
||||
return articles, None
|
||||
|
||||
# Pipeline-Schritt 2: Nachrichten sammeln (Start)
|
||||
await _pipe_start("collect")
|
||||
|
||||
# Pipelines parallel starten (RSS + WebSearch + Podcasts + optional Telegram)
|
||||
pipelines = [_rss_pipeline(), _web_search_pipeline(), _podcast_pipeline()]
|
||||
if include_telegram:
|
||||
@@ -910,6 +972,15 @@ class AgentOrchestrator:
|
||||
|
||||
# Alle Ergebnisse zusammenführen
|
||||
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)
|
||||
seen_urls = set()
|
||||
@@ -922,6 +993,7 @@ class AgentOrchestrator:
|
||||
dupes_removed = len(all_results) - len(unique_results)
|
||||
if dupes_removed > 0:
|
||||
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
|
||||
for article in unique_results:
|
||||
@@ -978,6 +1050,10 @@ class AgentOrchestrator:
|
||||
|
||||
new_candidates.append(article)
|
||||
|
||||
# Pipeline-Schritt 4: Relevanz bewerten (Start)
|
||||
await _pipe_start("relevance")
|
||||
_candidates_before_topic = len(new_candidates)
|
||||
|
||||
# --- Semantischer Topic-Filter (Haiku) ---
|
||||
# Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema
|
||||
# der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten.
|
||||
@@ -988,6 +1064,7 @@ class AgentOrchestrator:
|
||||
)
|
||||
if _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 ---
|
||||
new_count = 0
|
||||
@@ -1019,6 +1096,8 @@ class AgentOrchestrator:
|
||||
|
||||
# Geoparsing: Orte aus neuen Artikeln extrahieren und speichern
|
||||
if new_articles_for_analysis:
|
||||
# Pipeline-Schritt 5: Orte erkennen (Start)
|
||||
await _pipe_start("geoparsing")
|
||||
try:
|
||||
from agents.geoparsing import geoparse_articles
|
||||
incident_context = f"{title} - {description}"
|
||||
@@ -1049,8 +1128,12 @@ class AgentOrchestrator:
|
||||
)
|
||||
await db.commit()
|
||||
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:
|
||||
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
|
||||
if new_count > 0:
|
||||
@@ -1196,6 +1279,10 @@ class AgentOrchestrator:
|
||||
articles_for_check = [dict(row) for row in await cursor.fetchall()]
|
||||
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
|
||||
logger.info("Starte Analyse und Faktencheck parallel...")
|
||||
analysis_result, factcheck_result = await asyncio.gather(
|
||||
@@ -1205,6 +1292,8 @@ class AgentOrchestrator:
|
||||
|
||||
analysis, analysis_usage = analysis_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 ---
|
||||
if analysis_usage:
|
||||
@@ -1458,6 +1547,13 @@ class AgentOrchestrator:
|
||||
|
||||
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
|
||||
try:
|
||||
from services.post_refresh_qc import run_post_refresh_qc
|
||||
@@ -1469,6 +1565,12 @@ class AgentOrchestrator:
|
||||
)
|
||||
except Exception as 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)
|
||||
if not is_first_refresh:
|
||||
if self._ws_manager:
|
||||
@@ -1525,6 +1627,32 @@ class AgentOrchestrator:
|
||||
db, incident_id, title, visibility, created_by, tenant_id, db_notifications,
|
||||
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 uebersprungen wurde (kein neuer Artikel und Summary existiert),
|
||||
# die noch offenen Pipeline-Schritte als uebersprungen 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 uebersprungenem 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)
|
||||
await db.execute(
|
||||
|
||||
@@ -117,6 +117,22 @@ CREATE TABLE IF NOT EXISTS refresh_log (
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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.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)
|
||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'")
|
||||
if not await cursor.fetchone():
|
||||
|
||||
@@ -613,6 +613,98 @@ async def get_factchecks(
|
||||
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"
|
||||
|
||||
# Juengsten Refresh-Log waehlen: 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")
|
||||
async def get_locations(
|
||||
incident_id: int,
|
||||
|
||||
230
src/services/pipeline_tracker.py
Normale Datei
230
src/services/pipeline_tracker.py
Normale Datei
@@ -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)
|
||||
@@ -5638,3 +5638,335 @@ body.tutorial-active .tutorial-cursor {
|
||||
border-color: var(--accent);
|
||||
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-x: auto;
|
||||
overflow-y: visible;
|
||||
}
|
||||
.pipeline-track {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--sp-md);
|
||||
min-width: max-content;
|
||||
padding: var(--sp-md) 0;
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
|
||||
.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) {
|
||||
.pipeline-track { flex-direction: column; min-width: auto; align-items: stretch; }
|
||||
.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 {
|
||||
right: 50%;
|
||||
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 {
|
||||
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 { border-top-color: var(--accent); }
|
||||
@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; }
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
<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="faktencheck">Faktencheck</button>
|
||||
<button class="tab-btn" data-tab="pipeline">Analysepipeline</button>
|
||||
<button class="tab-btn" data-tab="quellen">Quellenübersicht</button>
|
||||
</div>
|
||||
|
||||
@@ -281,6 +282,23 @@
|
||||
</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="card source-overview-card">
|
||||
<div class="card-header">
|
||||
@@ -628,6 +646,7 @@
|
||||
<script src="/static/js/ws.js?v=20260316b"></script>
|
||||
<script src="/static/js/components.js?v=20260427a"></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/cluster-data.js?v=20260322f"></script>
|
||||
<script src="/static/js/tutorial.js?v=20260316z"></script>
|
||||
@@ -687,7 +706,8 @@
|
||||
</div>
|
||||
<div class="progress-popup-body">
|
||||
<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">
|
||||
<span class="progress-check-icon">○</span>
|
||||
<span class="progress-check-label">In Warteschlange</span>
|
||||
|
||||
@@ -133,6 +133,10 @@ const API = {
|
||||
return this._request('GET', `/incidents/${incidentId}/factchecks`);
|
||||
},
|
||||
|
||||
getPipeline(incidentId) {
|
||||
return this._request('GET', `/incidents/${incidentId}/pipeline`);
|
||||
},
|
||||
|
||||
getSnapshots(incidentId) {
|
||||
return this._request('GET', `/incidents/${incidentId}/snapshots`);
|
||||
},
|
||||
|
||||
@@ -833,6 +833,11 @@ const App = {
|
||||
|
||||
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)
|
||||
this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err));
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Nur ein Tab-Panel gleichzeitig sichtbar, pro Lage gemerkt in localStorage.
|
||||
*/
|
||||
const LayoutManager = {
|
||||
TAB_ORDER: ['zusammenfassung', 'lagebild', 'timeline', 'karte', 'faktencheck', 'quellen'],
|
||||
TAB_ORDER: ['zusammenfassung', 'lagebild', 'timeline', 'karte', 'faktencheck', 'pipeline', 'quellen'],
|
||||
_currentIncidentId: null,
|
||||
_initialized: false,
|
||||
|
||||
|
||||
474
src/static/js/pipeline.js
Normale Datei
474
src/static/js/pipeline.js
Normale Datei
@@ -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 — 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">×</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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => Pipeline.init());
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren