Commits vergleichen
4 Commits
develop
...
bfa4d5fd78
| Autor | SHA1 | Datum | |
|---|---|---|---|
| bfa4d5fd78 | |||
| c57ac6c6d8 | |||
| d9e5733cfb | |||
| 9574308c29 |
@@ -1,22 +1,4 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"version": "2026-05-03T15:21Z",
|
|
||||||
"date": "2026-05-03",
|
|
||||||
"title": "Übersichtlichere Navigation in der Seitenleiste",
|
|
||||||
"items": [
|
|
||||||
"Schaltflächen in der Seitenleiste haben jetzt klarere Icons und kürzere Beschriftungen",
|
|
||||||
"Der Feedback-Button zeigt nun ein Brief-Symbol für bessere Erkennbarkeit"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "2026-04-30T23:12Z",
|
|
||||||
"date": "2026-04-30",
|
|
||||||
"title": "Hintergrundbild-Unschärfe zuverlässiger und vollständiger",
|
|
||||||
"items": [
|
|
||||||
"Der Weichzeichner-Effekt wird jetzt stabiler angezeigt und aktualisiert sich korrekt",
|
|
||||||
"Der Header-Bereich wird nun ebenfalls korrekt mit dem Unschärfe-Effekt versehen"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "2026-04-29T22:30Z",
|
"version": "2026-04-29T22:30Z",
|
||||||
"date": "2026-04-29",
|
"date": "2026-04-29",
|
||||||
|
|||||||
@@ -11,8 +11,4 @@ python-multipart
|
|||||||
aiosmtplib
|
aiosmtplib
|
||||||
geonamescache>=2.0
|
geonamescache>=2.0
|
||||||
telethon
|
telethon
|
||||||
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
|
|
||||||
Jinja2>=3.1
|
|
||||||
weasyprint>=68.0
|
|
||||||
python-docx>=1.2
|
|
||||||
pikepdf>=9.0
|
pikepdf>=9.0
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
|||||||
- "summary": Zusammenfassung auf {output_language} mit Quellenverweisen [1], [2] etc. im Text (Markdown-Überschriften ## erlaubt wenn sinnvoll, aber KEINE "## ZUSAMMENFASSUNG"/"## ÜBERBLICK"-Sektion)
|
- "summary": Zusammenfassung auf {output_language} mit Quellenverweisen [1], [2] etc. im Text (Markdown-Überschriften ## erlaubt wenn sinnvoll, aber KEINE "## ZUSAMMENFASSUNG"/"## ÜBERBLICK"-Sektion)
|
||||||
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
|
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
|
||||||
- "key_facts": Array von bestätigten Kernfakten (Strings, in Ausgabesprache)
|
- "key_facts": Array von bestätigten Kernfakten (Strings, in Ausgabesprache)
|
||||||
|
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
|
||||||
|
|
||||||
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
|||||||
- "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc.
|
- "summary": Das strukturierte Briefing als Markdown-Text mit Quellenverweisen [1], [2] etc.
|
||||||
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
|
- "sources": Array von Quellenobjekten, je: {{"nr": 1, "name": "Quellenname", "url": "https://..."}}
|
||||||
- "key_facts": Array von gesicherten Kernfakten (Strings, in Ausgabesprache)
|
- "key_facts": Array von gesicherten Kernfakten (Strings, in Ausgabesprache)
|
||||||
|
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für fremdsprachige Artikel)
|
||||||
|
|
||||||
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||||
|
|
||||||
@@ -147,6 +149,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
|||||||
- "summary": Aktualisierte Zusammenfassung mit Quellenverweisen [1], [2] etc.
|
- "summary": Aktualisierte Zusammenfassung mit Quellenverweisen [1], [2] etc.
|
||||||
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
|
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
|
||||||
- "key_facts": Array aller aktuellen Kernfakten (in Ausgabesprache)
|
- "key_facts": Array aller aktuellen Kernfakten (in Ausgabesprache)
|
||||||
|
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
|
||||||
|
|
||||||
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||||
|
|
||||||
@@ -198,6 +201,7 @@ Antworte AUSSCHLIESSLICH als JSON-Objekt mit diesen Feldern:
|
|||||||
- "summary": Das aktualisierte Briefing als Markdown-Text mit Quellenverweisen
|
- "summary": Das aktualisierte Briefing als Markdown-Text mit Quellenverweisen
|
||||||
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
|
- "sources": Array mit NUR den NEUEN Quellen aus den neuen Meldungen, je: {{"nr": <fortlaufende ganze Zahl, KEINE Buchstaben-Suffixe>, "name": "Quellenname", "url": "https://..."}}. Alte Quellen werden automatisch gemerged.
|
||||||
- "key_facts": Array aller gesicherten Kernfakten (in Ausgabesprache)
|
- "key_facts": Array aller gesicherten Kernfakten (in Ausgabesprache)
|
||||||
|
- "translations": Array von Objekten mit "article_id", "headline_de", "content_de" (nur für neue fremdsprachige Artikel)
|
||||||
|
|
||||||
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
Antworte NUR mit dem JSON-Objekt. Keine Einleitung, keine Erklärung."""
|
||||||
|
|
||||||
@@ -792,5 +796,5 @@ class AnalyzerAgent:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {"summary": summary, "sources": sources, "key_facts": []}
|
return {"summary": summary, "sources": sources, "key_facts": [], "translations": []}
|
||||||
|
|
||||||
|
|||||||
@@ -21,21 +21,15 @@ from source_rules import (
|
|||||||
|
|
||||||
logger = logging.getLogger("osint.orchestrator")
|
logger = logging.getLogger("osint.orchestrator")
|
||||||
|
|
||||||
# Reputations-Score nach Quellenkategorie (fuer Relevanz-Scoring).
|
# Reputations-Score nach Quellenkategorie (für Relevanz-Scoring)
|
||||||
# Keys muessen mit den tatsaechlichen DB-Werten in sources.category uebereinstimmen
|
|
||||||
# (siehe DOMAIN_CATEGORY_MAP in source_rules.py).
|
|
||||||
CATEGORY_REPUTATION = {
|
CATEGORY_REPUTATION = {
|
||||||
"nachrichtenagentur": 1.0, # Reuters, AP, dpa, AFP — Primärquellen
|
"nachrichten_de": 0.9,
|
||||||
"behoerde": 1.0, # BMI, BSI, Europol — offizielle Quellen
|
"nachrichten_int": 0.9,
|
||||||
"oeffentlich-rechtlich": 0.95, # tagesschau, ZDF, ARD, BBC, ORF
|
"presseagenturen": 1.0,
|
||||||
"qualitaetszeitung": 0.85, # Spiegel, Zeit, FAZ, NZZ, Süddeutsche
|
"behoerden": 1.0,
|
||||||
"think-tank": 0.85, # SWP, IISS, Brookings, Chatham House
|
"fachmedien": 0.8,
|
||||||
"fachmedien": 0.8, # heise, golem, netzpolitik, Handelsblatt
|
"international": 0.7,
|
||||||
"international": 0.75, # CNN, Guardian, NYT, Al Jazeera, France24
|
"sonstige": 0.4,
|
||||||
"regional": 0.65, # regionale Tageszeitungen
|
|
||||||
"telegram": 0.5, # OSINT-Kanaele — gemischte Qualitaet
|
|
||||||
"sonstige": 0.4, # unkategorisiert
|
|
||||||
"boulevard": 0.3, # Bild, Sun etc.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Research-Modus: Automatisch 3 Durchläufe für optimale Ergebnisse
|
# Research-Modus: Automatisch 3 Durchläufe für optimale Ergebnisse
|
||||||
@@ -489,9 +483,6 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
logger.info(f"Lage {incident_id} aus Warteschlange entfernt (removed={removed})")
|
logger.info(f"Lage {incident_id} aus Warteschlange entfernt (removed={removed})")
|
||||||
|
|
||||||
# refresh_log-Eintrag schreiben, damit Auto-Refresh nicht im naechsten Tick erneut einreiht
|
|
||||||
await self._log_queued_cancellation(incident_id)
|
|
||||||
|
|
||||||
# Send cancelled event
|
# Send cancelled event
|
||||||
if self._ws_manager:
|
if self._ws_manager:
|
||||||
try:
|
try:
|
||||||
@@ -642,28 +633,6 @@ class AgentOrchestrator:
|
|||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
async def _log_queued_cancellation(self, incident_id: int):
|
|
||||||
"""Schreibt einen cancelled-Eintrag fuer einen Queue-Abbruch (Lage war noch nicht laufend).
|
|
||||||
Verhindert, dass der Auto-Refresh-Scheduler im naechsten Tick sofort wieder einreiht."""
|
|
||||||
from database import get_db
|
|
||||||
db = await get_db()
|
|
||||||
try:
|
|
||||||
cur = await db.execute("SELECT tenant_id FROM incidents WHERE id = ?", (incident_id,))
|
|
||||||
row = await cur.fetchone()
|
|
||||||
tid = row["tenant_id"] if row else None
|
|
||||||
now_str = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
await db.execute(
|
|
||||||
"""INSERT INTO refresh_log (incident_id, started_at, completed_at, status,
|
|
||||||
trigger_type, error_message, tenant_id)
|
|
||||||
VALUES (?, ?, ?, 'cancelled', 'manual', 'Aus Warteschlange entfernt', ?)""",
|
|
||||||
(incident_id, now_str, now_str, tid),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Konnte Queue-Cancel nicht in refresh_log loggen: {e}")
|
|
||||||
finally:
|
|
||||||
await db.close()
|
|
||||||
|
|
||||||
async def _mark_refresh_failed(self, incident_id: int, error: str):
|
async def _mark_refresh_failed(self, incident_id: int, error: str):
|
||||||
"""Markiert den laufenden Refresh-Log-Eintrag als error."""
|
"""Markiert den laufenden Refresh-Log-Eintrag als error."""
|
||||||
from database import get_db
|
from database import get_db
|
||||||
@@ -708,7 +677,6 @@ 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:
|
||||||
@@ -751,47 +719,6 @@ 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
|
||||||
@@ -814,23 +741,6 @@ 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)."""
|
||||||
@@ -875,7 +785,7 @@ class AgentOrchestrator:
|
|||||||
return articles, feed_usage
|
return articles, feed_usage
|
||||||
|
|
||||||
async def _web_search_pipeline():
|
async def _web_search_pipeline():
|
||||||
"""Claude WebSearch-Recherche mit Vorselektion eingetragener Web-Quellen."""
|
"""Claude WebSearch-Recherche."""
|
||||||
researcher = ResearcherAgent()
|
researcher = ResearcherAgent()
|
||||||
# Bestehende Artikel als Kontext mitgeben (Research + Adhoc)
|
# Bestehende Artikel als Kontext mitgeben (Research + Adhoc)
|
||||||
existing_for_context = None
|
existing_for_context = None
|
||||||
@@ -886,34 +796,13 @@ class AgentOrchestrator:
|
|||||||
"source_url": row["source_url"]}
|
"source_url": row["source_url"]}
|
||||||
for row in existing_db_articles_full
|
for row in existing_db_articles_full
|
||||||
]
|
]
|
||||||
|
results, usage = await researcher.search(
|
||||||
# Web-Quellen vorselektieren (Haiku) — nur thematisch passende werden Claude im Prompt empfohlen
|
|
||||||
preferred_sources = []
|
|
||||||
try:
|
|
||||||
from source_rules import get_feeds_with_metadata
|
|
||||||
web_sources = await get_feeds_with_metadata(tenant_id=tenant_id, source_type="web_source")
|
|
||||||
if web_sources:
|
|
||||||
preferred_sources, web_sel_usage = await researcher.select_relevant_web_sources(
|
|
||||||
title, description, web_sources,
|
|
||||||
)
|
|
||||||
if web_sel_usage:
|
|
||||||
usage_acc.add(web_sel_usage)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Web-Source-Vorselektion fehlgeschlagen (Pipeline laeuft weiter): {e}")
|
|
||||||
preferred_sources = []
|
|
||||||
|
|
||||||
results, usage, parse_failed = await researcher.search(
|
|
||||||
title, description, incident_type,
|
title, description, incident_type,
|
||||||
international=international, user_id=user_id,
|
international=international, user_id=user_id,
|
||||||
existing_articles=existing_for_context,
|
existing_articles=existing_for_context,
|
||||||
preferred_sources=preferred_sources,
|
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(f"Claude-Recherche: {len(results)} Ergebnisse")
|
||||||
f"Claude-Recherche: {len(results)} Ergebnisse"
|
return results, usage
|
||||||
+ (f" (mit {len(preferred_sources)} Web-Quellen-Hinweis)" if preferred_sources else "")
|
|
||||||
+ (" (Parser fehlgeschlagen)" if parse_failed else "")
|
|
||||||
)
|
|
||||||
return results, usage, parse_failed
|
|
||||||
|
|
||||||
async def _podcast_pipeline():
|
async def _podcast_pipeline():
|
||||||
"""Podcast-Episoden-Suche (nur adhoc-Lagen, nur mit vorhandenen Transkripten)."""
|
"""Podcast-Episoden-Suche (nur adhoc-Lagen, nur mit vorhandenen Transkripten)."""
|
||||||
@@ -988,9 +877,6 @@ 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:
|
||||||
@@ -999,7 +885,7 @@ class AgentOrchestrator:
|
|||||||
pipeline_results = await asyncio.gather(*pipelines)
|
pipeline_results = await asyncio.gather(*pipelines)
|
||||||
|
|
||||||
(rss_articles, rss_feed_usage) = pipeline_results[0]
|
(rss_articles, rss_feed_usage) = pipeline_results[0]
|
||||||
(search_results, search_usage, search_parse_failed) = pipeline_results[1]
|
(search_results, search_usage) = pipeline_results[1]
|
||||||
(podcast_articles, _podcast_usage) = pipeline_results[2]
|
(podcast_articles, _podcast_usage) = pipeline_results[2]
|
||||||
telegram_articles = pipeline_results[3][0] if include_telegram else []
|
telegram_articles = pipeline_results[3][0] if include_telegram else []
|
||||||
|
|
||||||
@@ -1021,15 +907,6 @@ 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()
|
||||||
@@ -1042,7 +919,6 @@ 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:
|
||||||
@@ -1099,10 +975,6 @@ 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.
|
||||||
@@ -1113,7 +985,6 @@ 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
|
||||||
@@ -1145,8 +1016,6 @@ 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}"
|
||||||
@@ -1177,12 +1046,8 @@ 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:
|
||||||
@@ -1328,10 +1193,6 @@ 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(
|
||||||
@@ -1341,8 +1202,6 @@ 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:
|
||||||
@@ -1435,64 +1294,20 @@ class AgentOrchestrator:
|
|||||||
snap_articles, snap_fcs, log_id, now, tenant_id),
|
snap_articles, snap_fcs, log_id, now, tenant_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Translations werden vom dedizierten Translator-Agent unten
|
# Übersetzungen aktualisieren (nur für gültige DB-IDs)
|
||||||
# erzeugt (frueher inline im Analyzer-Output, das war token-
|
for translation in analysis.get("translations", []):
|
||||||
# instabil und schaetzte regelmaessig content_de aus).
|
article_id = translation.get("article_id")
|
||||||
|
if isinstance(article_id, int):
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE articles SET headline_de = ?, content_de = ? WHERE id = ? AND incident_id = ?",
|
||||||
|
(translation.get("headline_de"), translation.get("content_de"), article_id, incident_id),
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Cancel-Check nach paralleler Verarbeitung
|
# Cancel-Check nach paralleler Verarbeitung
|
||||||
self._check_cancelled(incident_id)
|
self._check_cancelled(incident_id)
|
||||||
|
|
||||||
# --- Translator (Haiku) fuer fremdsprachige Artikel ohne DE-Texte ---
|
|
||||||
# Idempotent: nur Artikel ohne headline_de/content_de werden geholt.
|
|
||||||
# Lauft nach der Analyse (Lagebild ist schon committed) und vor QC
|
|
||||||
# (damit normalize_umlaut_articles auch die frischen DE-Texte fasst).
|
|
||||||
try:
|
|
||||||
tr_cursor = await db.execute(
|
|
||||||
"""SELECT id, headline, content_original, language
|
|
||||||
FROM articles
|
|
||||||
WHERE incident_id = ?
|
|
||||||
AND language IS NOT NULL AND LOWER(language) != 'de'
|
|
||||||
AND (headline_de IS NULL OR headline_de = ''
|
|
||||||
OR content_de IS NULL OR content_de = '')""",
|
|
||||||
(incident_id,),
|
|
||||||
)
|
|
||||||
pending_translations = [dict(r) for r in await tr_cursor.fetchall()]
|
|
||||||
if pending_translations:
|
|
||||||
logger.info(
|
|
||||||
"Translator fuer Incident %d: %d Artikel ohne DE-Uebersetzung",
|
|
||||||
incident_id, len(pending_translations),
|
|
||||||
)
|
|
||||||
from agents.translator import translate_articles
|
|
||||||
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
|
|
||||||
translations = await translate_articles(
|
|
||||||
pending_translations,
|
|
||||||
output_lang="de",
|
|
||||||
usage_accumulator=usage_acc,
|
|
||||||
)
|
|
||||||
for t in translations:
|
|
||||||
hd = t.get("headline_de")
|
|
||||||
cd = t.get("content_de")
|
|
||||||
if hd:
|
|
||||||
hd, _ = _norm_de2(hd)
|
|
||||||
if cd:
|
|
||||||
cd, _ = _norm_de2(cd)
|
|
||||||
if hd or cd:
|
|
||||||
await db.execute(
|
|
||||||
"UPDATE articles SET headline_de = COALESCE(?, headline_de), "
|
|
||||||
"content_de = COALESCE(?, content_de) WHERE id = ? AND incident_id = ?",
|
|
||||||
(hd, cd, t["id"], incident_id),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
logger.info(
|
|
||||||
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
|
|
||||||
incident_id, len(translations), len(pending_translations),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Translator-Fehler fuer Incident %d: %s", incident_id, e, exc_info=True)
|
|
||||||
# Refresh trotz Translator-Fehler weiterlaufen lassen
|
|
||||||
|
|
||||||
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
|
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
|
||||||
# Basis ist jetzt das frisch generierte Lagebild (autoritativ, thematisch sauber).
|
# Basis ist jetzt das frisch generierte Lagebild (autoritativ, thematisch sauber).
|
||||||
# Zeitstempel und Quellen kommen aus den jüngsten belegenden Artikeln.
|
# Zeitstempel und Quellen kommen aus den jüngsten belegenden Artikeln.
|
||||||
@@ -1640,13 +1455,6 @@ 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
|
||||||
@@ -1658,12 +1466,6 @@ 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:
|
||||||
@@ -1720,32 +1522,6 @@ 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(
|
||||||
@@ -1792,11 +1568,6 @@ class AgentOrchestrator:
|
|||||||
|
|
||||||
logger.info(f"Refresh für Lage {incident_id} abgeschlossen: {new_count} neue Artikel")
|
logger.info(f"Refresh für Lage {incident_id} abgeschlossen: {new_count} neue Artikel")
|
||||||
|
|
||||||
# Multi-Pass-Diagnose: Pass-Ergebnis zurueck an Multi-Pass-Caller geben
|
|
||||||
if _pass_info is not None:
|
|
||||||
_pass_info["new_count"] = new_count
|
|
||||||
_pass_info["parse_failed"] = search_parse_failed
|
|
||||||
|
|
||||||
# Executive Summary im Hintergrund vorab generieren (fuer schnelleren Export)
|
# Executive Summary im Hintergrund vorab generieren (fuer schnelleren Export)
|
||||||
if new_count > 0:
|
if new_count > 0:
|
||||||
async def _pregenerate_exec_summary():
|
async def _pregenerate_exec_summary():
|
||||||
@@ -1851,7 +1622,6 @@ class AgentOrchestrator:
|
|||||||
Durchlauf 3: Konsolidierung (letzte Lücken, Fakten-Upgrade)
|
Durchlauf 3: Konsolidierung (letzte Lücken, Fakten-Upgrade)
|
||||||
"""
|
"""
|
||||||
total = RESEARCH_MULTI_PASS_COUNT
|
total = RESEARCH_MULTI_PASS_COUNT
|
||||||
pass_results = []
|
|
||||||
|
|
||||||
for pass_nr in range(1, total + 1):
|
for pass_nr in range(1, total + 1):
|
||||||
# Cancel zwischen Durchläufen prüfen
|
# Cancel zwischen Durchläufen prüfen
|
||||||
@@ -1892,27 +1662,12 @@ class AgentOrchestrator:
|
|||||||
if is_last:
|
if is_last:
|
||||||
raise
|
raise
|
||||||
# Nicht-letzter Durchlauf: weiter mit nächstem, bisherige Ergebnisse bleiben
|
# Nicht-letzter Durchlauf: weiter mit nächstem, bisherige Ergebnisse bleiben
|
||||||
finally:
|
|
||||||
pass_results.append(pass_info)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Research Multi-Pass abgeschlossen für Lage {incident_id}: "
|
f"Research Multi-Pass abgeschlossen für Lage {incident_id}: "
|
||||||
f"{total} Durchläufe"
|
f"{total} Durchläufe"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Diagnose: Wenn ALLE Passes 0 neue Artikel hatten UND mindestens einer
|
|
||||||
# an einem Parser-Fehler scheiterte, ist die Recherche faktisch fehlgeschlagen —
|
|
||||||
# Claude lieferte zwar Antworten, aber kein verwertbares JSON. Sonst bliebe
|
|
||||||
# die Lage ohne sichtbare Fehlermeldung leer (siehe staging Lage "Friedrich Merz").
|
|
||||||
total_new = sum(p.get("new_count", 0) for p in pass_results)
|
|
||||||
any_parse_failed = any(p.get("parse_failed") for p in pass_results)
|
|
||||||
if total_new == 0 and any_parse_failed:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Recherche fehlgeschlagen: Claude lieferte keine verwertbaren Quellen "
|
|
||||||
"(JSON-Parsing schlug bei mindestens einem Durchlauf fehl). "
|
|
||||||
"Bitte Logs prüfen und Refresh erneut starten."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton-Instanz
|
# Singleton-Instanz
|
||||||
orchestrator = AgentOrchestrator()
|
orchestrator = AgentOrchestrator()
|
||||||
|
|||||||
@@ -7,60 +7,6 @@ from config import CLAUDE_MODEL_FAST
|
|||||||
|
|
||||||
logger = logging.getLogger("osint.researcher")
|
logger = logging.getLogger("osint.researcher")
|
||||||
|
|
||||||
|
|
||||||
class ResearcherParseError(Exception):
|
|
||||||
"""Claude hat eine nicht-leere Antwort geliefert, aus der kein JSON extrahiert werden konnte."""
|
|
||||||
|
|
||||||
|
|
||||||
def _truncate_for_log(text: str, limit: int = 600) -> str:
|
|
||||||
"""Kürzt eine Claude-Antwort für Logs, damit ein Sample sichtbar ist."""
|
|
||||||
if not text:
|
|
||||||
return ""
|
|
||||||
snippet = text.strip().replace("\n", "\\n")
|
|
||||||
if len(snippet) > limit:
|
|
||||||
snippet = snippet[:limit] + "..."
|
|
||||||
return snippet
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_json_array(text: str):
|
|
||||||
"""Findet das erste vollständige JSON-Array im Text (auch mit Vor-/Nachtext oder Markdown-Fence)."""
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
decoder = json.JSONDecoder()
|
|
||||||
idx = 0
|
|
||||||
while True:
|
|
||||||
bracket = text.find("[", idx)
|
|
||||||
if bracket == -1:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
obj, _ = decoder.raw_decode(text, bracket)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
idx = bracket + 1
|
|
||||||
continue
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return obj
|
|
||||||
idx = bracket + 1
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_json_object(text: str):
|
|
||||||
"""Findet das erste vollständige JSON-Objekt im Text (auch mit Vor-/Nachtext oder Markdown-Fence)."""
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
decoder = json.JSONDecoder()
|
|
||||||
idx = 0
|
|
||||||
while True:
|
|
||||||
brace = text.find("{", idx)
|
|
||||||
if brace == -1:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
obj, _ = decoder.raw_decode(text, brace)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
idx = brace + 1
|
|
||||||
continue
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return obj
|
|
||||||
idx = brace + 1
|
|
||||||
|
|
||||||
RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Recherche-Agent für ein Lagemonitoring-System.
|
RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Recherche-Agent für ein Lagemonitoring-System.
|
||||||
AUSGABESPRACHE: {output_language}
|
AUSGABESPRACHE: {output_language}
|
||||||
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
|
||||||
@@ -69,7 +15,7 @@ WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschre
|
|||||||
AUFTRAG: Suche nach aktuellen Informationen zu folgendem Vorfall:
|
AUFTRAG: Suche nach aktuellen Informationen zu folgendem Vorfall:
|
||||||
Titel: {title}
|
Titel: {title}
|
||||||
Kontext: {description}
|
Kontext: {description}
|
||||||
{existing_context}{preferred_sources_block}
|
{existing_context}
|
||||||
REGELN:
|
REGELN:
|
||||||
- Suche nur bei seriösen Nachrichtenquellen (Nachrichtenagenturen, Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden)
|
- Suche nur bei seriösen Nachrichtenquellen (Nachrichtenagenturen, Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden)
|
||||||
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
|
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
|
||||||
@@ -100,7 +46,7 @@ WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschre
|
|||||||
AUFTRAG: Führe eine umfassende, mehrstufige Hintergrundrecherche durch zu:
|
AUFTRAG: Führe eine umfassende, mehrstufige Hintergrundrecherche durch zu:
|
||||||
Titel: {title}
|
Titel: {title}
|
||||||
Kontext: {description}
|
Kontext: {description}
|
||||||
{existing_context}{preferred_sources_block}
|
{existing_context}
|
||||||
RECHERCHE IN 4 PHASEN — Führe ALLE Phasen nacheinander durch:
|
RECHERCHE IN 4 PHASEN — Führe ALLE Phasen nacheinander durch:
|
||||||
|
|
||||||
PHASE 1 — BREITE ERFASSUNG:
|
PHASE 1 — BREITE ERFASSUNG:
|
||||||
@@ -212,24 +158,6 @@ Antwort NUR als JSON-Array:
|
|||||||
[{{"de": "iran", "en": "iran"}}, {{"de": "israel", "en": "israel"}}, {{"de": "teheran", "en": "tehran"}}, {{"de": "luftangriff", "en": "airstrike"}}, {{"de": "trump", "en": "trump"}}]"""
|
[{{"de": "iran", "en": "iran"}}, {{"de": "israel", "en": "israel"}}, {{"de": "teheran", "en": "tehran"}}, {{"de": "luftangriff", "en": "airstrike"}}, {{"de": "trump", "en": "trump"}}]"""
|
||||||
|
|
||||||
|
|
||||||
WEB_SOURCE_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Pruefe diese eingetragenen Web-Quellen und waehle nur die thematisch passenden aus.
|
|
||||||
|
|
||||||
LAGE: {title}
|
|
||||||
KONTEXT: {description}
|
|
||||||
|
|
||||||
WEB-QUELLEN:
|
|
||||||
{source_list}
|
|
||||||
|
|
||||||
REGELN:
|
|
||||||
- Waehle nur Quellen, die thematisch tatsaechlich zur Lage passen
|
|
||||||
- Lieber leere Liste zurueckgeben als pauschal alle aufnehmen
|
|
||||||
- Behoerden- und institutionelle Quellen sind oft hochwertig, aber nur wenn das Thema passt
|
|
||||||
- Petitions-Plattformen z.B. nur bei Lagen zu Buergerinitiativen, Gesetzen, oeffentlichem Druck
|
|
||||||
- Bei reinen Kriegs-/Konflikt-/Tagesnachrichten meistens leere Liste
|
|
||||||
|
|
||||||
Antworte NUR mit einem JSON-Array der Quellen-Nummern, z.B. [1, 3] oder []."""
|
|
||||||
|
|
||||||
|
|
||||||
TELEGRAM_CHANNEL_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Waehle aus dieser Liste von Telegram-Kanaelen diejenigen aus, die fuer die Lage relevant sein koennten.
|
TELEGRAM_CHANNEL_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Waehle aus dieser Liste von Telegram-Kanaelen diejenigen aus, die fuer die Lage relevant sein koennten.
|
||||||
|
|
||||||
LAGE: {title}
|
LAGE: {title}
|
||||||
@@ -283,28 +211,30 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
|
# Neues Format: JSON-Objekt mit "feeds" und "keywords"
|
||||||
keywords = None
|
keywords = None
|
||||||
indices = None
|
indices = None
|
||||||
|
|
||||||
# Neues Format: {"feeds": [...], "keywords": [...]}
|
# Versuche JSON-Objekt zu parsen
|
||||||
obj = _extract_json_object(result)
|
obj_match = re.search(r'\{[^{}]*"feeds"\s*:\s*\[[\d\s,]+\][^{}]*\}', result, re.DOTALL)
|
||||||
if isinstance(obj, dict) and isinstance(obj.get("feeds"), list):
|
if obj_match:
|
||||||
indices = obj["feeds"]
|
try:
|
||||||
raw_keywords = obj.get("keywords", [])
|
obj = json.loads(obj_match.group())
|
||||||
if isinstance(raw_keywords, list) and raw_keywords:
|
indices = obj.get("feeds", [])
|
||||||
keywords = [str(k).lower().strip() for k in raw_keywords if k]
|
raw_keywords = obj.get("keywords", [])
|
||||||
logger.info(f"Feed-Selektion Keywords: {keywords}")
|
if isinstance(raw_keywords, list) and raw_keywords:
|
||||||
|
keywords = [str(k).lower().strip() for k in raw_keywords if k]
|
||||||
|
logger.info(f"Feed-Selektion Keywords: {keywords}")
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
# Fallback: nacktes Array
|
# Fallback: altes Array-Format
|
||||||
if indices is None:
|
if indices is None:
|
||||||
arr = _extract_json_array(result)
|
arr_match = re.search(r'\[[\d\s,]+\]', result)
|
||||||
if not isinstance(arr, list):
|
if not arr_match:
|
||||||
logger.warning(
|
logger.warning("Feed-Selektion: Kein JSON in Antwort, nutze alle Feeds")
|
||||||
"Feed-Selektion: Kein JSON in Antwort, nutze alle Feeds. Sample: %s",
|
|
||||||
_truncate_for_log(result),
|
|
||||||
)
|
|
||||||
return feeds_metadata, None, usage
|
return feeds_metadata, None, usage
|
||||||
indices = arr
|
indices = json.loads(arr_match.group())
|
||||||
|
|
||||||
selected = []
|
selected = []
|
||||||
for idx in indices:
|
for idx in indices:
|
||||||
@@ -345,12 +275,19 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
parsed = _extract_json_array(result)
|
parsed = None
|
||||||
if not isinstance(parsed, list):
|
try:
|
||||||
logger.warning(
|
parsed = json.loads(result)
|
||||||
"Keyword-Extraktion: Kein gueltiges JSON erhalten. Sample: %s",
|
except json.JSONDecodeError:
|
||||||
_truncate_for_log(result),
|
match = re.search(r'\[.*\]', result, re.DOTALL)
|
||||||
)
|
if match:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(match.group())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not parsed or not isinstance(parsed, list):
|
||||||
|
logger.warning("Keyword-Extraktion: Kein gueltiges JSON erhalten")
|
||||||
return None, usage
|
return None, usage
|
||||||
|
|
||||||
# Flache Liste: alle DE + EN Begriffe
|
# Flache Liste: alle DE + EN Begriffe
|
||||||
@@ -373,35 +310,9 @@ class ResearcherAgent:
|
|||||||
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None) -> tuple[list[dict], ClaudeUsage | None, bool]:
|
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None) -> tuple[list[dict], ClaudeUsage | None]:
|
||||||
"""Sucht nach Informationen zu einem Vorfall.
|
"""Sucht nach Informationen zu einem Vorfall."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
(artikel, usage, parse_failed) — parse_failed ist True, wenn Claude geantwortet hat,
|
|
||||||
das JSON aber nicht extrahierbar war. So kann der Orchestrator zwischen
|
|
||||||
"echt keine Treffer" und "kaputte Antwort" unterscheiden.
|
|
||||||
"""
|
|
||||||
from config import OUTPUT_LANGUAGE
|
from config import OUTPUT_LANGUAGE
|
||||||
|
|
||||||
# Bevorzugte Web-Quellen als Prompt-Block (optional)
|
|
||||||
preferred_sources_block = ""
|
|
||||||
if preferred_sources:
|
|
||||||
ps_lines = []
|
|
||||||
for s in preferred_sources:
|
|
||||||
domain = s.get("domain", "")
|
|
||||||
name = s.get("name", domain) or domain
|
|
||||||
if not domain:
|
|
||||||
continue
|
|
||||||
ps_lines.append(f"- {domain} ({name})")
|
|
||||||
if ps_lines:
|
|
||||||
preferred_sources_block = (
|
|
||||||
"\nEINGETRAGENE WEB-QUELLEN (vom Betreiber als seriös markiert):\n"
|
|
||||||
+ "\n".join(ps_lines) + "\n"
|
|
||||||
"EMPFEHLUNG: Wenn diese Domains thematisch zur Lage passen, suche dort gezielt "
|
|
||||||
"mit \"site:domain [Suchbegriff]\". Sie sind vertrauenswuerdig eingetragen, ersetzen "
|
|
||||||
"aber nicht deine sonstige Recherche.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
if incident_type == "research":
|
if incident_type == "research":
|
||||||
lang_instruction = LANG_DEEP_INTERNATIONAL if international else LANG_DEEP_GERMAN_ONLY
|
lang_instruction = LANG_DEEP_INTERNATIONAL if international else LANG_DEEP_GERMAN_ONLY
|
||||||
# Bestehende Artikel als Kontext für den Prompt aufbereiten
|
# Bestehende Artikel als Kontext für den Prompt aufbereiten
|
||||||
@@ -421,7 +332,6 @@ class ResearcherAgent:
|
|||||||
prompt = DEEP_RESEARCH_PROMPT_TEMPLATE.format(
|
prompt = DEEP_RESEARCH_PROMPT_TEMPLATE.format(
|
||||||
title=title, description=description, language_instruction=lang_instruction,
|
title=title, description=description, language_instruction=lang_instruction,
|
||||||
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
|
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
|
||||||
preferred_sources_block=preferred_sources_block,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
lang_instruction = LANG_INTERNATIONAL if international else LANG_GERMAN_ONLY
|
lang_instruction = LANG_INTERNATIONAL if international else LANG_GERMAN_ONLY
|
||||||
@@ -440,18 +350,11 @@ class ResearcherAgent:
|
|||||||
prompt = RESEARCH_PROMPT_TEMPLATE.format(
|
prompt = RESEARCH_PROMPT_TEMPLATE.format(
|
||||||
title=title, description=description, language_instruction=lang_instruction,
|
title=title, description=description, language_instruction=lang_instruction,
|
||||||
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
|
output_language=OUTPUT_LANGUAGE, existing_context=existing_context,
|
||||||
preferred_sources_block=preferred_sources_block,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt)
|
result, usage = await call_claude(prompt)
|
||||||
try:
|
articles = self._parse_response(result)
|
||||||
articles = self._parse_response(result)
|
|
||||||
except ResearcherParseError as parse_err:
|
|
||||||
# Claude hat geantwortet, aber kein verwertbares JSON dabei.
|
|
||||||
# Usage trotzdem zurueckgeben, damit Credits korrekt verbucht werden.
|
|
||||||
logger.warning("Claude-Recherche: %s", parse_err)
|
|
||||||
return [], usage, True
|
|
||||||
|
|
||||||
# Ausgeschlossene Quellen dynamisch aus DB laden
|
# Ausgeschlossene Quellen dynamisch aus DB laden
|
||||||
excluded_sources = await self._get_excluded_sources(user_id=user_id)
|
excluded_sources = await self._get_excluded_sources(user_id=user_id)
|
||||||
@@ -473,13 +376,13 @@ class ResearcherAgent:
|
|||||||
filtered.append(article)
|
filtered.append(article)
|
||||||
|
|
||||||
logger.info(f"Recherche ergab {len(filtered)} Artikel (von {len(articles)} gefundenen, international={international})")
|
logger.info(f"Recherche ergab {len(filtered)} Artikel (von {len(articles)} gefundenen, international={international})")
|
||||||
return filtered, usage, False
|
return filtered, usage
|
||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise # Timeout nach oben durchreichen fuer Retry im Orchestrator
|
raise # Timeout nach oben durchreichen fuer Retry im Orchestrator
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Recherche-Fehler: {e}")
|
logger.error(f"Recherche-Fehler: {e}")
|
||||||
return [], None, False
|
return [], None
|
||||||
|
|
||||||
async def _get_excluded_sources(self, user_id: int = None) -> list[str]:
|
async def _get_excluded_sources(self, user_id: int = None) -> list[str]:
|
||||||
"""Laedt ausgeschlossene Quellen (global + per-User)."""
|
"""Laedt ausgeschlossene Quellen (global + per-User)."""
|
||||||
@@ -502,118 +405,56 @@ class ResearcherAgent:
|
|||||||
return list(EXCLUDED_SOURCES)
|
return list(EXCLUDED_SOURCES)
|
||||||
|
|
||||||
def _parse_response(self, response: str) -> list[dict]:
|
def _parse_response(self, response: str) -> list[dict]:
|
||||||
"""Parst die Claude-Antwort als JSON-Array.
|
"""Parst die Claude-Antwort als JSON-Array."""
|
||||||
|
# Versuche JSON direkt zu parsen
|
||||||
Wirft ResearcherParseError, wenn die Antwort nicht-leer ist, sich aber
|
|
||||||
kein JSON extrahieren laesst. Eine echte leere Liste (z.B. wenn Claude
|
|
||||||
wirklich keine Treffer hat) wird als [] zurueckgegeben.
|
|
||||||
"""
|
|
||||||
text = (response or "").strip()
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 1) Direkt parsen (Antwort ist bereits sauberes JSON)
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(text)
|
data = json.loads(response)
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
return data
|
return data
|
||||||
if isinstance(data, dict) and isinstance(data.get("articles"), list):
|
if isinstance(data, dict) and "articles" in data:
|
||||||
return data["articles"]
|
return data["articles"]
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 2) JSON-Array irgendwo im Text (Markdown-Fence oder Vor-/Nachtext)
|
# JSON-Code-Block extrahieren
|
||||||
arr = _extract_json_array(text)
|
code_pat = r'`{3}(?:json)?\s*\n?(\[.*?\])\s*`{3}'
|
||||||
if isinstance(arr, list):
|
code_match = re.search(code_pat, response, re.DOTALL)
|
||||||
return arr
|
if code_match:
|
||||||
|
|
||||||
# 3) JSON-Objekt mit "articles"-Key
|
|
||||||
obj = _extract_json_object(text)
|
|
||||||
if isinstance(obj, dict) and isinstance(obj.get("articles"), list):
|
|
||||||
return obj["articles"]
|
|
||||||
|
|
||||||
# 4) Recovery: einzelne Headline-Objekte aus Fliesstext
|
|
||||||
recovered = []
|
|
||||||
for obj_str in re.findall(r'\{[^{}]*"headline"[^{}]*\}', text, re.DOTALL):
|
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(obj_str)
|
data = json.loads(code_match.group(1))
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
pass
|
||||||
if isinstance(parsed, dict) and "headline" in parsed:
|
|
||||||
recovered.append(parsed)
|
|
||||||
if recovered:
|
|
||||||
logger.info("JSON-Recovery: %d Artikel aus Einzelobjekten extrahiert", len(recovered))
|
|
||||||
return recovered
|
|
||||||
|
|
||||||
# Parse fehlgeschlagen — Claude hat geantwortet, aber kein verwertbares JSON dabei.
|
# Versuche JSON aus der Antwort zu extrahieren (zwischen [ und ])
|
||||||
# Sample loggen, damit der Fehler debuggbar ist, und Aufrufer signalisieren.
|
arr_pat = r'\[\s*\{.*\}\s*\]'
|
||||||
logger.warning(
|
match = re.search(arr_pat, response, re.DOTALL)
|
||||||
"Konnte Claude-Antwort nicht als JSON parsen (Laenge: %d). Sample: %s",
|
if match:
|
||||||
len(text),
|
try:
|
||||||
_truncate_for_log(text),
|
data = json.loads(match.group())
|
||||||
)
|
if isinstance(data, list):
|
||||||
raise ResearcherParseError(f"Claude-Antwort enthielt kein verwertbares JSON (Laenge: {len(text)})")
|
return data
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
async def select_relevant_web_sources(
|
# Letzter Versuch: einzelne JSON-Objekte mit headline
|
||||||
self,
|
objects = re.findall(r'\{[^{}]*"headline"[^{}]*\}', response)
|
||||||
title: str,
|
if objects:
|
||||||
description: str,
|
results = []
|
||||||
web_sources: list[dict],
|
for obj_str in objects:
|
||||||
) -> tuple[list[dict], ClaudeUsage | None]:
|
try:
|
||||||
"""Laesst Claude die thematisch passenden Web-Quellen auswaehlen (Haiku).
|
obj = json.loads(obj_str)
|
||||||
|
if "headline" in obj:
|
||||||
|
results.append(obj)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if results:
|
||||||
|
logger.info(f"JSON-Recovery: {len(results)} Artikel aus Einzelobjekten extrahiert")
|
||||||
|
return results
|
||||||
|
|
||||||
Returns:
|
logger.warning(f"Konnte Claude-Antwort nicht als JSON parsen (Laenge: {len(response)})")
|
||||||
(ausgewaehlte Quellen, usage). Bei Fehler: ([], None).
|
return []
|
||||||
Leere Auswahl ist explizit erlaubt — keine Quelle wird zwangsweise aufgenommen.
|
|
||||||
"""
|
|
||||||
if not web_sources:
|
|
||||||
return [], None
|
|
||||||
|
|
||||||
# Bei sehr wenigen Quellen lohnt der Selektions-Call kaum — alle weiterreichen.
|
|
||||||
if len(web_sources) <= 3:
|
|
||||||
logger.info("Web-Source-Selektion: Nur %d Quellen, alle uebernehmen", len(web_sources))
|
|
||||||
return list(web_sources), None
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
for i, src in enumerate(web_sources, 1):
|
|
||||||
cat = src.get("category", "sonstige")
|
|
||||||
notes = (src.get("notes") or "")[:80]
|
|
||||||
domain = src.get("domain", "")
|
|
||||||
line = f"{i}. {src.get('name', domain)} ({domain}) [{cat}]"
|
|
||||||
if notes:
|
|
||||||
line += f" - {notes}"
|
|
||||||
lines.append(line)
|
|
||||||
|
|
||||||
prompt = WEB_SOURCE_SELECTION_PROMPT.format(
|
|
||||||
title=title,
|
|
||||||
description=description or "Keine weitere Beschreibung",
|
|
||||||
source_list="\n".join(lines),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
|
||||||
indices = _extract_json_array(result)
|
|
||||||
if not isinstance(indices, list):
|
|
||||||
logger.warning(
|
|
||||||
"Web-Source-Selektion: Kein JSON in Antwort, ignoriere Quellen. Sample: %s",
|
|
||||||
_truncate_for_log(result),
|
|
||||||
)
|
|
||||||
return [], usage
|
|
||||||
|
|
||||||
selected = []
|
|
||||||
for idx in indices:
|
|
||||||
if isinstance(idx, int) and 1 <= idx <= len(web_sources):
|
|
||||||
selected.append(web_sources[idx - 1])
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Web-Source-Selektion: %d von %d ausgewaehlt%s",
|
|
||||||
len(selected), len(web_sources),
|
|
||||||
f" ({', '.join(s.get('domain', '') for s in selected)})" if selected else "",
|
|
||||||
)
|
|
||||||
return selected, usage
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Web-Source-Selektion fehlgeschlagen (%s)", e)
|
|
||||||
return [], None
|
|
||||||
|
|
||||||
async def select_relevant_telegram_channels(
|
async def select_relevant_telegram_channels(
|
||||||
self,
|
self,
|
||||||
@@ -647,14 +488,12 @@ class ResearcherAgent:
|
|||||||
try:
|
try:
|
||||||
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
||||||
|
|
||||||
indices = _extract_json_array(result)
|
arr_match = re.search(r'\[[\d\s,]+\]', result)
|
||||||
if not isinstance(indices, list):
|
if not arr_match:
|
||||||
logger.warning(
|
logger.warning("Telegram-Selektion: Kein JSON in Antwort, nutze alle Kanaele")
|
||||||
"Telegram-Selektion: Kein JSON in Antwort, nutze alle Kanaele. Sample: %s",
|
|
||||||
_truncate_for_log(result),
|
|
||||||
)
|
|
||||||
return channels_metadata, usage
|
return channels_metadata, usage
|
||||||
|
|
||||||
|
indices = json.loads(arr_match.group())
|
||||||
selected = []
|
selected = []
|
||||||
for idx in indices:
|
for idx in indices:
|
||||||
if isinstance(idx, int) and 1 <= idx <= len(channels_metadata):
|
if isinstance(idx, int) and 1 <= idx <= len(channels_metadata):
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
"""Translator-Agent: uebersetzt fremdsprachige Artikel ins Deutsche.
|
|
||||||
|
|
||||||
Eigener Agent (separat vom Analyzer), damit Token-Limits nicht zwischen
|
|
||||||
Lagebild und Uebersetzung konkurrieren. Nutzt CLAUDE_MODEL_FAST (Haiku) in
|
|
||||||
Batches.
|
|
||||||
|
|
||||||
Aufgerufen vom Orchestrator nach analyzer.analyze() und vor post_refresh_qc.
|
|
||||||
Backfill-Skript nutzt dieselbe Funktion fuer rueckwirkendes Auffuellen.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator
|
|
||||||
from config import CLAUDE_MODEL_FAST, TRANSLATOR_ENABLED
|
|
||||||
|
|
||||||
logger = logging.getLogger("osint.translator")
|
|
||||||
|
|
||||||
# Pro Batch nicht mehr als so viele Artikel an Claude geben.
|
|
||||||
# Bei Haiku ist das Output-Limit ca. 8k Tokens. Pro Artikel kommen leicht
|
|
||||||
# 400-600 Tokens raus (headline_de + content_de bis 1000 Zeichen). Bei 15
|
|
||||||
# wurde regelmaessig getrunkt (mid-JSON broken). 5 ist sicher mit Reserve.
|
|
||||||
DEFAULT_BATCH_SIZE = 5
|
|
||||||
|
|
||||||
# content_original wird ohnehin auf 1000 Zeichen gecappt (rss_parser).
|
|
||||||
# Fuer den Translator nochmal verkuerzen, falls vorhanden mehr.
|
|
||||||
CONTENT_INPUT_MAX = 1200
|
|
||||||
|
|
||||||
# content_de soll wie content_original auf 1000 Zeichen begrenzt sein.
|
|
||||||
CONTENT_OUTPUT_MAX = 1000
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_complete_objects(text: str) -> list[dict]:
|
|
||||||
"""Extrahiert vollstaendige JSON-Objekte aus moeglicherweise abgeschnittenem Text.
|
|
||||||
|
|
||||||
Klammer-Counter-Ansatz: jedes balancierte {...} wird probiert.
|
|
||||||
"""
|
|
||||||
results = []
|
|
||||||
depth = 0
|
|
||||||
start = -1
|
|
||||||
in_string = False
|
|
||||||
escape = False
|
|
||||||
for i, ch in enumerate(text):
|
|
||||||
if escape:
|
|
||||||
escape = False
|
|
||||||
continue
|
|
||||||
if ch == "\\":
|
|
||||||
escape = True
|
|
||||||
continue
|
|
||||||
if ch == '"' and not escape:
|
|
||||||
in_string = not in_string
|
|
||||||
continue
|
|
||||||
if in_string:
|
|
||||||
continue
|
|
||||||
if ch == "{":
|
|
||||||
if depth == 0:
|
|
||||||
start = i
|
|
||||||
depth += 1
|
|
||||||
elif ch == "}":
|
|
||||||
depth -= 1
|
|
||||||
if depth == 0 and start >= 0:
|
|
||||||
obj_text = text[start:i + 1]
|
|
||||||
try:
|
|
||||||
obj = json.loads(obj_text)
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
results.append(obj)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
start = -1
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(articles: list[dict], output_lang: str = "de") -> str:
|
|
||||||
"""Bauen den Translation-Prompt fuer eine Batch."""
|
|
||||||
lang_label = {"de": "Deutsch", "en": "Englisch"}.get(output_lang, output_lang)
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for a in articles:
|
|
||||||
items.append({
|
|
||||||
"id": a["id"],
|
|
||||||
"headline": a.get("headline", "") or "",
|
|
||||||
"content": (a.get("content_original") or "")[:CONTENT_INPUT_MAX],
|
|
||||||
"source_lang": a.get("language", "en"),
|
|
||||||
})
|
|
||||||
|
|
||||||
return f"""Du bist ein praeziser Uebersetzer fuer Nachrichten-Artikel.
|
|
||||||
Uebersetze die folgenden Artikel nach {lang_label}.
|
|
||||||
|
|
||||||
WICHTIG:
|
|
||||||
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) - NIEMALS Umschreibungen wie ae, oe, ue, ss.
|
|
||||||
Beispiele: "Gespraeche" -> "Gespräche", "Fuehrer" -> "Führer", "grosse" -> "große".
|
|
||||||
- Behalte Eigennamen (Personen, Orte, Organisationen) im Original.
|
|
||||||
- Headline kurz und buendig wie im Original.
|
|
||||||
- Content auf MAX {CONTENT_OUTPUT_MAX} Zeichen kuerzen, kein HTML, kein Markdown.
|
|
||||||
- Wenn der Artikel schon auf {lang_label} ist (z.B. source_lang="{output_lang}"),
|
|
||||||
kopiere headline und content unveraendert.
|
|
||||||
|
|
||||||
Antworte AUSSCHLIESSLICH mit einem flachen JSON-Array (kein Wrapper-Objekt!).
|
|
||||||
Format genau so:
|
|
||||||
[
|
|
||||||
{{"id": 1, "headline_de": "Titel auf Deutsch", "content_de": "Inhalt auf Deutsch"}},
|
|
||||||
{{"id": 2, "headline_de": "...", "content_de": "..."}}
|
|
||||||
]
|
|
||||||
|
|
||||||
NICHT erlaubt: {{"translations": [...]}} oder {{"items": [...]}} oder Markdown-Codefences.
|
|
||||||
Nur das Array, ohne Einleitung, ohne Erklaerung.
|
|
||||||
|
|
||||||
ARTIKEL:
|
|
||||||
{json.dumps(items, ensure_ascii=False, indent=2)}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_response(text: str) -> list[dict]:
|
|
||||||
"""Robustes JSON-Array-Parsing.
|
|
||||||
|
|
||||||
Handhabt:
|
|
||||||
- reines JSON
|
|
||||||
- JSON in Markdown-Codefence ```json ... ```
|
|
||||||
- abgeschnittene Antworten (extrahiert vollstaendige Top-Level-Objekte)
|
|
||||||
"""
|
|
||||||
text = text.strip()
|
|
||||||
# Markdown-Codefence entfernen
|
|
||||||
if text.startswith("```"):
|
|
||||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
|
||||||
text = re.sub(r"\s*```\s*$", "", text)
|
|
||||||
text = text.strip()
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Erst Array versuchen
|
|
||||||
match = re.search(r"\[.*\]", text, re.DOTALL)
|
|
||||||
if match:
|
|
||||||
try:
|
|
||||||
data = json.loads(match.group(0))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Truncate-Fallback: einzelne Top-Level-Objekte extrahieren
|
|
||||||
data = _extract_complete_objects(text)
|
|
||||||
else:
|
|
||||||
data = _extract_complete_objects(text)
|
|
||||||
|
|
||||||
# Claude wraps das Array gelegentlich in {"translations": [...]} oder {"items": [...]}
|
|
||||||
if isinstance(data, dict):
|
|
||||||
for key in ("translations", "items", "results", "data"):
|
|
||||||
if isinstance(data.get(key), list):
|
|
||||||
data = data[key]
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Einzelnes Objekt? Dann als Liste mit einem Element behandeln
|
|
||||||
if "id" in data:
|
|
||||||
data = [data]
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Translator-Antwort: Dict ohne erwarteten Array-Key (keys={list(data.keys())[:5]})")
|
|
||||||
|
|
||||||
if not isinstance(data, list):
|
|
||||||
raise ValueError(f"Translator-Antwort ist kein Array: {type(data).__name__}")
|
|
||||||
|
|
||||||
cleaned = []
|
|
||||||
for item in data:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
aid = item.get("id")
|
|
||||||
if not isinstance(aid, int):
|
|
||||||
try:
|
|
||||||
aid = int(aid)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
cleaned.append({
|
|
||||||
"id": aid,
|
|
||||||
"headline_de": (item.get("headline_de") or "").strip() or None,
|
|
||||||
"content_de": (item.get("content_de") or "").strip() or None,
|
|
||||||
})
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
|
|
||||||
async def translate_articles_batch(
|
|
||||||
articles: list[dict],
|
|
||||||
output_lang: str = "de",
|
|
||||||
) -> tuple[list[dict], ClaudeUsage]:
|
|
||||||
"""Uebersetzt eine Batch von Artikeln.
|
|
||||||
|
|
||||||
Erwartet articles als Liste von Dicts mit den Feldern id, headline,
|
|
||||||
content_original, language.
|
|
||||||
|
|
||||||
Rueckgabe: (uebersetzte_artikel, usage)
|
|
||||||
Wenn der Call fehlschlaegt, wird ([], leere_usage) zurueckgegeben - der
|
|
||||||
Caller kann entscheiden, ob retry oder skip.
|
|
||||||
"""
|
|
||||||
if not articles:
|
|
||||||
return [], ClaudeUsage()
|
|
||||||
|
|
||||||
prompt = _build_prompt(articles, output_lang)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result_text, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Translator Claude-Call fehlgeschlagen: {e}")
|
|
||||||
return [], ClaudeUsage()
|
|
||||||
|
|
||||||
try:
|
|
||||||
translations = _parse_response(result_text)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Translator JSON-Parsing fehlgeschlagen: {e}; raw: {result_text[:300]!r}")
|
|
||||||
return [], usage
|
|
||||||
|
|
||||||
# Validierung: nur Translations zurueckgeben, deren id wirklich
|
|
||||||
# in der angefragten Batch war
|
|
||||||
requested_ids = {a["id"] for a in articles}
|
|
||||||
valid = [t for t in translations if t["id"] in requested_ids]
|
|
||||||
if len(valid) != len(translations):
|
|
||||||
logger.warning(
|
|
||||||
"Translator: %d von %d Translations referenzieren unbekannte IDs",
|
|
||||||
len(translations) - len(valid), len(translations),
|
|
||||||
)
|
|
||||||
return valid, usage
|
|
||||||
|
|
||||||
|
|
||||||
async def translate_articles(
|
|
||||||
articles: list[dict],
|
|
||||||
output_lang: str = "de",
|
|
||||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
||||||
usage_accumulator: UsageAccumulator | None = None,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Uebersetzt eine beliebige Anzahl Artikel in Batches.
|
|
||||||
|
|
||||||
Bringt die Batches durch Logik in `translate_articles_batch` und gibt
|
|
||||||
EINE flache Liste der Translations zurueck. Wenn ein Batch fehlschlaegt,
|
|
||||||
wird er uebersprungen (anderer Batches laufen weiter).
|
|
||||||
"""
|
|
||||||
if not articles:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if not TRANSLATOR_ENABLED:
|
|
||||||
logger.info(
|
|
||||||
"Translator deaktiviert (TRANSLATOR_ENABLED=false), %d Artikel uebersprungen",
|
|
||||||
len(articles),
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
all_translations = []
|
|
||||||
for i in range(0, len(articles), batch_size):
|
|
||||||
batch = articles[i : i + batch_size]
|
|
||||||
translations, usage = await translate_articles_batch(batch, output_lang)
|
|
||||||
if usage_accumulator is not None:
|
|
||||||
usage_accumulator.add(usage)
|
|
||||||
all_translations.extend(translations)
|
|
||||||
logger.info(
|
|
||||||
"Translator-Batch %d/%d: %d/%d uebersetzt (cost=$%.4f)",
|
|
||||||
(i // batch_size) + 1,
|
|
||||||
(len(articles) + batch_size - 1) // batch_size,
|
|
||||||
len(translations), len(batch),
|
|
||||||
usage.cost_usd,
|
|
||||||
)
|
|
||||||
return all_translations
|
|
||||||
@@ -41,10 +41,6 @@ OUTPUT_LANGUAGE = "Deutsch"
|
|||||||
# In Kundenversion auf False setzen oder Env-Variable entfernen
|
# In Kundenversion auf False setzen oder Env-Variable entfernen
|
||||||
DEV_MODE = os.environ.get("DEV_MODE", "true").lower() == "true"
|
DEV_MODE = os.environ.get("DEV_MODE", "true").lower() == "true"
|
||||||
|
|
||||||
# Feature-Flag: Translator-Agent (Haiku) komplett deaktivieren.
|
|
||||||
# False = keine Uebersetzungen mehr, fremdsprachige Artikel bleiben unuebersetzt.
|
|
||||||
TRANSLATOR_ENABLED = os.environ.get("TRANSLATOR_ENABLED", "true").lower() == "true"
|
|
||||||
|
|
||||||
# RSS-Feeds (Fallback, primär aus DB geladen)
|
# RSS-Feeds (Fallback, primär aus DB geladen)
|
||||||
RSS_FEEDS = {
|
RSS_FEEDS = {
|
||||||
"deutsch": [
|
"deutsch": [
|
||||||
|
|||||||
@@ -117,22 +117,6 @@ 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,
|
||||||
@@ -434,29 +418,6 @@ 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():
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import httpx
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
|
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
|
||||||
from source_rules import _extract_domain
|
from source_rules import _extract_domain
|
||||||
from feeds.transcript_extractors._common import html_to_text
|
|
||||||
from services.post_refresh_qc import normalize_german_umlauts
|
|
||||||
|
|
||||||
logger = logging.getLogger("osint.rss")
|
logger = logging.getLogger("osint.rss")
|
||||||
|
|
||||||
@@ -154,27 +152,11 @@ class RSSParser:
|
|||||||
|
|
||||||
for entry in feed.entries[:50]:
|
for entry in feed.entries[:50]:
|
||||||
title = entry.get("title", "")
|
title = entry.get("title", "")
|
||||||
# RSS-summary ist bei vielen Quellen HTML (Guardian, AP, SZ, ...).
|
summary = entry.get("summary", "")
|
||||||
# Vor weiterer Verwendung strippen, sonst landet HTML in DB
|
|
||||||
# und KI-Agenten und Sprach-Heuristik werden gestoert.
|
|
||||||
summary_raw = entry.get("summary", "")
|
|
||||||
summary = html_to_text(summary_raw) if summary_raw else ""
|
|
||||||
# ASCII-Umlaut-Normalisierung (z.B. dpa-AFX schreibt "Gespraeche").
|
|
||||||
# Dictionary-basiert, sicher gegen englische Woerter wie "Boeing".
|
|
||||||
title, _ = normalize_german_umlauts(title)
|
|
||||||
summary, _ = normalize_german_umlauts(summary)
|
|
||||||
text = f"{title} {summary}".lower()
|
text = f"{title} {summary}".lower()
|
||||||
|
|
||||||
# Adaptive Match-Schwelle:
|
# Flexibles Keyword-Matching: mindestens die Hälfte der Suchworte muss vorkommen (aufgerundet)
|
||||||
# - Bei mindestens einem spezifischen Keyword (>=7 Zeichen) im Text reicht 1 Treffer.
|
min_matches = min(2, max(1, (len(search_words) + 1) // 2))
|
||||||
# Verhindert, dass Headlines mit nur einem starken Keyword wie "buckelwal"
|
|
||||||
# rausfallen, wenn die Lage thematisch eng ist (Bug 1, vom User dokumentiert).
|
|
||||||
# - Sonst: alte Heuristik (mindestens halb der Wörter, max. 2).
|
|
||||||
specific_in_text = any(w in text for w in search_words if len(w) >= 7)
|
|
||||||
if specific_in_text:
|
|
||||||
min_matches = 1
|
|
||||||
else:
|
|
||||||
min_matches = min(2, max(1, (len(search_words) + 1) // 2))
|
|
||||||
match_count = sum(1 for word in search_words if word in text)
|
match_count = sum(1 for word in search_words if word in text)
|
||||||
|
|
||||||
if match_count >= min_matches:
|
if match_count >= min_matches:
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ async def check_auto_refresh():
|
|||||||
|
|
||||||
# Letzten abgeschlossenen oder laufenden Refresh pruefen
|
# Letzten abgeschlossenen oder laufenden Refresh pruefen
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running', 'cancelled', 'error') ORDER BY id DESC LIMIT 1",
|
"SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running') ORDER BY id DESC LIMIT 1",
|
||||||
(incident_id,),
|
(incident_id,),
|
||||||
)
|
)
|
||||||
last_refresh = await cursor.fetchone()
|
last_refresh = await cursor.fetchone()
|
||||||
|
|||||||
@@ -40,25 +40,12 @@ async def require_writable_license(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Dependency die sicherstellt, dass die Lizenz Schreibzugriff erlaubt.
|
"""Dependency die sicherstellt, dass die Lizenz Schreibzugriff erlaubt.
|
||||||
|
|
||||||
Blockiert neue Lagen/Refreshes bei abgelaufener Lizenz, deaktivierter Org
|
Blockiert neue Lagen/Refreshes bei abgelaufener Lizenz (Nur-Lesen-Modus).
|
||||||
oder aufgebrauchtem Token-Budget (Hard-Stop).
|
|
||||||
"""
|
"""
|
||||||
lic = current_user.get("license", {})
|
lic = current_user.get("license", {})
|
||||||
if lic.get("read_only"):
|
if lic.get("read_only"):
|
||||||
reason = lic.get("read_only_reason") or "expired"
|
|
||||||
if reason == "budget_exceeded":
|
|
||||||
detail = "Token-Budget aufgebraucht. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren."
|
|
||||||
elif reason == "expired":
|
|
||||||
detail = "Lizenz abgelaufen. Nur Lesezugriff moeglich."
|
|
||||||
elif reason == "no_license":
|
|
||||||
detail = "Keine aktive Lizenz. Bitte Verwaltung kontaktieren."
|
|
||||||
elif reason == "org_disabled":
|
|
||||||
detail = "Organisation deaktiviert. Bitte Support kontaktieren."
|
|
||||||
else:
|
|
||||||
detail = lic.get("message") or "Nur Lesezugriff moeglich."
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=detail,
|
detail="Lizenz abgelaufen oder widerrufen. Nur Lesezugriff moeglich.",
|
||||||
headers={"X-License-Status": reason},
|
|
||||||
)
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ class UserMeResponse(BaseModel):
|
|||||||
license_status: str = "unknown"
|
license_status: str = "unknown"
|
||||||
license_type: str = ""
|
license_type: str = ""
|
||||||
read_only: bool = False
|
read_only: bool = False
|
||||||
read_only_reason: Optional[str] = None
|
|
||||||
unlimited_budget: bool = False
|
|
||||||
credits_total: Optional[int] = None
|
credits_total: Optional[int] = None
|
||||||
credits_remaining: Optional[int] = None
|
credits_remaining: Optional[int] = None
|
||||||
credits_percent_used: Optional[float] = None
|
credits_percent_used: Optional[float] = None
|
||||||
|
|||||||
@@ -26,15 +26,10 @@ LOGO_PATH = Path(__file__).parent / "static" / "favicon.svg"
|
|||||||
|
|
||||||
|
|
||||||
FC_STATUS_LABELS = {
|
FC_STATUS_LABELS = {
|
||||||
# 1:1 vom Monitor-Frontend (components.js) — konsistent zum UI.
|
"confirmed": "Bestätigt",
|
||||||
"confirmed": "Bestätigt",
|
"unconfirmed": "Unbestätigt",
|
||||||
"unconfirmed": "Unbestätigt",
|
"disputed": "Umstritten",
|
||||||
"contradicted": "Widerlegt",
|
"false": "Falsch",
|
||||||
"developing": "Unklar",
|
|
||||||
"established": "Gesichert",
|
|
||||||
"disputed": "Umstritten",
|
|
||||||
"unverified": "Ungeprüft",
|
|
||||||
"false": "Falsch", # Legacy-Fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -714,7 +709,7 @@ async def generate_pdf(
|
|||||||
),
|
),
|
||||||
lagebild_timestamp=(incident.get("updated_at") or "")[:16].replace("T", " "),
|
lagebild_timestamp=(incident.get("updated_at") or "")[:16].replace("T", " "),
|
||||||
sources=_prepare_sources(incident)[:30] if scope == "report" else _prepare_sources(incident),
|
sources=_prepare_sources(incident)[:30] if scope == "report" else _prepare_sources(incident),
|
||||||
fact_checks=_prepare_fact_checks(fact_checks),
|
fact_checks=_prepare_fact_checks(fact_checks[:20] if scope == "report" else fact_checks),
|
||||||
source_stats=_prepare_source_stats(articles)[:20] if scope == "report" else _prepare_source_stats(articles),
|
source_stats=_prepare_source_stats(articles)[:20] if scope == "report" else _prepare_source_stats(articles),
|
||||||
timeline=_prepare_timeline(articles) if scope == "full" else [],
|
timeline=_prepare_timeline(articles) if scope == "full" else [],
|
||||||
articles=articles if scope == "full" else [],
|
articles=articles if scope == "full" else [],
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
|
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
|
||||||
def _staging_mode() -> bool:
|
|
||||||
"""STAGING_MODE Env-Flag (vgl. services.license_service)."""
|
|
||||||
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
|
|
||||||
from models import (
|
from models import (
|
||||||
MagicLinkRequest,
|
MagicLinkRequest,
|
||||||
MagicLinkResponse,
|
MagicLinkResponse,
|
||||||
@@ -193,11 +187,10 @@ async def get_me(
|
|||||||
from services.license_service import check_license
|
from services.license_service import check_license
|
||||||
license_info = await check_license(db, current_user["tenant_id"])
|
license_info = await check_license(db, current_user["tenant_id"])
|
||||||
|
|
||||||
# Credits-Daten laden (echte Prozente, nicht gekappt)
|
# Credits-Daten laden
|
||||||
credits_total = None
|
credits_total = None
|
||||||
credits_remaining = None
|
credits_remaining = None
|
||||||
credits_percent_used = None
|
credits_percent_used = None
|
||||||
unlimited_budget = bool(license_info.get("unlimited_budget", False))
|
|
||||||
if current_user.get("tenant_id"):
|
if current_user.get("tenant_id"):
|
||||||
lic_cursor = await db.execute(
|
lic_cursor = await db.execute(
|
||||||
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
||||||
@@ -207,12 +200,7 @@ async def get_me(
|
|||||||
credits_total = lic_row["credits_total"]
|
credits_total = lic_row["credits_total"]
|
||||||
credits_used = lic_row["credits_used"] or 0
|
credits_used = lic_row["credits_used"] or 0
|
||||||
credits_remaining = max(0, int(credits_total - credits_used))
|
credits_remaining = max(0, int(credits_total - credits_used))
|
||||||
credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0
|
credits_percent_used = round(min(100, (credits_used / credits_total) * 100), 1) if credits_total > 0 else 0
|
||||||
|
|
||||||
# STAGING_MODE: Org-Switcher im Frontend deaktivieren
|
|
||||||
is_global_admin_response = current_user.get("is_global_admin", False)
|
|
||||||
if _staging_mode():
|
|
||||||
is_global_admin_response = False
|
|
||||||
|
|
||||||
return UserMeResponse(
|
return UserMeResponse(
|
||||||
id=current_user["id"],
|
id=current_user["id"],
|
||||||
@@ -228,9 +216,7 @@ async def get_me(
|
|||||||
license_status=license_info.get("status", "unknown"),
|
license_status=license_info.get("status", "unknown"),
|
||||||
license_type=license_info.get("license_type", ""),
|
license_type=license_info.get("license_type", ""),
|
||||||
read_only=license_info.get("read_only", False),
|
read_only=license_info.get("read_only", False),
|
||||||
read_only_reason=license_info.get("read_only_reason"),
|
is_global_admin=current_user.get("is_global_admin", False),
|
||||||
unlimited_budget=unlimited_budget,
|
|
||||||
is_global_admin=is_global_admin_response,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -613,98 +613,6 @@ 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,
|
||||||
@@ -1165,18 +1073,8 @@ async def export_incident(
|
|||||||
)
|
)
|
||||||
snapshots = [dict(r) for r in await cursor.fetchall()]
|
snapshots = [dict(r) for r in await cursor.fetchall()]
|
||||||
|
|
||||||
# Zusammenfassung fuer den Export:
|
# Executive Summary (KI-generiert, gecacht)
|
||||||
# - Bei Adhoc-Lagen primaer "Neueste Entwicklungen" (latest_developments) als Markdown-Bullets,
|
exec_summary = incident.get("executive_summary")
|
||||||
# weil Live-Monitoring von Aktualitaet lebt.
|
|
||||||
# - Fallback (oder bei Research): Executive Summary (KI-generiert, gecacht).
|
|
||||||
is_adhoc = (incident.get("type") or "adhoc") != "research"
|
|
||||||
latest_dev = (incident.get("latest_developments") or "").strip()
|
|
||||||
exec_summary = None
|
|
||||||
if is_adhoc and latest_dev:
|
|
||||||
from report_generator import _markdown_to_html as _md_to_html
|
|
||||||
exec_summary = _md_to_html(latest_dev)
|
|
||||||
if not exec_summary:
|
|
||||||
exec_summary = incident.get("executive_summary")
|
|
||||||
if not exec_summary:
|
if not exec_summary:
|
||||||
summary_text = incident.get("summary") or ""
|
summary_text = incident.get("summary") or ""
|
||||||
exec_summary = await generate_executive_summary(summary_text)
|
exec_summary = await generate_executive_summary(summary_text)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Lizenz-Verwaltung und -Pruefung."""
|
"""Lizenz-Verwaltung und -Pruefung."""
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from config import TIMEZONE
|
from config import TIMEZONE
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
@@ -8,21 +7,11 @@ import aiosqlite
|
|||||||
logger = logging.getLogger("osint.license")
|
logger = logging.getLogger("osint.license")
|
||||||
|
|
||||||
|
|
||||||
def _staging_mode() -> bool:
|
|
||||||
"""Staging-Mode aktiv? Wenn ja, gilt: immer unlimited Budget, kein Hard-Stop.
|
|
||||||
|
|
||||||
Wird ueber ENV-Variable STAGING_MODE=1 (oder true) aktiviert.
|
|
||||||
Nur in Staging-.env gesetzt; Live-.env hat das Flag nicht.
|
|
||||||
"""
|
|
||||||
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
|
|
||||||
|
|
||||||
|
|
||||||
async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
||||||
"""Prueft den Lizenzstatus einer Organisation.
|
"""Prueft den Lizenzstatus einer Organisation.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict mit: valid, status, license_type, max_users, current_users, read_only,
|
dict mit: valid, status, license_type, max_users, current_users, read_only, message
|
||||||
read_only_reason, message, unlimited_budget, credits_total, credits_used
|
|
||||||
"""
|
"""
|
||||||
# Organisation pruefen
|
# Organisation pruefen
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
@@ -31,14 +20,10 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
)
|
)
|
||||||
org = await cursor.fetchone()
|
org = await cursor.fetchone()
|
||||||
if not org:
|
if not org:
|
||||||
return {"valid": False, "status": "not_found", "read_only": True,
|
return {"valid": False, "status": "not_found", "read_only": True, "message": "Organisation nicht gefunden"}
|
||||||
"read_only_reason": "not_found",
|
|
||||||
"message": "Organisation nicht gefunden"}
|
|
||||||
|
|
||||||
if not org["is_active"]:
|
if not org["is_active"]:
|
||||||
return {"valid": False, "status": "org_disabled", "read_only": True,
|
return {"valid": False, "status": "org_disabled", "read_only": True, "message": "Organisation deaktiviert"}
|
||||||
"read_only_reason": "org_disabled",
|
|
||||||
"message": "Organisation deaktiviert"}
|
|
||||||
|
|
||||||
# Aktive Lizenz suchen
|
# Aktive Lizenz suchen
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
@@ -50,19 +35,7 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
license_row = await cursor.fetchone()
|
license_row = await cursor.fetchone()
|
||||||
|
|
||||||
if not license_row:
|
if not license_row:
|
||||||
return {"valid": False, "status": "no_license", "read_only": True,
|
return {"valid": False, "status": "no_license", "read_only": True, "message": "Keine aktive Lizenz"}
|
||||||
"read_only_reason": "no_license",
|
|
||||||
"message": "Keine aktive Lizenz"}
|
|
||||||
|
|
||||||
# Felder zur weiteren Verwendung extrahieren
|
|
||||||
lic_dict = dict(license_row)
|
|
||||||
unlimited_budget = bool(lic_dict.get("unlimited_budget"))
|
|
||||||
credits_total = lic_dict.get("credits_total")
|
|
||||||
credits_used = lic_dict.get("credits_used") or 0
|
|
||||||
|
|
||||||
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
|
|
||||||
if _staging_mode():
|
|
||||||
unlimited_budget = True
|
|
||||||
|
|
||||||
# Ablauf pruefen
|
# Ablauf pruefen
|
||||||
now = datetime.now(TIMEZONE)
|
now = datetime.now(TIMEZONE)
|
||||||
@@ -79,21 +52,11 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
"status": "expired",
|
"status": "expired",
|
||||||
"license_type": license_row["license_type"],
|
"license_type": license_row["license_type"],
|
||||||
"read_only": True,
|
"read_only": True,
|
||||||
"read_only_reason": "expired",
|
|
||||||
"message": "Lizenz abgelaufen",
|
"message": "Lizenz abgelaufen",
|
||||||
"unlimited_budget": unlimited_budget,
|
|
||||||
"credits_total": credits_total,
|
|
||||||
"credits_used": credits_used,
|
|
||||||
}
|
}
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Budget-Check (Hard-Stop bei aufgebrauchten Credits, ausser unlimited)
|
|
||||||
budget_exceeded = False
|
|
||||||
if not unlimited_budget and credits_total and credits_total > 0:
|
|
||||||
if credits_used >= credits_total:
|
|
||||||
budget_exceeded = True
|
|
||||||
|
|
||||||
# Nutzerzahl pruefen
|
# Nutzerzahl pruefen
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1",
|
"SELECT COUNT(*) as cnt FROM users WHERE organization_id = ? AND is_active = 1",
|
||||||
@@ -101,21 +64,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
)
|
)
|
||||||
current_users = (await cursor.fetchone())["cnt"]
|
current_users = (await cursor.fetchone())["cnt"]
|
||||||
|
|
||||||
if budget_exceeded:
|
|
||||||
return {
|
|
||||||
"valid": True, # Lizenz ist gueltig, aber Budget aufgebraucht -> read-only
|
|
||||||
"status": "budget_exceeded",
|
|
||||||
"license_type": license_row["license_type"],
|
|
||||||
"max_users": license_row["max_users"],
|
|
||||||
"current_users": current_users,
|
|
||||||
"read_only": True,
|
|
||||||
"read_only_reason": "budget_exceeded",
|
|
||||||
"message": "Token-Budget aufgebraucht",
|
|
||||||
"unlimited_budget": False,
|
|
||||||
"credits_total": credits_total,
|
|
||||||
"credits_used": credits_used,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
"status": license_row["status"],
|
"status": license_row["status"],
|
||||||
@@ -123,11 +71,7 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
|
|||||||
"max_users": license_row["max_users"],
|
"max_users": license_row["max_users"],
|
||||||
"current_users": current_users,
|
"current_users": current_users,
|
||||||
"read_only": False,
|
"read_only": False,
|
||||||
"read_only_reason": None,
|
|
||||||
"message": "Lizenz aktiv",
|
"message": "Lizenz aktiv",
|
||||||
"unlimited_budget": unlimited_budget,
|
|
||||||
"credits_total": credits_total,
|
|
||||||
"credits_used": credits_used,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
"""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": "factcheck",
|
|
||||||
"label": "Fakten prüfen",
|
|
||||||
"icon": "shield",
|
|
||||||
"tooltip": "Behauptungen aus den Meldungen werden gegeneinander abgeglichen: Bestätigt? Umstritten? Noch unklar?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "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)
|
|
||||||
@@ -400,20 +400,18 @@ async def run_post_refresh_qc(db, incident_id: int) -> dict:
|
|||||||
db, incident_id, incident_title, incident_desc
|
db, incident_id, incident_title, incident_desc
|
||||||
)
|
)
|
||||||
umlauts_fixed = await normalize_umlaut_fields(db, incident_id)
|
umlauts_fixed = await normalize_umlaut_fields(db, incident_id)
|
||||||
article_umlauts_fixed = await normalize_umlaut_articles(db, incident_id)
|
|
||||||
|
|
||||||
total_umlaut_changes = umlauts_fixed + article_umlauts_fixed
|
if facts_removed > 0 or locations_fixed > 0 or umlauts_fixed > 0:
|
||||||
if facts_removed > 0 or locations_fixed > 0 or total_umlaut_changes > 0:
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Post-Refresh QC fuer Incident %d: %d Duplikate entfernt, %d Locations korrigiert, %d Umlaute normalisiert (davon %d in Articles)",
|
"Post-Refresh QC fuer Incident %d: %d Duplikate entfernt, %d Locations korrigiert, %d Umlaute normalisiert",
|
||||||
incident_id, facts_removed, locations_fixed, total_umlaut_changes, article_umlauts_fixed,
|
incident_id, facts_removed, locations_fixed, umlauts_fixed,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"facts_removed": facts_removed,
|
"facts_removed": facts_removed,
|
||||||
"locations_fixed": locations_fixed,
|
"locations_fixed": locations_fixed,
|
||||||
"umlauts_fixed": total_umlaut_changes,
|
"umlauts_fixed": umlauts_fixed,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -570,64 +568,3 @@ async def normalize_umlaut_fields(db, incident_id: int) -> int:
|
|||||||
incident_id, count_summary, count_dev,
|
incident_id, count_summary, count_dev,
|
||||||
)
|
)
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
async def normalize_umlaut_articles(db, incident_id: int) -> int:
|
|
||||||
"""Normalisiert Umlaute in allen Artikel-Texten des Incidents.
|
|
||||||
|
|
||||||
Felder die behandelt werden:
|
|
||||||
- headline_de und content_de bei allen Artikeln (LLM-Uebersetzung kann
|
|
||||||
ASCII-Umlaute liefern trotz Prompt-Anweisung)
|
|
||||||
- headline und content_original bei language='de' (manche Quellen wie
|
|
||||||
dpa-AFX, Telegram-Kanaele liefern selbst schon ASCII-Umlaute)
|
|
||||||
|
|
||||||
Idempotent: Wenn der Text schon korrekt ist, macht das Dict-Lookup
|
|
||||||
keine Aenderung und wir schreiben nicht zurueck.
|
|
||||||
|
|
||||||
Rueckgabe: Gesamtzahl der Wort-Ersetzungen ueber alle Artikel.
|
|
||||||
"""
|
|
||||||
cursor = await db.execute(
|
|
||||||
"""SELECT id, language, headline, headline_de, content_original, content_de
|
|
||||||
FROM articles WHERE incident_id = ?""",
|
|
||||||
(incident_id,),
|
|
||||||
)
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
if not rows:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
total = 0
|
|
||||||
for row in rows:
|
|
||||||
is_de = (row["language"] or "").lower() == "de"
|
|
||||||
updates = {}
|
|
||||||
|
|
||||||
# Felder die immer behandelt werden (LLM-Uebersetzungen)
|
|
||||||
if row["headline_de"]:
|
|
||||||
new, n = normalize_german_umlauts(row["headline_de"])
|
|
||||||
if n > 0:
|
|
||||||
updates["headline_de"] = new
|
|
||||||
total += n
|
|
||||||
if row["content_de"]:
|
|
||||||
new, n = normalize_german_umlauts(row["content_de"])
|
|
||||||
if n > 0:
|
|
||||||
updates["content_de"] = new
|
|
||||||
total += n
|
|
||||||
|
|
||||||
# Originalfelder nur bei deutschen Quellen
|
|
||||||
if is_de:
|
|
||||||
if row["headline"]:
|
|
||||||
new, n = normalize_german_umlauts(row["headline"])
|
|
||||||
if n > 0:
|
|
||||||
updates["headline"] = new
|
|
||||||
total += n
|
|
||||||
if row["content_original"]:
|
|
||||||
new, n = normalize_german_umlauts(row["content_original"])
|
|
||||||
if n > 0:
|
|
||||||
updates["content_original"] = new
|
|
||||||
total += n
|
|
||||||
|
|
||||||
if updates:
|
|
||||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
|
||||||
values = list(updates.values()) + [row["id"]]
|
|
||||||
await db.execute(f"UPDATE articles SET {set_clause} WHERE id = ?", values)
|
|
||||||
|
|
||||||
return total
|
|
||||||
|
|||||||
@@ -649,14 +649,14 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss
|
|||||||
try:
|
try:
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
|
"SELECT name, url, domain, category, COALESCE(article_count, 0) AS article_count FROM sources "
|
||||||
"WHERE source_type = ? AND status = 'active' "
|
"WHERE source_type = ? AND status = 'active' "
|
||||||
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
"AND (tenant_id IS NULL OR tenant_id = ?)",
|
||||||
(source_type, tenant_id),
|
(source_type, tenant_id),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT name, url, domain, category, notes, COALESCE(article_count, 0) AS article_count FROM sources "
|
"SELECT name, url, domain, category, COALESCE(article_count, 0) AS article_count FROM sources "
|
||||||
"WHERE source_type = ? AND status = 'active'",
|
"WHERE source_type = ? AND status = 'active'",
|
||||||
(source_type,),
|
(source_type,),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -549,31 +549,6 @@ a:hover {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-dropdown-action {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
.header-dropdown-action:hover {
|
|
||||||
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.header-dropdown-action svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-license-badge {
|
.header-license-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -1729,108 +1704,6 @@ a.dev-source-pill:hover {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s ease, background 0.15s ease;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.source-overview-item:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
}
|
|
||||||
.source-overview-item:focus-visible {
|
|
||||||
box-shadow: 0 0 0 2px var(--tint-accent-strong);
|
|
||||||
}
|
|
||||||
.source-overview-item.active {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--tint-accent-subtle);
|
|
||||||
box-shadow: var(--glow-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline-Aufklapp-Bereich (volle Reihen-Breite, direkt unter dem geklickten Item) */
|
|
||||||
.source-overview-detail {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
padding: var(--sp-md) var(--sp-lg);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
animation: source-detail-in 0.18s ease;
|
|
||||||
}
|
|
||||||
@keyframes source-detail-in {
|
|
||||||
from { opacity: 0; transform: translateY(-4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.source-overview-detail-empty {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.source-overview-detail-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.source-overview-detail-list::-webkit-scrollbar { width: 6px; }
|
|
||||||
.source-overview-detail-list::-webkit-scrollbar-track { background: var(--bg-primary); border-radius: 3px; }
|
|
||||||
.source-overview-detail-list::-webkit-scrollbar-thumb { background: var(--text-disabled); border-radius: 3px; }
|
|
||||||
.source-overview-detail-list li {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
padding: 4px 0;
|
|
||||||
border-top: 1px dashed var(--border);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto 1fr;
|
|
||||||
gap: var(--sp-md);
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
.source-overview-detail-list li:first-child { border-top: none; }
|
|
||||||
.source-overview-detail-list li a {
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.source-overview-detail-list li a:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.source-overview-detail-num {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent);
|
|
||||||
min-width: 36px;
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.source-overview-detail-num--none {
|
|
||||||
color: var(--text-disabled);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.source-overview-detail-date {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.source-overview-detail-headline {
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.source-overview-detail-list li {
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
}
|
|
||||||
.source-overview-detail-date {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
margin-left: 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.source-overview-detail { animation: none; }
|
|
||||||
.source-overview-item { transition: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-overview-name {
|
.source-overview-name {
|
||||||
@@ -2260,19 +2133,12 @@ a.dev-source-pill:hover {
|
|||||||
font-size: 12px; color: var(--accent); font-weight: 600;
|
font-size: 12px; color: var(--accent); font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Blur for First Refresh ===
|
/* === Blur for First Refresh === */
|
||||||
* Liegt auf #incident-view, damit Header (Titel/Aktionen/Beschreibung) und
|
.tab-panels.blurred .tab-panel {
|
||||||
* Tab-Panels gemeinsam unscharf werden. will-change + translateZ erzwingen
|
|
||||||
* einen persistenten GPU-Composite-Layer, sodass der Effekt bei Window-Resize
|
|
||||||
* und Reflow nicht zerschossen wird. Keine Transition: Blur soll schlagartig
|
|
||||||
* kommen und schlagartig gehen, sonst sieht man waehrend des Reflows einen
|
|
||||||
* lesbaren Zwischenzustand. */
|
|
||||||
#incident-view.refresh-blurred {
|
|
||||||
filter: blur(8px);
|
filter: blur(8px);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
will-change: filter;
|
transition: filter 0.4s ease;
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Disabled Actions During First Refresh === */
|
/* === Disabled Actions During First Refresh === */
|
||||||
@@ -2577,113 +2443,213 @@ a.dev-source-pill:hover {
|
|||||||
padding: 12px 20px 8px;
|
padding: 12px 20px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Timeline: Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter === */
|
/* Achsen-Container */
|
||||||
.ht-tl {
|
.ht-axis {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Heatmap-Strip */
|
|
||||||
.ht-strip {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 0 6px;
|
|
||||||
}
|
|
||||||
.ht-strip-cells {
|
|
||||||
display: grid;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
grid-auto-columns: minmax(8px, 1fr);
|
|
||||||
gap: 2px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
.ht-strip-cell {
|
|
||||||
background: color-mix(in srgb, var(--accent) calc(var(--intensity) * 70%), var(--border));
|
|
||||||
border-radius: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
min-height: 12px;
|
|
||||||
}
|
|
||||||
.ht-strip-cell.empty {
|
|
||||||
background: var(--border);
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.ht-strip-cell:hover:not(.empty) {
|
|
||||||
transform: scaleY(1.6);
|
|
||||||
box-shadow: var(--glow-accent);
|
|
||||||
}
|
|
||||||
.ht-strip-cell.has-snapshot {
|
|
||||||
box-shadow: inset 0 -3px 0 var(--accent);
|
|
||||||
}
|
|
||||||
.ht-strip-cell.active {
|
|
||||||
background: var(--accent);
|
|
||||||
transform: scaleY(1.6);
|
|
||||||
box-shadow: var(--glow-accent-strong), inset 0 -3px 0 var(--accent);
|
|
||||||
z-index: 2;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
height: 110px;
|
||||||
.ht-strip:has(.ht-strip-cell.active) .ht-strip-cell:not(.active):not(.empty) {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Banner: aktiver Strip-Filter */
|
/* Stündliches Layout: höher wegen Datums-Markern oben */
|
||||||
.ht-strip-banner {
|
.ht-axis--hourly {
|
||||||
display: flex;
|
height: 130px;
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-md);
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: var(--tint-accent);
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
.ht-strip-banner-icon {
|
|
||||||
color: var(--accent);
|
/* Punkte-Bereich (über der Linie) */
|
||||||
|
.ht-points {
|
||||||
|
position: absolute;
|
||||||
|
left: 4%;
|
||||||
|
right: 4%;
|
||||||
|
top: 0;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-axis--hourly .ht-points {
|
||||||
|
top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Achsenlinie */
|
||||||
|
.ht-axis-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 2%;
|
||||||
|
right: 2%;
|
||||||
|
top: 60px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-axis--hourly .ht-axis-line {
|
||||||
|
top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Datums-Marker (vertikale Linie + Datum oben, nur bei Stunden-Granularität) */
|
||||||
|
.ht-day-markers {
|
||||||
|
position: absolute;
|
||||||
|
left: 4%;
|
||||||
|
right: 4%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-day-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-day-marker-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
|
||||||
.ht-strip-banner-text {
|
|
||||||
flex: 1;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.ht-strip-banner-text strong {
|
|
||||||
color: var(--accent);
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
|
||||||
.ht-strip-banner-close {
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 10px;
|
color: var(--accent);
|
||||||
border-radius: var(--radius);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
.ht-strip-banner-close:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--bg-card);
|
|
||||||
}
|
|
||||||
.ht-strip-labels {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 9px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
.ht-strip-label {
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stream-Container */
|
.ht-day-marker-line {
|
||||||
.ht-stream {
|
position: absolute;
|
||||||
margin-top: var(--sp-md);
|
top: 14px;
|
||||||
|
height: 66px;
|
||||||
|
width: 1px;
|
||||||
|
left: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Punkt (Basis) */
|
||||||
|
.ht-point {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-disabled);
|
||||||
|
border: 2px solid var(--bg-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-point:hover {
|
||||||
|
box-shadow: var(--glow-accent);
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-point.active {
|
||||||
|
box-shadow: var(--glow-accent-strong);
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dimmen: nicht-aktive Punkte verblassen wenn ein Punkt aktiv ist */
|
||||||
|
.ht-points:has(.ht-point.active) .ht-point:not(.active) {
|
||||||
|
opacity: 0.3;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pfeil über dem aktiven Punkt */
|
||||||
|
.ht-point.active::after {
|
||||||
|
content: '▼';
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 2px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Snapshot-Punkt (Raute) */
|
||||||
|
.ht-point.ht-snapshot-point {
|
||||||
|
border-radius: 2px;
|
||||||
|
transform: translateX(-50%) rotate(45deg);
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: var(--glow-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-point.ht-snapshot-point .ht-tooltip,
|
||||||
|
.ht-point.ht-snapshot-point .ht-point-count {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-point.ht-snapshot-point .ht-tooltip {
|
||||||
|
transform: rotate(-45deg) translateX(-50%);
|
||||||
|
transform-origin: bottom left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gemischter Punkt (Gold-Kreis) */
|
||||||
|
.ht-point.ht-mixed-point {
|
||||||
|
background: var(--accent);
|
||||||
|
border: 2px solid var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip (über dem Punkt) */
|
||||||
|
.ht-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-point:hover .ht-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zahl unter dem Punkt */
|
||||||
|
.ht-point-count {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-disabled);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-point.active .ht-point-count,
|
||||||
|
.ht-point:hover .ht-point-count {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Achsen-Labels (unter der Linie) */
|
||||||
|
.ht-axis-labels {
|
||||||
|
position: absolute;
|
||||||
|
left: 4%;
|
||||||
|
right: 4%;
|
||||||
|
top: 72px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-axis--hourly .ht-axis-labels {
|
||||||
|
top: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-axis-label {
|
||||||
|
position: absolute;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leerer Zustand */
|
||||||
.ht-empty {
|
.ht-empty {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -2691,19 +2657,60 @@ a.dev-source-pill:hover {
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Time-Group Flash beim Scrollen vom Strip */
|
/* Detail-Panel */
|
||||||
.vt-time-group--flash {
|
.ht-detail-panel {
|
||||||
animation: vt-group-flash 1.2s ease-out;
|
margin-top: 8px;
|
||||||
}
|
border: 1px solid var(--border);
|
||||||
@keyframes vt-group-flash {
|
border-radius: var(--radius);
|
||||||
0% { background: var(--tint-accent-strong); }
|
background: var(--bg-secondary);
|
||||||
100% { background: transparent; }
|
animation: ht-slide-down 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@keyframes ht-slide-down {
|
||||||
.vt-time-group--flash { animation: none; }
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ht-detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-detail-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-detail-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-disabled);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-detail-close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-detail-content {
|
||||||
|
max-height: 350px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ht-detail-content::-webkit-scrollbar { width: 6px; }
|
||||||
|
.ht-detail-content::-webkit-scrollbar-track { background: var(--bg-primary); border-radius: 3px; }
|
||||||
|
.ht-detail-content::-webkit-scrollbar-thumb { background: var(--text-disabled); border-radius: 3px; }
|
||||||
|
.ht-detail-content::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
|
||||||
|
|
||||||
/* === Briefing Listen === */
|
/* === Briefing Listen === */
|
||||||
.briefing-content ul {
|
.briefing-content ul {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
@@ -4028,6 +4035,7 @@ a.dev-source-pill:hover {
|
|||||||
.tab-panel .ht-timeline-container {
|
.tab-panel .ht-timeline-container {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
.tab-panels.blurred { filter: blur(4px); pointer-events: none; }
|
||||||
|
|
||||||
.grid-stack .card-header:active {
|
.grid-stack .card-header:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
@@ -5624,420 +5632,3 @@ 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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
<link rel="stylesheet" href="/static/vendor/leaflet.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
|
||||||
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
|
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=20260501h">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260316k">
|
||||||
<style>
|
<style>
|
||||||
/* Export Modal Radio */
|
/* Export Modal Radio */
|
||||||
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
|
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
|
||||||
@@ -72,11 +72,6 @@
|
|||||||
<span class="credits-percent" id="credits-percent"></span>
|
<span class="credits-percent" id="credits-percent"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="credits-divider"></div>
|
|
||||||
<button class="header-dropdown-action" type="button" onclick="AIDisclaimer && AIDisclaimer.show()">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
|
||||||
<span>Über KI-Inhalte</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-license-warning" id="header-license-warning"></div>
|
<div class="header-license-warning" id="header-license-warning"></div>
|
||||||
@@ -123,14 +118,8 @@
|
|||||||
<div id="archived-incidents" aria-live="polite" style="display:none;"></div>
|
<div id="archived-incidents" aria-live="polite" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-sources-link">
|
<div class="sidebar-sources-link">
|
||||||
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()" title="Quellen verwalten">
|
<button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()">Quellen verwalten</button>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg>
|
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()">Feedback senden</button>
|
||||||
<span>Quellen</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()" title="Feedback senden">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-10 5L2 7"/></svg>
|
|
||||||
<span>Feedback</span>
|
|
||||||
</button>
|
|
||||||
<!-- Tutorial-Einstieg temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
|
<!-- Tutorial-Einstieg temporaer deaktiviert (Ueberarbeitung) - reaktivieren durch Entfernen der Kommentarzeichen:
|
||||||
<button class="btn btn-secondary btn-full btn-small" onclick="Tutorial.start()" title="Interaktiven Rundgang starten">Rundgang starten</button>
|
<button class="btn btn-secondary btn-full btn-small" onclick="Tutorial.start()" title="Interaktiven Rundgang starten">Rundgang starten</button>
|
||||||
-->
|
-->
|
||||||
@@ -209,7 +198,6 @@
|
|||||||
<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>
|
||||||
|
|
||||||
@@ -293,23 +281,6 @@
|
|||||||
</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">
|
||||||
@@ -657,8 +628,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=20260501i"></script>
|
<script src="/static/js/app.js?v=20260427c"></script>
|
||||||
<script src="/static/js/app.js?v=20260501h"></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>
|
||||||
<script src="/static/js/chat.js?v=20260422a"></script>
|
<script src="/static/js/chat.js?v=20260422a"></script>
|
||||||
@@ -717,8 +687,7 @@
|
|||||||
</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="pipeline-mini" id="progress-pipeline-mini" aria-label="Analyseschritte"></div>
|
<div class="progress-checklist" id="progress-checklist">
|
||||||
<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>
|
||||||
@@ -749,6 +718,5 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/update-system.js"></script>
|
<script src="/static/js/update-system.js"></script>
|
||||||
<script src="/static/js/ai-disclaimer.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* AI-Hallucination-Disclaimer fuer den AegisSight Monitor.
|
|
||||||
*
|
|
||||||
* Zeigt:
|
|
||||||
* 1) Beim ersten Besuch (oder bei neuem v-Bump) ein Modal mit Hinweisen
|
|
||||||
* zur Fehlbarkeit von KI-Modellen.
|
|
||||||
* 2) Im Header-User-Dropdown immer einen Eintrag "Ueber KI-Inhalte",
|
|
||||||
* ueber den der User das Modal jederzeit erneut oeffnen kann.
|
|
||||||
*
|
|
||||||
* Persistenz:
|
|
||||||
* localStorage 'aegis_ai_disclaimer_seen' -> Versionsstring (z.B. "v1").
|
|
||||||
* Wenn die Version sich aendert (Wortlaut-Update), erscheint das Modal
|
|
||||||
* beim naechsten Login erneut.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'aegis_ai_disclaimer_seen';
|
|
||||||
const CURRENT_VERSION = 'v1';
|
|
||||||
|
|
||||||
// ---- DOM-Helpers (analog zu update-system.js) ----
|
|
||||||
function el(tag, attrs, ...children) {
|
|
||||||
const e = document.createElement(tag);
|
|
||||||
for (const k in (attrs || {})) {
|
|
||||||
if (k === 'class') e.className = attrs[k];
|
|
||||||
else if (k === 'html') e.innerHTML = attrs[k];
|
|
||||||
else if (k.startsWith('on')) e.addEventListener(k.slice(2), attrs[k]);
|
|
||||||
else e.setAttribute(k, attrs[k]);
|
|
||||||
}
|
|
||||||
for (const c of children) {
|
|
||||||
if (c == null) continue;
|
|
||||||
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
||||||
}
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
function injectStyles() {
|
|
||||||
if (document.getElementById('aegis-aidisc-styles')) return;
|
|
||||||
const css = `
|
|
||||||
#aegis-aidisc-overlay {
|
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 99998;
|
|
||||||
backdrop-filter: blur(3px);
|
|
||||||
display: flex; align-items: center; justify-content: center; padding: 24px;
|
|
||||||
animation: aegis-aidisc-fade 0.25s ease;
|
|
||||||
}
|
|
||||||
@keyframes aegis-aidisc-fade { from { opacity: 0; } to { opacity: 1; } }
|
|
||||||
#aegis-aidisc-modal {
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
box-shadow: 0 24px 80px rgba(0,0,0,0.4);
|
|
||||||
font-family: 'Inter', -apple-system, sans-serif;
|
|
||||||
max-width: 580px; width: 100%; max-height: 85vh; overflow: hidden;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
}
|
|
||||||
#aegis-aidisc-modal header {
|
|
||||||
padding: 22px 28px 18px; border-bottom: 1px solid var(--border);
|
|
||||||
display: flex; align-items: center; gap: 12px;
|
|
||||||
}
|
|
||||||
#aegis-aidisc-modal header svg { color: var(--accent); flex-shrink: 0; }
|
|
||||||
#aegis-aidisc-modal h2 { margin: 0; color: var(--accent); font-size: 1.25rem; font-weight: 700; }
|
|
||||||
#aegis-aidisc-modal .body { padding: 18px 28px; overflow-y: auto; line-height: 1.55; }
|
|
||||||
#aegis-aidisc-modal .body p { margin: 0 0 12px; color: var(--text-primary); font-size: 0.94rem; }
|
|
||||||
#aegis-aidisc-modal .body strong { color: var(--accent); }
|
|
||||||
#aegis-aidisc-modal .body ul { margin: 8px 0 14px; padding-left: 22px; }
|
|
||||||
#aegis-aidisc-modal .body li { margin-bottom: 6px; color: var(--text-secondary); font-size: 0.92rem; }
|
|
||||||
#aegis-aidisc-modal .footnote {
|
|
||||||
margin-top: 10px; padding-top: 12px; border-top: 1px solid var(--border);
|
|
||||||
color: var(--text-tertiary); font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
#aegis-aidisc-modal footer {
|
|
||||||
padding: 14px 28px 20px; border-top: 1px solid var(--border);
|
|
||||||
display: flex; justify-content: flex-end; gap: 10px;
|
|
||||||
}
|
|
||||||
#aegis-aidisc-modal footer button {
|
|
||||||
background: var(--accent); color: #fff; border: 0; padding: 10px 22px;
|
|
||||||
border-radius: 6px; font: inherit; font-size: 0.92rem; font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
#aegis-aidisc-modal footer button:hover { background: var(--accent-hover); }
|
|
||||||
#aegis-aidisc-modal footer button.secondary {
|
|
||||||
background: transparent; color: var(--text-secondary); border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
#aegis-aidisc-modal footer button.secondary:hover {
|
|
||||||
background: var(--bg-hover, rgba(255,255,255,0.04)); color: var(--text-primary);
|
|
||||||
}`;
|
|
||||||
document.head.appendChild(el('style', { id: 'aegis-aidisc-styles', html: css }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Modal-Aufbau ----
|
|
||||||
function buildModal(opts) {
|
|
||||||
const isFromUser = !!(opts && opts.fromUserAction);
|
|
||||||
|
|
||||||
// Lucide info-Icon (gleiches Pattern wie .info-icon im Repo)
|
|
||||||
const headerIcon = el('span', {
|
|
||||||
html: '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" '
|
|
||||||
+ '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="M12 16v-4"/><path d="M12 8h.01"/></svg>'
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = el('div', { class: 'body' });
|
|
||||||
body.appendChild(el('p', null,
|
|
||||||
'Der AegisSight Monitor nutzt Künstliche Intelligenz '
|
|
||||||
+ 'zur Analyse, Übersetzung und Zusammenfassung von Nachrichten.'));
|
|
||||||
|
|
||||||
const warn = el('p');
|
|
||||||
warn.innerHTML = '<strong>KI-Modelle können Fehler machen</strong> '
|
|
||||||
+ '(sogenannte „Halluzinationen"): erfundene Details, falsche Verbindungen oder '
|
|
||||||
+ 'ungenaue Zusammenfassungen sind möglich, auch wenn der Text plausibel klingt.';
|
|
||||||
body.appendChild(warn);
|
|
||||||
|
|
||||||
body.appendChild(el('p', null, 'Wir empfehlen daher:'));
|
|
||||||
body.appendChild(el('ul', null,
|
|
||||||
el('li', null, 'Wichtige Informationen mit den verlinkten Quellen verifizieren'),
|
|
||||||
el('li', null, 'Bei kritischen Entscheidungen die Originalartikel prüfen'),
|
|
||||||
el('li', null, 'Faktenchecks als Hinweis verstehen, nicht als endgültige Wahrheit')
|
|
||||||
));
|
|
||||||
|
|
||||||
body.appendChild(el('p', { class: 'footnote' },
|
|
||||||
'Diesen Hinweis findest du jederzeit wieder im Menü oben rechts unter „Über KI-Inhalte".'));
|
|
||||||
|
|
||||||
const closeAndStore = () => {
|
|
||||||
try { localStorage.setItem(STORAGE_KEY, CURRENT_VERSION); } catch (e) {}
|
|
||||||
overlay.remove();
|
|
||||||
document.removeEventListener('keydown', escHandler);
|
|
||||||
};
|
|
||||||
const closeOnly = () => {
|
|
||||||
overlay.remove();
|
|
||||||
document.removeEventListener('keydown', escHandler);
|
|
||||||
};
|
|
||||||
|
|
||||||
const footer = el('footer', null);
|
|
||||||
if (!isFromUser) {
|
|
||||||
footer.appendChild(el('button', { class: 'secondary', onclick: closeOnly }, 'Später nochmal'));
|
|
||||||
}
|
|
||||||
footer.appendChild(el('button', { onclick: closeAndStore }, 'Verstanden'));
|
|
||||||
|
|
||||||
const overlay = el('div', { id: 'aegis-aidisc-overlay' },
|
|
||||||
el('div', { id: 'aegis-aidisc-modal' },
|
|
||||||
el('header', null, headerIcon, el('h2', null, 'Hinweis zu KI-generierten Inhalten')),
|
|
||||||
body,
|
|
||||||
footer
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function escHandler(ev) {
|
|
||||||
if (ev.key === 'Escape' && document.getElementById('aegis-aidisc-overlay')) {
|
|
||||||
// ESC = wie "Verstanden" beim erstmaligen Anzeigen, sonst nur schliessen
|
|
||||||
if (isFromUser) closeOnly(); else closeAndStore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
overlay.addEventListener('click', (ev) => {
|
|
||||||
if (ev.target === overlay) {
|
|
||||||
if (isFromUser) closeOnly(); else closeAndStore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', escHandler);
|
|
||||||
|
|
||||||
return overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(opts) {
|
|
||||||
if (document.getElementById('aegis-aidisc-overlay')) return;
|
|
||||||
injectStyles();
|
|
||||||
document.body.appendChild(buildModal(opts));
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
// Nur auf der Dashboard-Seite zeigen, nicht auf der Login-Seite
|
|
||||||
if (!document.body || document.body.classList.contains('login-page')) return;
|
|
||||||
|
|
||||||
injectStyles();
|
|
||||||
let seenVersion = '';
|
|
||||||
try { seenVersion = localStorage.getItem(STORAGE_KEY) || ''; } catch (e) {}
|
|
||||||
if (seenVersion !== CURRENT_VERSION) {
|
|
||||||
// Etwas verzoegern, damit Hauptdashboard sichtbar ist bevor Modal kommt
|
|
||||||
setTimeout(() => show({ fromUserAction: false }), 600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Globaler Zugriff zum manuellen Oeffnen aus dem Header-Dropdown
|
|
||||||
window.AIDisclaimer = {
|
|
||||||
show: () => show({ fromUserAction: true }),
|
|
||||||
VERSION: CURRENT_VERSION,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -67,29 +67,6 @@ const API = {
|
|||||||
} else if (typeof detail === 'object' && detail !== null) {
|
} else if (typeof detail === 'object' && detail !== null) {
|
||||||
detail = JSON.stringify(detail);
|
detail = JSON.stringify(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lizenz-Status aus Header auslesen (vom Backend gesetzt bei 403)
|
|
||||||
const licStatus = response.headers.get('X-License-Status');
|
|
||||||
if (response.status === 403 && licStatus && typeof App !== 'undefined') {
|
|
||||||
if (!App.user) App.user = {};
|
|
||||||
App.user.read_only = true;
|
|
||||||
App.user.read_only_reason = licStatus;
|
|
||||||
const warningEl = document.getElementById('header-license-warning');
|
|
||||||
if (warningEl) {
|
|
||||||
let text = 'Nur Lesezugriff';
|
|
||||||
if (licStatus === 'budget_exceeded') text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.';
|
|
||||||
else if (licStatus === 'expired') text = 'Lizenz abgelaufen – nur Lesezugriff';
|
|
||||||
else if (licStatus === 'no_license') text = 'Keine aktive Lizenz – nur Lesezugriff';
|
|
||||||
else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert – nur Lesezugriff';
|
|
||||||
warningEl.textContent = text;
|
|
||||||
warningEl.classList.add('visible');
|
|
||||||
}
|
|
||||||
if (typeof App._updateRefreshButton === 'function') App._updateRefreshButton(false);
|
|
||||||
if (typeof UI !== 'undefined' && UI.showToast) {
|
|
||||||
UI.showToast(detail || 'Lizenz-Beschränkung – nur Lesezugriff', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ApiError(response.status, detail);
|
throw new ApiError(response.status, detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +133,6 @@ 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`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ const App = {
|
|||||||
_editingSourceId: null,
|
_editingSourceId: null,
|
||||||
_timelineFilter: 'all',
|
_timelineFilter: 'all',
|
||||||
_timelineRange: 'all',
|
_timelineRange: 'all',
|
||||||
_activeStripWindow: null,
|
_activePointIndex: null,
|
||||||
_timelineSearchTimer: null,
|
_timelineSearchTimer: null,
|
||||||
_pendingComplete: null,
|
_pendingComplete: null,
|
||||||
_pendingCompleteTimer: null,
|
_pendingCompleteTimer: null,
|
||||||
@@ -450,7 +450,6 @@ const App = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await API.getMe();
|
const user = await API.getMe();
|
||||||
this.user = user;
|
|
||||||
this._currentUsername = user.email;
|
this._currentUsername = user.email;
|
||||||
document.getElementById('header-user').textContent = user.email;
|
document.getElementById('header-user').textContent = user.email;
|
||||||
|
|
||||||
@@ -516,27 +515,11 @@ const App = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warnung bei Read-Only (Lizenz abgelaufen oder Token-Budget aufgebraucht)
|
// Warnung bei abgelaufener Lizenz
|
||||||
const warningEl = document.getElementById('header-license-warning');
|
const warningEl = document.getElementById('header-license-warning');
|
||||||
if (warningEl) {
|
if (warningEl && user.read_only) {
|
||||||
if (user.read_only) {
|
warningEl.textContent = 'Lizenz abgelaufen – nur Lesezugriff';
|
||||||
let text = 'Nur Lesezugriff';
|
warningEl.classList.add('visible');
|
||||||
const reason = user.read_only_reason;
|
|
||||||
if (reason === 'budget_exceeded') {
|
|
||||||
text = 'Token-Budget aufgebraucht – nur Lesezugriff. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren.';
|
|
||||||
} else if (reason === 'expired') {
|
|
||||||
text = 'Lizenz abgelaufen – nur Lesezugriff';
|
|
||||||
} else if (reason === 'no_license') {
|
|
||||||
text = 'Keine aktive Lizenz – nur Lesezugriff';
|
|
||||||
} else if (reason === 'org_disabled') {
|
|
||||||
text = 'Organisation deaktiviert – nur Lesezugriff';
|
|
||||||
}
|
|
||||||
warningEl.textContent = text;
|
|
||||||
warningEl.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
warningEl.textContent = '';
|
|
||||||
warningEl.classList.remove('visible');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Global Admin: Org-Switcher (herausnehmbar) ---
|
// --- Global Admin: Org-Switcher (herausnehmbar) ---
|
||||||
@@ -618,10 +601,6 @@ const App = {
|
|||||||
const inc = this.incidents.find(i => i.id === id);
|
const inc = this.incidents.find(i => i.id === id);
|
||||||
const isFirst = inc && !inc.has_summary;
|
const isFirst = inc && !inc.has_summary;
|
||||||
UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst);
|
UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst);
|
||||||
// Pipeline-Reset auch nach F5: aktive Lage in Queue -> Icons grau
|
|
||||||
if (id === this.currentIncidentId && typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
|
|
||||||
Pipeline.beginQueue(id);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,12 +735,12 @@ const App = {
|
|||||||
if (prevOverlay) prevOverlay.style.display = 'none';
|
if (prevOverlay) prevOverlay.style.display = 'none';
|
||||||
const prevMini = document.getElementById('progress-mini');
|
const prevMini = document.getElementById('progress-mini');
|
||||||
if (prevMini) prevMini.style.display = 'none';
|
if (prevMini) prevMini.style.display = 'none';
|
||||||
const blurTarget = document.getElementById('incident-view');
|
const grid = document.querySelector('.tab-panels');
|
||||||
// Wenn gerade ein erster Refresh laeuft, Blur stehen lassen statt
|
// Wenn gerade ein erster Refresh laeuft, Blur stehen lassen statt
|
||||||
// remove+add im selben Tick — CSS filter:blur greift sonst nicht.
|
// remove+add im selben Tick — CSS filter:blur greift sonst nicht.
|
||||||
const _restState = isRefreshing ? UI._progressState[id] : null;
|
const _restState = isRefreshing ? UI._progressState[id] : null;
|
||||||
const _willReBlur = _restState && _restState.isFirst && !_restState.minimized;
|
const _willReBlur = _restState && _restState.isFirst && !_restState.minimized;
|
||||||
if (blurTarget && !_willReBlur) blurTarget.classList.remove('refresh-blurred');
|
if (grid && !_willReBlur) grid.classList.remove('blurred');
|
||||||
|
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
const state = UI._progressState[id];
|
const state = UI._progressState[id];
|
||||||
@@ -854,11 +833,6 @@ 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));
|
||||||
|
|
||||||
@@ -887,97 +861,6 @@ const App = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
|
|
||||||
toggleSourceOverviewDetail(el) {
|
|
||||||
if (!el) return;
|
|
||||||
const grid = el.parentElement;
|
|
||||||
if (!grid) return;
|
|
||||||
const sourceName = el.dataset.source || '';
|
|
||||||
const wasActive = el.classList.contains('active');
|
|
||||||
|
|
||||||
// Alle anderen schliessen + bestehendes Detail entfernen
|
|
||||||
grid.querySelectorAll('.source-overview-item.active').forEach(it => {
|
|
||||||
it.classList.remove('active');
|
|
||||||
it.setAttribute('aria-expanded', 'false');
|
|
||||||
});
|
|
||||||
const existingDetail = grid.querySelector('.source-overview-detail');
|
|
||||||
if (existingDetail) existingDetail.remove();
|
|
||||||
|
|
||||||
// Wenn das geklickte Item bereits aktiv war: nur schliessen
|
|
||||||
if (wasActive) return;
|
|
||||||
|
|
||||||
// Neues Detail einfuegen direkt nach dem geklickten Item
|
|
||||||
el.classList.add('active');
|
|
||||||
el.setAttribute('aria-expanded', 'true');
|
|
||||||
|
|
||||||
const type = this._currentIncidentType;
|
|
||||||
const getDate = (a) => (type === 'research' && a.published_at) ? a.published_at : (a.collected_at || a.published_at);
|
|
||||||
const articles = (this._currentArticles || [])
|
|
||||||
.filter(a => (a.source || 'Unbekannt') === sourceName)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const ta = new Date(getDate(a) || 0).getTime();
|
|
||||||
const tb = new Date(getDate(b) || 0).getTime();
|
|
||||||
return tb - ta;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lagebild-Quellennummer pro Artikel ermitteln (matcht Artikel zu sources_json)
|
|
||||||
const normalize = (s) => (s || '').toLowerCase().replace(/^(der|die|das)\s+/, '').replace(/\s+/g, ' ').trim();
|
|
||||||
const sourcesList = this._currentSources || [];
|
|
||||||
const urlToNr = new Map();
|
|
||||||
sourcesList.forEach(s => {
|
|
||||||
if (s.url && s.nr != null) urlToNr.set(String(s.url).trim(), s.nr);
|
|
||||||
});
|
|
||||||
const findNr = (a) => {
|
|
||||||
// 1) Exakter URL-Match
|
|
||||||
if (a.source_url) {
|
|
||||||
const exact = urlToNr.get(String(a.source_url).trim());
|
|
||||||
if (exact != null) return exact;
|
|
||||||
}
|
|
||||||
// 2) Fallback: Match via Quellen-Namen (kann mehrfach treffen, nimm erstes)
|
|
||||||
if (a.source) {
|
|
||||||
const target = normalize(a.source);
|
|
||||||
const hit = sourcesList.find(s => s.nr != null && normalize(s.name) === target);
|
|
||||||
if (hit) return hit.nr;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const detail = document.createElement('div');
|
|
||||||
detail.className = 'source-overview-detail';
|
|
||||||
if (articles.length === 0) {
|
|
||||||
detail.innerHTML = '<div class="source-overview-detail-empty">Keine Artikel gefunden.</div>';
|
|
||||||
} else {
|
|
||||||
const fmtDate = (ts) => {
|
|
||||||
if (!ts) return '—';
|
|
||||||
try {
|
|
||||||
const d = new Date(ts);
|
|
||||||
if (isNaN(d.getTime())) return '—';
|
|
||||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: TIMEZONE })
|
|
||||||
+ ' '
|
|
||||||
+ d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
|
|
||||||
} catch (e) { return '—'; }
|
|
||||||
};
|
|
||||||
const items = articles.map(a => {
|
|
||||||
const nr = findNr(a);
|
|
||||||
const numHtml = nr != null
|
|
||||||
? `<span class="source-overview-detail-num">[${UI.escape(String(nr))}]</span>`
|
|
||||||
: `<span class="source-overview-detail-num source-overview-detail-num--none" title="Nicht im Lagebild zitiert">—</span>`;
|
|
||||||
const dateStr = fmtDate(getDate(a));
|
|
||||||
const headline = UI.escape(a.headline_de || a.headline || '(ohne Titel)');
|
|
||||||
const inner = a.source_url
|
|
||||||
? `<a href="${UI.escape(a.source_url)}" target="_blank" rel="noopener">${headline}</a>`
|
|
||||||
: headline;
|
|
||||||
return `<li>
|
|
||||||
${numHtml}
|
|
||||||
<span class="source-overview-detail-date">${UI.escape(dateStr)}</span>
|
|
||||||
<span class="source-overview-detail-headline">${inner}</span>
|
|
||||||
</li>`;
|
|
||||||
}).join('');
|
|
||||||
detail.innerHTML = `<ul class="source-overview-detail-list">${items}</ul>`;
|
|
||||||
}
|
|
||||||
el.insertAdjacentElement('afterend', detail);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Restliche Artikel seitenweise im Hintergrund nachladen und in _currentArticles mergen. */
|
/** Restliche Artikel seitenweise im Hintergrund nachladen und in _currentArticles mergen. */
|
||||||
async _loadRemainingArticlesInBackground(incidentId) {
|
async _loadRemainingArticlesInBackground(incidentId) {
|
||||||
const BATCH = 500;
|
const BATCH = 500;
|
||||||
@@ -1150,7 +1033,7 @@ const App = {
|
|||||||
}
|
}
|
||||||
this._timelineFilter = 'all';
|
this._timelineFilter = 'all';
|
||||||
this._timelineRange = 'all';
|
this._timelineRange = 'all';
|
||||||
this._activeStripWindow = null;
|
this._activePointIndex = null;
|
||||||
const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
|
const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
|
||||||
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
|
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
|
||||||
const isActive = btn.dataset.filter === 'all';
|
const isActive = btn.dataset.filter === 'all';
|
||||||
@@ -1226,9 +1109,6 @@ const App = {
|
|||||||
this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
|
this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter.
|
|
||||||
* Klick auf Heatmap-Balken: Stream filtert auf das Zeitfenster (aktive Balken hervorgehoben).
|
|
||||||
*/
|
|
||||||
rerenderTimeline() {
|
rerenderTimeline() {
|
||||||
const container = document.getElementById('timeline');
|
const container = document.getElementById('timeline');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -1239,216 +1119,271 @@ const App = {
|
|||||||
let entries = this._collectEntries(filterType, searchTerm, range);
|
let entries = this._collectEntries(filterType, searchTerm, range);
|
||||||
this._updateTimelineCount(entries);
|
this._updateTimelineCount(entries);
|
||||||
|
|
||||||
// Strip nutzt IMMER alle Eintraege im Range (unabhaengig von Filter/Search/Strip-Window)
|
|
||||||
const stripEntries = this._collectEntries('all', '', range);
|
|
||||||
stripEntries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
|
||||||
|
|
||||||
// Wenn ein Heatmap-Balken aktiv ist: Stream zusaetzlich auf dieses Zeitfenster filtern
|
|
||||||
const win = this._activeStripWindow;
|
|
||||||
if (win && entries.length > 0) {
|
|
||||||
entries = entries.filter(e => {
|
|
||||||
const ts = new Date(e.timestamp || 0).getTime();
|
|
||||||
return ts >= win.start && ts < win.end;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<div class="ht-tl">';
|
|
||||||
if (stripEntries.length > 0) {
|
|
||||||
html += this._renderTimelineStrip(stripEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Banner mit aktivem Filter
|
|
||||||
if (win) {
|
|
||||||
html += `<div class="ht-strip-banner">
|
|
||||||
<span class="ht-strip-banner-icon" aria-hidden="true">▼</span>
|
|
||||||
<span class="ht-strip-banner-text">Gefiltert auf <strong>${UI.escape(win.label)}</strong> · ${entries.length} Eintr${entries.length === 1 ? 'ag' : 'äge'}</span>
|
|
||||||
<button class="ht-strip-banner-close" onclick="App.clearStripWindow()" aria-label="Filter aufheben">Filter aufheben</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '<div class="ht-stream">';
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
html += win
|
this._activePointIndex = null;
|
||||||
? '<div class="ht-empty">Keine Einträge in diesem Zeitfenster.</div>'
|
container.innerHTML = (searchTerm || range !== 'all')
|
||||||
: (searchTerm || range !== 'all')
|
? '<div class="ht-empty">Keine Einträge im gewählten Zeitraum.</div>'
|
||||||
? '<div class="ht-empty">Keine Einträge im gewählten Zeitraum.</div>'
|
: '<div class="ht-empty">Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".</div>';
|
||||||
: '<div class="ht-empty">Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".</div>';
|
return;
|
||||||
} else {
|
|
||||||
html += this._renderVerticalStream(entries);
|
|
||||||
}
|
}
|
||||||
html += '</div>';
|
|
||||||
html += '</div>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Granularitaets-Heuristik fuer den Newsfeed: Stunden bei kurzen Spannen, sonst Tage. */
|
entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
||||||
_calcGranularity(entries) {
|
|
||||||
if (!entries || entries.length < 2) return 'day';
|
|
||||||
const ts = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
|
|
||||||
if (ts.length < 2) return 'day';
|
|
||||||
const span = Math.max(...ts) - Math.min(...ts);
|
|
||||||
if (span <= 48 * 60 * 60 * 1000) return 'hour';
|
|
||||||
return 'day';
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Vertikaler Stream: Datums-Trennzeilen + Lagebericht-Sektionen + Meldungen. */
|
const granularity = this._calcGranularity(entries, range);
|
||||||
_renderVerticalStream(entries) {
|
let buckets = this._buildBuckets(entries, granularity);
|
||||||
if (!entries || entries.length === 0) {
|
buckets = this._mergeCloseBuckets(buckets);
|
||||||
return '<div class="ht-empty">Keine Einträge.</div>';
|
|
||||||
|
// Aktiven Index validieren
|
||||||
|
if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) {
|
||||||
|
this._activePointIndex = null;
|
||||||
}
|
}
|
||||||
// Neueste oben
|
|
||||||
const sorted = [...entries].sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
|
|
||||||
const granularity = this._calcGranularity(sorted);
|
|
||||||
const groups = this._groupByTimePeriod(sorted, granularity);
|
|
||||||
|
|
||||||
let html = '<div class="vt-timeline">';
|
// Achsen-Bereich
|
||||||
groups.forEach(g => {
|
const rangeStart = buckets[0].timestamp;
|
||||||
const groupId = 'vt-grp-' + g.key.replace(/[^a-z0-9]/gi, '-');
|
const rangeEnd = buckets[buckets.length - 1].timestamp;
|
||||||
html += `<div class="vt-time-group" id="${groupId}" data-time-key="${UI.escape(g.key)}">`;
|
const maxCount = Math.max(...buckets.map(b => b.entries.length));
|
||||||
html += `<div class="vt-time-label"><span class="vt-time-label-text">${UI.escape(g.label)}</span></div>`;
|
|
||||||
html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
|
// Stunden- vs. Tages-Granularität
|
||||||
|
const isHourly = granularity === 'hour';
|
||||||
|
const axisLabels = this._buildAxisLabels(buckets, granularity, true);
|
||||||
|
|
||||||
|
// HTML aufbauen
|
||||||
|
let html = `<div class="ht-axis${isHourly ? ' ht-axis--hourly' : ''}">`;
|
||||||
|
|
||||||
|
// Datums-Marker (immer anzeigen, ausgedünnt)
|
||||||
|
const dayMarkers = this._thinLabels(this._buildDayMarkers(buckets, rangeStart, rangeEnd), 10);
|
||||||
|
html += '<div class="ht-day-markers">';
|
||||||
|
dayMarkers.forEach(m => {
|
||||||
|
html += `<div class="ht-day-marker" style="left:${m.pos}%;">`;
|
||||||
|
html += `<div class="ht-day-marker-label">${UI.escape(m.text)}</div>`;
|
||||||
|
html += `<div class="ht-day-marker-line"></div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
return html;
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ======= Quanti-Strip ======= */
|
// Punkte
|
||||||
_stripGranularity(stripEntries) {
|
html += '<div class="ht-points">';
|
||||||
if (stripEntries.length < 2) return 'day';
|
buckets.forEach((bucket, idx) => {
|
||||||
const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
|
const pos = this._bucketPositionPercent(bucket, rangeStart, rangeEnd, buckets.length);
|
||||||
if (ts.length < 2) return 'day';
|
const size = this._calcPointSize(bucket.entries.length, maxCount);
|
||||||
const span = Math.max(...ts) - Math.min(...ts);
|
const hasSnapshots = bucket.entries.some(e => e.kind === 'snapshot');
|
||||||
const DAY = 86400000;
|
const hasArticles = bucket.entries.some(e => e.kind === 'article');
|
||||||
if (span <= 2 * DAY) return 'hour';
|
|
||||||
if (span <= 60 * DAY) return 'day';
|
|
||||||
if (span <= 365 * DAY) return 'week';
|
|
||||||
return 'month';
|
|
||||||
},
|
|
||||||
|
|
||||||
_buildStripBuckets(stripEntries, granularity) {
|
let pointClass = 'ht-point';
|
||||||
if (stripEntries.length === 0) return [];
|
if (filterType === 'snapshots') {
|
||||||
const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
|
pointClass += ' ht-snapshot-point';
|
||||||
if (ts.length === 0) return [];
|
} else if (hasSnapshots) {
|
||||||
const minTs = Math.min(...ts);
|
pointClass += ' ht-mixed-point';
|
||||||
const maxTs = Math.max(...ts);
|
}
|
||||||
|
if (this._activePointIndex === idx) pointClass += ' active';
|
||||||
|
|
||||||
// Bucket-Start fuer minTs ermitteln
|
const tooltip = `${bucket.label}: ${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'}`;
|
||||||
const minDate = new Date(minTs);
|
|
||||||
const tzMin = _tz(minDate);
|
html += `<div class="${pointClass}" style="left:${pos}%;width:${size}px;height:${size}px;" onclick="App.openTimelineDetail(${idx})" data-idx="${idx}">`;
|
||||||
let firstStart;
|
html += `<div class="ht-tooltip">${UI.escape(tooltip)}</div>`;
|
||||||
let stepMs;
|
html += `</div>`;
|
||||||
if (granularity === 'hour') {
|
});
|
||||||
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date, tzMin.hours).getTime();
|
html += '</div>';
|
||||||
stepMs = 3600000;
|
|
||||||
} else if (granularity === 'day') {
|
// Achsenlinie
|
||||||
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date).getTime();
|
html += '<div class="ht-axis-line"></div>';
|
||||||
stepMs = 86400000;
|
|
||||||
} else if (granularity === 'week') {
|
// Achsen-Labels (ausgedünnt um Überlappung zu vermeiden)
|
||||||
const dow = (minDate.getDay() + 6) % 7; // 0=Mo
|
const thinned = this._thinLabels(axisLabels);
|
||||||
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date - dow).getTime();
|
html += '<div class="ht-axis-labels">';
|
||||||
stepMs = 7 * 86400000;
|
thinned.forEach(lbl => {
|
||||||
} else {
|
html += `<div class="ht-axis-label" style="left:${lbl.pos}%;">${UI.escape(lbl.text)}</div>`;
|
||||||
firstStart = new Date(tzMin.year, tzMin.month, 1).getTime();
|
});
|
||||||
stepMs = null; // dynamisch (Monatsgrenzen)
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Detail-Panel (wenn ein Punkt aktiv ist)
|
||||||
|
if (this._activePointIndex !== null && this._activePointIndex < buckets.length) {
|
||||||
|
html += this._renderDetailPanel(buckets[this._activePointIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buckets = [];
|
container.innerHTML = html;
|
||||||
const fmt = (t) => {
|
},
|
||||||
const d = new Date(t);
|
|
||||||
if (granularity === 'hour') return d.toLocaleString('de-DE', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
|
_calcGranularity(entries, range) {
|
||||||
if (granularity === 'day') return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
if (entries.length < 2) return 'day';
|
||||||
if (granularity === 'week') return 'Woche ab ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
const timestamps = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
|
||||||
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric', timeZone: TIMEZONE });
|
if (timestamps.length < 2) return 'day';
|
||||||
|
const span = Math.max(...timestamps) - Math.min(...timestamps);
|
||||||
|
if (range === '24h' || span <= 48 * 60 * 60 * 1000) return 'hour';
|
||||||
|
return 'day';
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildBuckets(entries, granularity) {
|
||||||
|
const bucketMap = {};
|
||||||
|
entries.forEach(e => {
|
||||||
|
const d = new Date(e.timestamp || 0);
|
||||||
|
const b = _tz(d);
|
||||||
|
let key, label, ts;
|
||||||
|
if (granularity === 'hour') {
|
||||||
|
key = `${b.year}-${b.month + 1}-${b.date}-${b.hours}`;
|
||||||
|
label = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE }) + ', ' + b.hours.toString().padStart(2, '0') + ':00';
|
||||||
|
ts = new Date(b.year, b.month, b.date, b.hours).getTime();
|
||||||
|
} else {
|
||||||
|
key = `${b.year}-${b.month + 1}-${b.date}`;
|
||||||
|
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
||||||
|
ts = new Date(b.year, b.month, b.date, 12).getTime();
|
||||||
|
}
|
||||||
|
if (!bucketMap[key]) {
|
||||||
|
bucketMap[key] = { key, label, timestamp: ts, entries: [] };
|
||||||
|
}
|
||||||
|
bucketMap[key].entries.push(e);
|
||||||
|
});
|
||||||
|
return Object.values(bucketMap).sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
},
|
||||||
|
|
||||||
|
_mergeCloseBuckets(buckets) {
|
||||||
|
if (buckets.length < 2) return buckets;
|
||||||
|
const rangeStart = buckets[0].timestamp;
|
||||||
|
const rangeEnd = buckets[buckets.length - 1].timestamp;
|
||||||
|
if (rangeEnd <= rangeStart) return buckets;
|
||||||
|
|
||||||
|
const container = document.getElementById('timeline');
|
||||||
|
const axisWidth = (container ? container.offsetWidth : 800) * 0.92;
|
||||||
|
const maxCount = Math.max(...buckets.map(b => b.entries.length));
|
||||||
|
const result = [buckets[0]];
|
||||||
|
|
||||||
|
for (let i = 1; i < buckets.length; i++) {
|
||||||
|
const prev = result[result.length - 1];
|
||||||
|
const curr = buckets[i];
|
||||||
|
|
||||||
|
const distPx = ((curr.timestamp - prev.timestamp) / (rangeEnd - rangeStart)) * axisWidth;
|
||||||
|
const prevSize = Math.min(32, this._calcPointSize(prev.entries.length, maxCount));
|
||||||
|
const currSize = Math.min(32, this._calcPointSize(curr.entries.length, maxCount));
|
||||||
|
const minDistPx = (prevSize + currSize) / 2 + 6;
|
||||||
|
|
||||||
|
if (distPx < minDistPx) {
|
||||||
|
prev.entries = prev.entries.concat(curr.entries);
|
||||||
|
} else {
|
||||||
|
result.push(curr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
_bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) {
|
||||||
|
if (totalBuckets === 1) return 50;
|
||||||
|
if (rangeEnd === rangeStart) return 50;
|
||||||
|
return ((bucket.timestamp - rangeStart) / (rangeEnd - rangeStart)) * 100;
|
||||||
|
},
|
||||||
|
|
||||||
|
_calcPointSize(count, maxCount) {
|
||||||
|
if (maxCount <= 1) return 16;
|
||||||
|
const minSize = 12;
|
||||||
|
const maxSize = 32;
|
||||||
|
const logScale = Math.log(count + 1) / Math.log(maxCount + 1);
|
||||||
|
return Math.round(minSize + logScale * (maxSize - minSize));
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildAxisLabels(buckets, granularity, timeOnly) {
|
||||||
|
if (buckets.length === 0) return [];
|
||||||
|
const maxLabels = 8;
|
||||||
|
const labels = [];
|
||||||
|
const rangeStart = buckets[0].timestamp;
|
||||||
|
const rangeEnd = buckets[buckets.length - 1].timestamp;
|
||||||
|
|
||||||
|
const getLabelText = (b) => {
|
||||||
|
if (timeOnly) {
|
||||||
|
// Bei Tages-Granularität: Uhrzeit des ersten Eintrags nehmen
|
||||||
|
const ts = (granularity === 'day' && b.entries && b.entries.length > 0)
|
||||||
|
? new Date(b.entries[0].timestamp || b.timestamp)
|
||||||
|
: new Date(b.timestamp);
|
||||||
|
const tp = _tz(ts);
|
||||||
|
return tp.hours.toString().padStart(2, '0') + ':' + tp.minutes.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
return b.label;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (granularity === 'month') {
|
if (buckets.length <= maxLabels) {
|
||||||
let d = new Date(firstStart);
|
buckets.forEach(b => {
|
||||||
while (d.getTime() <= maxTs && buckets.length < 240) {
|
labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
|
||||||
const start = d.getTime();
|
});
|
||||||
const next = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
|
|
||||||
buckets.push({ start, end: next, label: fmt(start), articles: 0, snapshots: 0 });
|
|
||||||
d = new Date(next);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
for (let t = firstStart; t <= maxTs && buckets.length < 240; t += stepMs) {
|
const step = (buckets.length - 1) / (maxLabels - 1);
|
||||||
buckets.push({ start: t, end: t + stepMs, label: fmt(t), articles: 0, snapshots: 0 });
|
for (let i = 0; i < maxLabels; i++) {
|
||||||
|
const idx = Math.round(i * step);
|
||||||
|
const b = buckets[idx];
|
||||||
|
labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return labels;
|
||||||
// Eintraege zaehlen
|
|
||||||
stripEntries.forEach(e => {
|
|
||||||
const ets = new Date(e.timestamp || 0).getTime();
|
|
||||||
// Linear-Suche, da Buckets sortiert; bei vielen Buckets ggf. Binary
|
|
||||||
for (let i = 0; i < buckets.length; i++) {
|
|
||||||
if (ets >= buckets[i].start && ets < buckets[i].end) {
|
|
||||||
if (e.kind === 'article') buckets[i].articles++;
|
|
||||||
else if (e.kind === 'snapshot') buckets[i].snapshots++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return buckets;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_renderTimelineStrip(stripEntries) {
|
_thinLabels(labels, minGapPercent) {
|
||||||
const granularity = this._stripGranularity(stripEntries);
|
if (!labels || labels.length <= 1) return labels;
|
||||||
const buckets = this._buildStripBuckets(stripEntries, granularity);
|
const gap = minGapPercent || 8;
|
||||||
if (buckets.length === 0) return '';
|
const result = [labels[0]];
|
||||||
|
for (let i = 1; i < labels.length; i++) {
|
||||||
const maxCount = Math.max(1, ...buckets.map(b => b.articles));
|
if (labels[i].pos - result[result.length - 1].pos >= gap) {
|
||||||
const win = this._activeStripWindow;
|
result.push(labels[i]);
|
||||||
|
|
||||||
let html = '<div class="ht-strip">';
|
|
||||||
html += '<div class="ht-strip-cells">';
|
|
||||||
buckets.forEach(b => {
|
|
||||||
const intensity = b.articles > 0 ? Math.min(1, b.articles / maxCount) : 0;
|
|
||||||
const cls = ['ht-strip-cell'];
|
|
||||||
if (b.snapshots > 0) cls.push('has-snapshot');
|
|
||||||
if (b.articles === 0 && b.snapshots === 0) cls.push('empty');
|
|
||||||
if (win && win.start === b.start && win.end === b.end) cls.push('active');
|
|
||||||
const tip = `${b.label}: ${b.articles} Meldung${b.articles === 1 ? '' : 'en'}` +
|
|
||||||
(b.snapshots > 0 ? ` + ${b.snapshots} Lagebericht${b.snapshots === 1 ? '' : 'e'}` : '');
|
|
||||||
// data-Attribute statt JSON-String im onclick-Inline (vermeidet Quote-Konflikte bei Labels mit Komma/Anführungszeichen)
|
|
||||||
html += `<div class="${cls.join(' ')}" style="--intensity:${intensity.toFixed(3)};" title="${UI.escape(tip)}" data-start="${b.start}" data-end="${b.end}" data-label="${UI.escape(b.label || '')}" onclick="App.handleStripClick(this)"></div>`;
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
// Wenige Datums-Labels unter dem Strip
|
|
||||||
const labelCount = Math.min(buckets.length, 6);
|
|
||||||
const stride = Math.max(1, Math.floor(buckets.length / labelCount));
|
|
||||||
const labelTexts = [];
|
|
||||||
for (let i = 0; i < buckets.length; i += stride) {
|
|
||||||
const b = buckets[i];
|
|
||||||
const d = new Date(b.start);
|
|
||||||
let txt;
|
|
||||||
if (granularity === 'hour') txt = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
|
|
||||||
else if (granularity === 'day') txt = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
|
||||||
else if (granularity === 'week') txt = 'KW ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
|
||||||
else txt = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit', timeZone: TIMEZONE });
|
|
||||||
labelTexts.push({ text: txt, idx: i });
|
|
||||||
}
|
|
||||||
if (labelTexts.length) {
|
|
||||||
html += '<div class="ht-strip-labels" style="grid-template-columns: repeat(' + buckets.length + ', 1fr);">';
|
|
||||||
const seen = new Set(labelTexts.map(l => l.idx));
|
|
||||||
for (let i = 0; i < buckets.length; i++) {
|
|
||||||
if (seen.has(i)) {
|
|
||||||
const t = labelTexts.find(l => l.idx === i).text;
|
|
||||||
html += `<div class="ht-strip-label">${UI.escape(t)}</div>`;
|
|
||||||
} else {
|
|
||||||
html += '<div></div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
html += '</div>';
|
|
||||||
}
|
}
|
||||||
html += '</div>';
|
return result;
|
||||||
return html;
|
},
|
||||||
|
|
||||||
|
_buildDayMarkers(buckets, rangeStart, rangeEnd) {
|
||||||
|
const seen = {};
|
||||||
|
const markers = [];
|
||||||
|
buckets.forEach(b => {
|
||||||
|
const d = new Date(b.timestamp);
|
||||||
|
const bp = _tz(d);
|
||||||
|
const dayKey = `${bp.year}-${bp.month}-${bp.date}`;
|
||||||
|
if (!seen[dayKey]) {
|
||||||
|
seen[dayKey] = true;
|
||||||
|
const np = _tz(new Date());
|
||||||
|
const todayKey = `${np.year}-${np.month}-${np.date}`;
|
||||||
|
const yp = _tz(new Date(Date.now() - 86400000));
|
||||||
|
const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`;
|
||||||
|
let label;
|
||||||
|
const dateStr = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
||||||
|
if (dayKey === todayKey) {
|
||||||
|
label = 'Heute, ' + dateStr;
|
||||||
|
} else if (dayKey === yesterdayKey) {
|
||||||
|
label = 'Gestern, ' + dateStr;
|
||||||
|
} else {
|
||||||
|
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
|
||||||
|
}
|
||||||
|
const pos = this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length);
|
||||||
|
markers.push({ text: label, pos });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return markers;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderDetailPanel(bucket) {
|
||||||
|
const type = this._currentIncidentType;
|
||||||
|
const sorted = [...bucket.entries].sort((a, b) => {
|
||||||
|
if (a.kind === 'snapshot' && b.kind !== 'snapshot') return -1;
|
||||||
|
if (a.kind !== 'snapshot' && b.kind === 'snapshot') return 1;
|
||||||
|
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
let entriesHtml = '';
|
||||||
|
sorted.forEach(e => {
|
||||||
|
if (e.kind === 'snapshot') {
|
||||||
|
entriesHtml += this._renderSnapshotEntry(e.data);
|
||||||
|
} else {
|
||||||
|
entriesHtml += this._renderArticleEntry(e.data, type, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<div class="ht-detail-panel">
|
||||||
|
<div class="ht-detail-header">
|
||||||
|
<span class="ht-detail-title">${UI.escape(bucket.label)} (${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'})</span>
|
||||||
|
<button class="ht-detail-close" onclick="App.closeTimelineDetail()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="ht-detail-content">${entriesHtml}</div>
|
||||||
|
</div>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
setTimelineFilter(filter) {
|
setTimelineFilter(filter) {
|
||||||
this._timelineFilter = filter;
|
this._timelineFilter = filter;
|
||||||
this._activeStripWindow = null;
|
this._activePointIndex = null;
|
||||||
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
|
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
|
||||||
const isActive = btn.dataset.filter === filter;
|
const isActive = btn.dataset.filter === filter;
|
||||||
btn.classList.toggle('active', isActive);
|
btn.classList.toggle('active', isActive);
|
||||||
@@ -1459,7 +1394,7 @@ const App = {
|
|||||||
|
|
||||||
setTimelineRange(range) {
|
setTimelineRange(range) {
|
||||||
this._timelineRange = range;
|
this._timelineRange = range;
|
||||||
this._activeStripWindow = null;
|
this._activePointIndex = null;
|
||||||
document.querySelectorAll('.ht-range-btn').forEach(btn => {
|
document.querySelectorAll('.ht-range-btn').forEach(btn => {
|
||||||
const isActive = btn.dataset.range === range;
|
const isActive = btn.dataset.range === range;
|
||||||
btn.classList.toggle('active', isActive);
|
btn.classList.toggle('active', isActive);
|
||||||
@@ -1468,34 +1403,20 @@ const App = {
|
|||||||
this.rerenderTimeline();
|
this.rerenderTimeline();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Robuster Click-Handler fuer Heatmap-Cells (vermeidet Quote-Konflikte). */
|
openTimelineDetail(bucketIndex) {
|
||||||
handleStripClick(el) {
|
if (this._activePointIndex === bucketIndex) {
|
||||||
if (!el) return;
|
this._activePointIndex = null;
|
||||||
const start = parseInt(el.dataset.start, 10);
|
|
||||||
const end = parseInt(el.dataset.end, 10);
|
|
||||||
const label = el.dataset.label || '';
|
|
||||||
if (!isNaN(start) && !isNaN(end)) {
|
|
||||||
this.openTimelineWindow(start, end, label);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Klick auf Heatmap-Balken: Stream auf dieses Zeitfenster filtern.
|
|
||||||
* Zweiter Klick auf denselben Balken hebt den Filter auf.
|
|
||||||
*/
|
|
||||||
openTimelineWindow(startMs, endMs, label) {
|
|
||||||
const win = this._activeStripWindow;
|
|
||||||
if (win && win.start === startMs && win.end === endMs) {
|
|
||||||
this._activeStripWindow = null;
|
|
||||||
} else {
|
} else {
|
||||||
this._activeStripWindow = { start: startMs, end: endMs, label: label || '' };
|
this._activePointIndex = bucketIndex;
|
||||||
}
|
}
|
||||||
this.rerenderTimeline();
|
this.rerenderTimeline();
|
||||||
|
this._resizeTimelineTile();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Strip-Filter aufheben (z.B. via Banner-Button). */
|
closeTimelineDetail() {
|
||||||
clearStripWindow() {
|
this._activePointIndex = null;
|
||||||
this._activeStripWindow = null;
|
|
||||||
this.rerenderTimeline();
|
this.rerenderTimeline();
|
||||||
|
this._resizeTimelineTile();
|
||||||
},
|
},
|
||||||
|
|
||||||
_resizeTimelineTile() {
|
_resizeTimelineTile() {
|
||||||
@@ -1930,11 +1851,6 @@ async handleRefresh() {
|
|||||||
this._updateRefreshButton(true);
|
this._updateRefreshButton(true);
|
||||||
// showProgress called via handleStatusUpdate
|
// showProgress called via handleStatusUpdate
|
||||||
const result = await API.refreshIncident(this.currentIncidentId);
|
const result = await API.refreshIncident(this.currentIncidentId);
|
||||||
// Pipeline auf "pending" setzen, damit alte gruene Haekchen nicht
|
|
||||||
// faelschlich "schon fertig" suggerieren waehrend die Lage in der Queue steht
|
|
||||||
if (typeof Pipeline !== 'undefined' && Pipeline.beginQueue) {
|
|
||||||
Pipeline.beginQueue(this.currentIncidentId);
|
|
||||||
}
|
|
||||||
if (result && result.status === 'skipped') {
|
if (result && result.status === 'skipped') {
|
||||||
UI.showToast('Aktualisierung ist in der Warteschlange und wird ausgefuehrt, sobald die aktuelle Recherche abgeschlossen ist.', 'info');
|
UI.showToast('Aktualisierung ist in der Warteschlange und wird ausgefuehrt, sobald die aktuelle Recherche abgeschlossen ist.', 'info');
|
||||||
} else {
|
} else {
|
||||||
@@ -2156,19 +2072,8 @@ async handleRefresh() {
|
|||||||
_updateRefreshButton(disabled) {
|
_updateRefreshButton(disabled) {
|
||||||
const btn = document.getElementById('refresh-btn');
|
const btn = document.getElementById('refresh-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
// Hard-Stop: Lese-Modus (Budget aufgebraucht / Lizenz abgelaufen) -> immer disabled
|
|
||||||
if (this.user && this.user.read_only) {
|
|
||||||
btn.disabled = true;
|
|
||||||
const reason = this.user.read_only_reason;
|
|
||||||
btn.textContent = reason === 'budget_exceeded' ? 'Budget aufgebraucht' : 'Nur Lesezugriff';
|
|
||||||
btn.title = reason === 'budget_exceeded'
|
|
||||||
? 'Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.'
|
|
||||||
: 'Lizenz erlaubt keinen Schreibzugriff';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
btn.disabled = disabled;
|
btn.disabled = disabled;
|
||||||
btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren';
|
btn.textContent = disabled ? 'Läuft...' : 'Aktualisieren';
|
||||||
btn.title = '';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleDelete() {
|
async handleDelete() {
|
||||||
|
|||||||
@@ -334,16 +334,16 @@ const UI = {
|
|||||||
// Blocking (no close) for first refresh
|
// Blocking (no close) for first refresh
|
||||||
if (state.isFirst) {
|
if (state.isFirst) {
|
||||||
overlay.classList.add('blocking');
|
overlay.classList.add('blocking');
|
||||||
// Apply blur to incident-view (Header + Tab-Panels gemeinsam).
|
// Apply blur to grid
|
||||||
const blurTarget = document.getElementById('incident-view');
|
const grid = document.querySelector('.tab-panels');
|
||||||
if (blurTarget) {
|
if (grid) {
|
||||||
blurTarget.classList.add('refresh-blurred');
|
grid.classList.add('blurred');
|
||||||
// Sicherheitsnetz: bei viel DOM-Reshuffle im selben Tick
|
// Sicherheitsnetz: bei viel DOM-Reshuffle im selben Tick
|
||||||
// (Display-Wechsel, renderSidebar, leere innerHTML) greift
|
// (Display-Wechsel, renderSidebar, leere innerHTML) greift
|
||||||
// CSS filter:blur erst beim naechsten Layout-Pass. Im
|
// CSS filter:blur erst beim naechsten Layout-Pass. Im
|
||||||
// naechsten Frame nochmal setzen — idempotent.
|
// naechsten Frame nochmal setzen — idempotent.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (state && state.isFirst) blurTarget.classList.add('refresh-blurred');
|
if (state && state.isFirst) grid.classList.add('blurred');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -354,22 +354,9 @@ const UI = {
|
|||||||
const minBtn = document.getElementById('progress-popup-minimize');
|
const minBtn = document.getElementById('progress-popup-minimize');
|
||||||
if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';
|
if (minBtn) minBtn.style.display = state.isFirst ? 'none' : '';
|
||||||
|
|
||||||
// Title - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft)
|
// Title
|
||||||
const titleEl = document.getElementById('progress-popup-title');
|
const titleEl = document.getElementById('progress-popup-title');
|
||||||
if (titleEl) {
|
if (titleEl) titleEl.textContent = state.isFirst ? 'Erste Recherche l\u00e4uft' : 'Aktualisierung l\u00e4uft';
|
||||||
let title;
|
|
||||||
if (status === 'queued') {
|
|
||||||
const pos = (state && state._queuePos) ? ' (#' + state._queuePos + ')' : '';
|
|
||||||
title = 'In Warteschlange' + pos;
|
|
||||||
} else if (status === 'cancelling') {
|
|
||||||
title = 'Wird abgebrochen\u2026';
|
|
||||||
} else if (state.isFirst) {
|
|
||||||
title = 'Erste Recherche l\u00e4uft';
|
|
||||||
} else {
|
|
||||||
title = 'Aktualisierung l\u00e4uft';
|
|
||||||
}
|
|
||||||
titleEl.textContent = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multi-pass info
|
// Multi-pass info
|
||||||
const passEl = document.getElementById('progress-popup-pass');
|
const passEl = document.getElementById('progress-popup-pass');
|
||||||
@@ -487,8 +474,8 @@ const UI = {
|
|||||||
|
|
||||||
if (incidentId === App.currentIncidentId) {
|
if (incidentId === App.currentIncidentId) {
|
||||||
// Remove blur
|
// Remove blur
|
||||||
const blurTarget = document.getElementById('incident-view');
|
const grid = document.querySelector('.tab-panels');
|
||||||
if (blurTarget) blurTarget.classList.remove('refresh-blurred');
|
if (grid) grid.classList.remove('blurred');
|
||||||
|
|
||||||
const overlay = document.getElementById('progress-overlay');
|
const overlay = document.getElementById('progress-overlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
@@ -581,8 +568,8 @@ const UI = {
|
|||||||
if (!incidentId) incidentId = App.currentIncidentId;
|
if (!incidentId) incidentId = App.currentIncidentId;
|
||||||
|
|
||||||
// Remove blur
|
// Remove blur
|
||||||
const blurTarget = document.getElementById('incident-view');
|
const grid = document.querySelector('.tab-panels');
|
||||||
if (blurTarget) blurTarget.classList.remove('refresh-blurred');
|
if (grid) grid.classList.remove('blurred');
|
||||||
|
|
||||||
if (incidentId === App.currentIncidentId) {
|
if (incidentId === App.currentIncidentId) {
|
||||||
const overlay = document.getElementById('progress-overlay');
|
const overlay = document.getElementById('progress-overlay');
|
||||||
@@ -984,9 +971,8 @@ const UI = {
|
|||||||
html += '<div class="source-overview-grid">';
|
html += '<div class="source-overview-grid">';
|
||||||
data.sources.forEach(s => {
|
data.sources.forEach(s => {
|
||||||
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
|
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
|
||||||
const sourceName = this.escape(s.source || 'Unbekannt');
|
html += `<div class="source-overview-item">
|
||||||
html += `<div class="source-overview-item" data-source="${sourceName}" tabindex="0" role="button" aria-expanded="false" onclick="App.toggleSourceOverviewDetail(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.toggleSourceOverviewDetail(this);}">
|
<span class="source-overview-name">${this.escape(s.source || 'Unbekannt')}</span>
|
||||||
<span class="source-overview-name">${sourceName}</span>
|
|
||||||
<span class="source-overview-lang">${langs}</span>
|
<span class="source-overview-lang">${langs}</span>
|
||||||
<span class="source-overview-count">${s.article_count}</span>
|
<span class="source-overview-count">${s.article_count}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -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', 'pipeline', 'quellen'],
|
TAB_ORDER: ['zusammenfassung', 'lagebild', 'timeline', 'karte', 'faktencheck', 'quellen'],
|
||||||
_currentIncidentId: null,
|
_currentIncidentId: null,
|
||||||
_initialized: false,
|
_initialized: false,
|
||||||
|
|
||||||
|
|||||||
@@ -1,592 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}
|
|
||||||
_snapshotState: null, // deep-copy von _stateByKey vor Refresh-Start (fuer Cancel-Restore)
|
|
||||||
_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));
|
|
||||||
// Erfolg: API-State neu laden (finaler Stand sichtbar)
|
|
||||||
WS.on('refresh_complete', (msg) => this._onRefreshDoneSuccess(msg));
|
|
||||||
// Cancel/Error: vor-Refresh-Snapshot zurueckspielen, damit Pipeline nicht im Mix-Zustand stehen bleibt
|
|
||||||
WS.on('refresh_cancelled', (msg) => this._onRefreshDoneCancel(msg));
|
|
||||||
WS.on('refresh_error', (msg) => this._onRefreshDoneError(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._snapshotState = null; // Snapshot ist immer lagen-spezifisch
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Edge-Case: Lage ist gerade in Queue (z.B. via Lagen-Wechsel beim
|
|
||||||
// Klick in der Sidebar). API liefert den LETZTEN gespeicherten Stand
|
|
||||||
// (alles done = gruen), aber tatsaechlich wartet ein neuer Refresh.
|
|
||||||
// -> beginQueue() selbst ausloesen, damit Icons grau zeigen.
|
|
||||||
try {
|
|
||||||
if (typeof App !== 'undefined' && App._refreshingIncidents
|
|
||||||
&& App._refreshingIncidents.has(incidentId)
|
|
||||||
&& typeof UI !== 'undefined' && UI._progressState
|
|
||||||
&& UI._progressState[incidentId]
|
|
||||||
&& UI._progressState[incidentId].step === 'queued') {
|
|
||||||
this.beginQueue(incidentId);
|
|
||||||
}
|
|
||||||
} catch (e) { /* tolerant */ }
|
|
||||||
} 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 der ERSTE Schritt (sources_review) auf "active" geht, beginnt ein neuer
|
|
||||||
// Refresh oder ein neuer Multi-Pass-Durchlauf — alle nachfolgenden Schritte auf
|
|
||||||
// "pending" (grau) zuruecksetzen, damit der User sieht: das ist neu und
|
|
||||||
// noch nicht durchlaufen. Sonst stehen sie als "done" vom letzten Mal da.
|
|
||||||
let didReset = false;
|
|
||||||
if (d.status === 'active' && this._definition && this._definition.length
|
|
||||||
&& key === this._definition[0].key) {
|
|
||||||
this._definition.forEach(s => {
|
|
||||||
if (s.key !== key && this._stateByKey[s.key]) {
|
|
||||||
this._stateByKey[s.key].status = 'pending';
|
|
||||||
didReset = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (didReset) {
|
|
||||||
// Beim Reset alle Bloecke neu zeichnen, nicht nur den aktuellen
|
|
||||||
this._render();
|
|
||||||
this._renderMini();
|
|
||||||
} else {
|
|
||||||
this._patchBlock(key);
|
|
||||||
this._patchMiniBlock(key);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wird vom Frontend gerufen, wenn ein Refresh angestossen wurde (queued).
|
|
||||||
* Macht einen Snapshot des aktuellen Pipeline-Stands (zur spaeteren Wiederherstellung
|
|
||||||
* bei Cancel/Error) und setzt dann alle Steps auf "pending" - damit der User sieht:
|
|
||||||
* "neuer Refresh laeuft an, alte gruene Haekchen sind nicht mehr aktuell".
|
|
||||||
*/
|
|
||||||
beginQueue(incidentId) {
|
|
||||||
if (this._incidentId !== incidentId) return; // andere Lage offen
|
|
||||||
if (!this._definition) return; // noch keine Pipeline-Definition geladen
|
|
||||||
// Aktuellen Stand sichern (deep-copy). Bei Mehrfach-Refresh ohne Cancel
|
|
||||||
// dazwischen wird der Snapshot bewusst ueberschrieben - er soll immer
|
|
||||||
// der "Stand kurz vor diesem Refresh" sein.
|
|
||||||
this._snapshotState = JSON.parse(JSON.stringify(this._stateByKey));
|
|
||||||
// Alle Steps auf pending setzen
|
|
||||||
this._definition.forEach(s => {
|
|
||||||
if (this._stateByKey[s.key]) {
|
|
||||||
this._stateByKey[s.key].status = 'pending';
|
|
||||||
} else {
|
|
||||||
this._stateByKey[s.key] = { status: 'pending', count_value: null, count_secondary: null, pass_number: 1 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this._render();
|
|
||||||
this._renderMini();
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Restauriert den letzten Snapshot. Rueckgabe: true bei Erfolg, false wenn keiner da war. */
|
|
||||||
_restoreSnapshot() {
|
|
||||||
if (!this._snapshotState) return false;
|
|
||||||
this._stateByKey = this._snapshotState;
|
|
||||||
this._snapshotState = null;
|
|
||||||
this._render();
|
|
||||||
this._renderMini();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
_onRefreshDoneSuccess(msg) {
|
|
||||||
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
|
|
||||||
this._snapshotState = null; // verworfen, neuer Stand wird vom API geladen
|
|
||||||
// Daten frisch nachladen, damit Header (Dauer) und finale Zahlen passen
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this._incidentId != null) this.bindToIncident(this._incidentId);
|
|
||||||
}, 600);
|
|
||||||
},
|
|
||||||
|
|
||||||
_onRefreshDoneCancel(msg) {
|
|
||||||
if (this._incidentId == null || (msg && msg.incident_id !== this._incidentId)) return;
|
|
||||||
if (!this._restoreSnapshot()) {
|
|
||||||
// Kein Snapshot vorhanden (z.B. Page-Reload mitten im Refresh) -> wie bisher API-Reload
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this._incidentId != null) this.bindToIncident(this._incidentId);
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_onRefreshDoneError(msg) {
|
|
||||||
// Wie Cancel: vorheriger Stand zurueck (nicht im Mix-Zustand stehenbleiben)
|
|
||||||
this._onRefreshDoneCancel(msg);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 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">×</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