Commits vergleichen

29 Commits

Autor SHA1 Nachricht Datum
Claude Code
00d7dd70fc fix(source_health): paywall-Strategie nicht ueber removepaywall fuer Feed-URL
removepaywall.com liefert HTML (Article-Renderer), nicht XML - der
Feed-Validity-Check schlug daher fehl mit "Kein gueltiger RSS/Atom-Feed".

Korrektur:
- paywall: Feed-URL direkt mit Browser-UA laden (kein URL-Rewrite).
- Bei paywall + 4xx: status=warning (erwartbar), Feed-Validity skippen.
- removepaywall.com bleibt im Researcher-Prompt fuer Article-Inhalte
  (das ist der korrekte Use-Case).
2026-05-09 05:02:19 +00:00
Claude Code
29c10e85cb fix: removepaywalls.com -> removepaywall.com (Singular ist die echte Domain)
User-Korrektur: die echte Service-Domain heisst removepaywall.com (Singular).
removepaywalls.com (Plural) liefert HTTP 403 - vermutlich nicht der gleiche
Service oder gar nicht mehr existent.

Betrifft:
- services/source_health.py: REMOVEPAYWALLS_PREFIX-Konstante (Phase 18)
- agents/researcher.py: Claude-Prompts fuer Paywall-Hinweise (zwei Stellen)

Verifiziert mit curl: removepaywall.com -> 200, removepaywalls.com -> 403.
2026-05-09 05:00:11 +00:00
Claude Code
03173eaa1a feat(source_health): fetch_strategy + Retry mit Googlebot/removepaywalls (Phase 18)
Pro Quelle ein Feld sources.fetch_strategy (default | googlebot | paywall | skip):
- default: normaler UA, Retry mit Googlebot bei 403/406/429.
- googlebot: direkt mit Googlebot-UA (fuer SEO-freundliche Sites).
- paywall: Anfrage via removepaywalls.com (fuer Spiegel+/SZ+/FT etc.).
- skip: Health-Check ueberspringen (bekannte unerreichbare Quellen wie Login-only).

Pre-Flagging in der Migration: FT/WSJ/NZZ/Handelsblatt/WiWo -> paywall,
Rheinische Post/Verfassungsschutz -> googlebot.

(Test mit den vier prominent fehlerhaften Quellen zeigt: FT/RP/Verfassungsschutz
sind besonders streng, gehen auch nicht ueber Googlebot/removepaywalls durch.
Fuer milder restriktive Quellen wirkt der Retry-Mechanismus.)
2026-05-09 04:56:07 +00:00
Claude Code
594b9cfa2c fix(source_health): URL-Schema vor httpx.get sicherstellen
Telegram-Quellen mit url=t.me/kanal (ohne https:// Prefix) liessen httpx
mit "ValueError: unknown url type" crashen. Fix: vor dem Request
https:// vorne anhaengen wenn kein Schema vorhanden ist.

Beobachtet auf Live: 110 Health-Errors, davon einige Telegram-Kanaele
mit "ValueError: unknown url type:" als Fehlermeldung.
2026-05-09 04:45:19 +00:00
Claude Code
087ec547f7 fix(source_health): tenant-faehig + History (Phase 2 in den Monitor ziehen)
Phase 2 hatte die Verbesserungen nur in der Verwaltung
(src/shared/services/source_health.py). Der Daily-Health-Check laeuft aber
im Monitor-Backend (Cron 04:00 UTC) und nutzte deshalb weiter den alten
Code - Folge:

- Tenant-Quellen wurden NIE gecheckt (0 Eintraege in source_health_checks
  fuer tenant_id IS NOT NULL).
- source_health_history blieb leer.

Diese Aenderung holt die Phase-2-Logik in den Monitor:
- services/source_health.py: Verwaltung-Version 1:1 uebernommen
  (tenant_id-Filter weg + History-Save vor DELETE + UA/Timeout aus config).
- config.py: HEALTH_CHECK_USER_AGENT + HEALTH_CHECK_TIMEOUT_S ergaenzt.

Manueller Test auf Staging-Monitor:
  283 Quellen geprueft, 253 Issues, 61 davon Tenant-Quellen.
  History 0 -> 458 Eintraege.

Damit ist die shared/-LOCKED-FILES-Markierung in der Verwaltung obsolet -
beide Repos haben jetzt den gleichen Code.
2026-05-09 04:43:02 +00:00
Claude Code
f1b55dd104 fix(incidents): international-Default auf False (Bug 3 Buckelwal-Diagnose)
Beim Anlegen einer neuen Lage ist der Schalter "Internationale Quellen einbeziehen"
ab jetzt standardmaessig DEAKTIVIERT.

Hintergrund: Bei lokalen DACH-Ereignissen (Tier-/Personenstoryen wie
"Buckelwal timmy") hat der "international=True"-Default zu schlechteren
Treffern gefuehrt, weil Claude in Deutsch UND Englisch suchte und die
englische Berichterstattung haeufig fehlt. Excluded-Sources- und
Boulevard-Filter haben das Problem zusaetzlich verschaerft.

Aenderungen:
- src/models.py IncidentCreate.international_sources: bool=True -> False
  (nur das Pydantic-Default beim Create-Endpoint - IncidentResponse/Incident
  bleiben True, weil das die DB-Werte bestehender Lagen reflektiert)
- src/static/dashboard.html: <input id="inc-international" checked> -> ohne checked
  (UI-Default ist jetzt unchecked, User muss bewusst aktivieren fuer
  internationale Lagen)
- Tooltip-Text ergaenzt: "Deaktiviert (Standard): ... empfohlen fuer DACH-Lagen."

Bestandslagen sind nicht betroffen - DB-Schema-Default INTEGER DEFAULT 1
bleibt unveraendert, fuer alle existierenden Lagen behaelt international
seinen aktuellen Wert.

Damit ist die Buckelwal-Diagnose komplett geloest:
- Bug 1 (rss_parser min_matches adaptiv) seit a08df3d auf main
- Bug 2 (Eigennamen-Pflicht-Keywords) seit e83f80d auf main
- Bug 3 (international-Default) jetzt auf develop, gleich Cherry-pick auf main
2026-05-09 04:20:58 +00:00
Claude Code
e83f80dbe9 fix(researcher): Lagentitel-Eigennamen als Pflicht-Keywords (Bug 2 Buckelwal-Diagnose)
KEYWORD_EXTRACTION_PROMPT explizit erweitert:
- Eigennamen/Tiernamen/Personennamen aus dem THEMA als ZWINGEND markiert.
- Hinweis dass DE und EN identisch sein duerfen (Eigennamen).
- Klar gesagt: bei spezifischen Begriffen (>=7 Zeichen) reicht 1 Treffer in
  RSS-Headlines (passt zu rss_parser.py adaptive Schwelle aus a08df3d).

Code-Post-Processing (researcher.py _extract_keywords):
- Nach dem Parser werden Lagentitel-Woerter (>=4 Zeichen, nicht in Stopwords)
  ggf. in die Keyword-Liste injiziert, falls Haiku sie weggelassen hat.
- Verhindert konkret den "Buckelwal timmy"-Bug: "timmy" fehlte in Haikus
  Liste, damit fielen Headlines mit nur "Buckelwal" durch das min_matches.

Hintergrund: Memory-Eintrag rss_match_und_keyword_bug.md, Bug 2 von 3.
Bug 1 (rss_parser min_matches adaptiv) ist seit Commit a08df3d auf Live.
Bug 3 (international=True default) bleibt offen, ist primaer UX-Frage.
2026-05-09 03:52:36 +00:00
Claude Code
d71daee581 Mojibake fix: source_suggester.py + source_health.py via ftfy
Beide Files hatten Doppel-Encoded UTF-8 in Docstrings, Kommentaren und
Prompt-Strings (z.B. "prüft" statt "prüft", "Vorschläge" statt
"Vorschläge"). ftfy hat das automatisch repariert.

Hauptauswirkungen:
- Logs sind jetzt mit echten Umlauten lesbar
- Claude/Haiku-Prompts in source_suggester.py (Quellen-Vorschlaege via KI)
  bekommen jetzt korrekte deutsche Umlaute - sollte bessere Antworten geben

Daneben hat ftfy line-endings normalisiert, daher der grosse Diff in
source_health.py - inhaltlich nur Mojibake-Reparatur.

Verifiziert mit:
  grep -cE "ä|ö|ü|ß|Ä|Ö|Ü" src/services/*.py
  -> 0 Treffer
2026-05-09 03:39:34 +00:00
1e9cca2555 Promote develop → main (2026-05-06 23:45 UTC) 2026-05-07 01:45:19 +02:00
Claude Code
f4c0c930b8 fix(orchestrator): aktive Pipeline-Schritte beim Cancel mitschliessen
Beim User-Cancel wurde nur refresh_log auf cancelled gesetzt, der zuletzt
aktive refresh_pipeline_steps-Eintrag blieb verwaist. Der
/api/incidents/<id>/pipeline-Endpoint liefert daraus dauerhaft
"Schritt X laeuft" an die UI, auch lange nach dem Cancel.

- pipeline_tracker.cancel_active_steps(): neuer Bulk-Helper, setzt alle
  noch active-Schritte eines refresh_log_id auf cancelled mit completed_at
- _mark_refresh_cancelled holt die refresh_log_id, macht das refresh_log-
  Update wie bisher und ruft danach cancel_active_steps auf

Reproduziert bei Lage 80 (Bjoern Hoecke), refresh_log 1273. Frontend-
CSS kennt status-cancelled nicht, faellt auf den neutralen Default-Style
zurueck (kein Spinner mehr, kein Haken, korrekt ent-hangen).
2026-05-06 23:40:39 +00:00
03ee30a83e Promote develop → main (2026-05-06 23:31 UTC) 2026-05-07 01:31:33 +02:00
Claude Code
f73c21235e feat(translator): Feature-Flag TRANSLATOR_ENABLED zum Abschalten (siehe main) 2026-05-03 20:43:40 +00:00
Claude Code
cbfb608471 feat(translator): Feature-Flag TRANSLATOR_ENABLED zum Abschalten
Ueber die ENV-Variable TRANSLATOR_ENABLED (default true) kann der
Translator-Agent komplett deaktiviert werden. Wenn false:
- translate_articles steigt mit return [] aus, ohne Claude-Calls
- Fremdsprachige Artikel bleiben unuebersetzt (headline_de/content_de NULL)

Hintergrund: Bei Lage 6 Irankonflikt sind 10.210 Artikel ohne DE-Uebersetzung
aufgelaufen. Pro Refresh werden 2042 Batches sequentiell gestreamt
(~25s/Batch -> 13.5h Gesamtdauer pro Refresh), was den Pipeline-Step
factcheck blockiert und die Queue lahmlegt. Bis das Performance-Thema
geloest ist (Parallelisierung, Relevanz-Filter, Hard-Cap), wird der
Agent live deaktiviert. Zustand spaeter ueber .env wieder aktivierbar.

Live-.env wurde mit TRANSLATOR_ENABLED=false ergaenzt.
2026-05-03 20:43:39 +00:00
Claude Code
9078489d0a fix(orchestrator): Auto-Refresh nicht direkt nach Cancel/Error neu einreihen
- main.py: Auto-Refresh-Filter beruecksichtigt jetzt auch cancelled und error
- orchestrator.py: Queue-Cancels schreiben jetzt einen cancelled-Eintrag ins
  refresh_log via _log_queued_cancellation

Wirkung: Nach Cancel oder Error startet die Lage erst beim naechsten
regulaeren Slot wieder. refresh_mode bleibt unveraendert.

(Identisch zu Commit auf main, develop nachgezogen.)
2026-05-03 19:30:04 +00:00
Claude Code
e517de7404 fix(orchestrator): Auto-Refresh nicht direkt nach Cancel/Error neu einreihen
Der Auto-Refresh-Scheduler hat seinen letzten relevanten refresh_log-Eintrag
bisher mit Filter status IN (completed, running) gesucht. Cancelled- und
Error-Laeufe wurden ignoriert, der davor liegende Completed wurde genommen.
Ergebnis: Direkt nach Cancel oder Error wurde der Slot als faellig gesehen
und nach 60 Sekunden wieder eingereiht (Endlos-Loop bei Iran-Konflikt heute,
4x error in Folge ohne Pause).

- main.py: Filter erweitert auf status IN (completed, running, cancelled, error)
- orchestrator.py: Queue-Cancels schreiben jetzt auch einen cancelled-Eintrag
  ins refresh_log via _log_queued_cancellation (vorher: stiller Discard,
  kein Fingerabdruck im Log -> Auto-Refresh erkannte den Cancel nie)

Wirkung: Nach Cancel oder Error startet die Lage erst beim naechsten
regulaeren Slot wieder. refresh_mode bleibt unveraendert.
2026-05-03 19:30:02 +00:00
07c3fed9c8 Promote develop → main (2026-05-03 15:21 UTC) 2026-05-03 17:21:40 +02:00
24d7500152 Release-Notes: Übersichtlichere Navigation in der Seitenleiste 2026-05-03 17:21:37 +02:00
Claude Code
f0fe35b279 Sidebar Feedback-Button: mail-Icon (Brief) statt message-square 2026-05-03 15:14:59 +00:00
Claude Code
fb6e9fff19 Sidebar: Quellen+Feedback-Buttons mit Lucide-Icons + kuerzerem Text
Quellen verwalten -> Quellen (mit database-Icon)
Feedback senden  -> Feedback (mit message-square-Icon)
Tooltip behaelt den vollen Text fuer Mouseover.
2026-05-03 15:14:05 +00:00
6a24d0b51d Promote develop → main (2026-05-03 14:30 UTC) 2026-05-03 16:30:36 +02:00
Claude Code
b1a0e97a34 Pipeline: bei Lagen-Wechsel auf bereits-queued Lage automatisch beginQueue
Wenn der User in der Sidebar auf eine Lage klickt, die schon in Queue
wartet, ruft bindToIncident() die API auf und kriegt den letzten
gespeicherten Pipeline-Stand (alles done = gruen). Das ist falsch fuer
queued-Status.

Fix: nach API-Load pruefen, ob die Lage in App._refreshingIncidents ist
UND in UI._progressState mit step=queued -> beginQueue() selbst ausloesen.
Damit zeigt die Pipeline grau, sobald man auf die queued-Lage wechselt.
2026-05-03 14:27:20 +00:00
Claude Code
77797f6027 Refresh-Modal: Titel je nach Status (queued/cancelling/laeuft)
Bisher hing der Titel nur an state.isFirst -> stand auch "Aktualisierung
laeuft" wenn die Lage tatsaechlich noch in der Queue wartete.

Jetzt:
- queued    -> "In Warteschlange" (mit Position #N falls vorhanden)
- cancelling -> "Wird abgebrochen…"
- isFirst   -> "Erste Recherche laeuft"
- sonst     -> "Aktualisierung laeuft"
2026-05-03 14:18:17 +00:00
Claude Code
dc51ecafe8 Pipeline-Snapshot: Mini-Pipeline auch zuruecksetzen
beginQueue() und _restoreSnapshot() haben bisher nur _render() aufgerufen,
aber NICHT _renderMini(). Daher blieben die kleinen Pipeline-Icons im
"Aktualisierung laeuft"-Modal gruen, obwohl die Lage in Queue war.
Fix: an beiden Stellen auch _renderMini() aufrufen.
2026-05-03 14:15:27 +00:00
Claude Code
31fa17465a Pipeline-Icons: Snapshot/Restore bei Queue + Cancel
Vorher:
- Lage refreshen -> Lage geht in Queue, aber Pipeline-Icons bleiben gruen
  mit Haekchen vom letzten Refresh (suggeriert faelschlich "alles fertig")
- Cancel/Error -> Pipeline bleibt im Mix-Zustand (teils active, teils pending)

Nachher:
- pipeline.beginQueue(id): macht Snapshot des aktuellen _stateByKey und
  setzt alle Steps auf pending. Ausgeloest aus app.js handleRefresh()
  und _restoreRefreshingState() (auch nach F5).
- _onRefreshDoneSuccess: Snapshot verwerfen + API-Reload (wie bisher).
- _onRefreshDoneCancel: Snapshot zurueckspielen -> vorheriger gruener
  Stand sichtbar.
- _onRefreshDoneError: gleiches Verhalten wie Cancel.
- bindToIncident: Snapshot mitloeschen (lagen-spezifisch).
- Bei zweitem Refresh ohne Cancel dazwischen wird Snapshot bewusst
  ueberschrieben.
2026-05-03 14:10:56 +00:00
eaffd70575 Promote develop → main (2026-05-03 13:47 UTC) 2026-05-03 15:47:34 +02:00
Claude Code
2a654cc882 AI-Disclaimer: Modell-Name (Claude/Anthropic) aus Text entfernt 2026-05-03 13:42:35 +00:00
Claude Code
6293cef91e Banner-Text + AI-Disclaimer-Modal + Translator-Robustheit
#28 Banner-Text bei Token-Budget aufgebraucht:
- middleware/license_check.py + static/js/app.js: Statt "Bitte Verwaltung
  kontaktieren" jetzt konkreter Upgrade-Pfad mit info@aegis-sight.de.

#29 AI-Hallucination-Disclaimer:
- Neue static/js/ai-disclaimer.js (analog zu update-system.js):
  IIFE-Modul, localStorage-versioniert (aegis_ai_disclaimer_seen=v1),
  inline-CSS mit Theme-Variablen, Modal mit Lucide-Info-Icon.
- Wird beim ersten Login einmalig gezeigt; ueber Header-User-Dropdown
  Eintrag "Ueber KI-Inhalte" jederzeit erneut oeffenbar.
- dashboard.html: Script-Tag + Dropdown-Button mit Lucide-SVG.
- style.css: kleiner Stil-Block fuer .header-dropdown-action.

Translator-Robustheit (Bonus):
- agents/translator.py: Parser akzeptiert jetzt auch von Claude wrapped
  Antworten ({{translations: [...]}}, {{items: [...]}}, einzelnes
  Object). Behebt Wrapper-Bug der gestern beim Backfill 75% der Calls
  fehlschlagen liess.
- Prompt deutlicher: "flaches JSON-Array, kein Wrapper".
2026-05-03 13:29:19 +00:00
46864c5457 Promote develop → main (2026-05-03 00:07 UTC) 2026-05-03 02:07:08 +02:00
Claude Code
a6f36be9c6 Translator-Agent: dedizierter Haiku-Pass fuer fehlende DE-Uebersetzungen
Bisher haben translations als Teil der Analyzer-JSON-Antwort gelebt
("translations": [...]). Bei vielen Artikeln pro Refresh hat das LLM die
Translations regelmaessig weggelassen (Output-Token-Druck), insbesondere
content_de (lange Texte werden zuerst gestrichen). Folge: viele englische
Artikel ohne deutsche Headline/Inhalt im Frontend.

Aenderungen:
- Neuer Agent src/agents/translator.py:
  * translate_articles_batch / translate_articles
  * Nutzt CLAUDE_MODEL_FAST (Haiku) - billig
  * Batch-Size 5 (mit Reserve gegen Output-Truncate)
  * Robustes JSON-Parsing: Markdown-Codefence, Truncate-Fallback,
    extrahiert auch unvollstaendige Antworten
  * Idempotent: Caller filtert auf fehlende headline_de/content_de
- analyzer.py: translations aus 4 Prompt-Templates entfernt (adhoc/research
  x analyze/enhance) und Fallback-Return-Dict bereinigt -> Analyzer-Output
  wird kompakter und zuverlaessiger
- orchestrator.py:
  * Alter Translation-INSERT-Block entfernt (analysis.translations wird
    nicht mehr genutzt)
  * Nach Analyse + db.commit + cancel-check neuer Translator-Call:
    SELECT WHERE language!=de AND (headline_de OR content_de fehlt),
    translate_articles, normalize_german_umlauts, COALESCE-UPDATE
  * Vor post_refresh_qc -> normalize_umlaut_articles greift auch frische
    Uebersetzungen
  * Failure-tolerant: Translator-Fehler bricht Refresh nicht ab

Backfill: migrations/migrate_translations_2026-05-03.py im Verwaltungs-Repo.
2026-05-03 00:04:59 +00:00
18 geänderte Dateien mit 1134 neuen und 348 gelöschten Zeilen

Datei anzeigen

@@ -1,4 +1,13 @@
[ [
{
"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", "version": "2026-04-30T23:12Z",
"date": "2026-04-30", "date": "2026-04-30",

Datei anzeigen

@@ -47,7 +47,6 @@ 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."""
@@ -102,7 +101,6 @@ 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."""
@@ -149,7 +147,6 @@ 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."""
@@ -201,7 +198,6 @@ 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."""
@@ -796,5 +792,5 @@ class AnalyzerAgent:
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
return {"summary": summary, "sources": sources, "key_facts": [], "translations": []} return {"summary": summary, "sources": sources, "key_facts": []}

Datei anzeigen

@@ -489,6 +489,9 @@ 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:
@@ -624,18 +627,56 @@ class AgentOrchestrator:
self._queue.task_done() self._queue.task_done()
async def _mark_refresh_cancelled(self, incident_id: int): async def _mark_refresh_cancelled(self, incident_id: int):
"""Markiert den laufenden Refresh-Log-Eintrag als cancelled.""" """Markiert den laufenden Refresh-Log-Eintrag als cancelled und schliesst
alle noch aktiven Pipeline-Schritte. Ohne den zweiten Schritt blieb der
zuletzt aktive Step-Eintrag verwaist und das Frontend zeigte dauerhaft
'Schritt X laeuft', weil /api/incidents/<id>/pipeline aus
refresh_pipeline_steps liest."""
from database import get_db from database import get_db
from services.pipeline_tracker import cancel_active_steps
db = await get_db() db = await get_db()
try: try:
now_str = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
cur = await db.execute(
"SELECT id FROM refresh_log WHERE incident_id = ? AND status = 'running'",
(incident_id,),
)
row = await cur.fetchone()
refresh_log_id = row["id"] if row else None
await db.execute( await db.execute(
"""UPDATE refresh_log SET status = 'cancelled', error_message = 'Vom Nutzer abgebrochen', """UPDATE refresh_log SET status = 'cancelled', error_message = 'Vom Nutzer abgebrochen',
completed_at = ? WHERE incident_id = ? AND status = 'running'""", completed_at = ? WHERE incident_id = ? AND status = 'running'""",
(datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), incident_id), (now_str, incident_id),
)
await db.commit()
if refresh_log_id is not None:
await cancel_active_steps(db, refresh_log_id=refresh_log_id)
except Exception as e:
logger.warning(f"Konnte Refresh-Log nicht als abgebrochen markieren: {e}")
finally:
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() await db.commit()
except Exception as e: except Exception as e:
logger.warning(f"Konnte Refresh-Log nicht als abgebrochen markieren: {e}") logger.warning(f"Konnte Queue-Cancel nicht in refresh_log loggen: {e}")
finally: finally:
await db.close() await db.close()
@@ -1410,30 +1451,64 @@ class AgentOrchestrator:
snap_articles, snap_fcs, log_id, now, tenant_id), snap_articles, snap_fcs, log_id, now, tenant_id),
) )
# Übersetzungen aktualisieren (nur für gültige DB-IDs) # Translations werden vom dedizierten Translator-Agent unten
# LLM-Drift abfangen: trotz Prompt-Anweisung kommen manchmal # erzeugt (frueher inline im Analyzer-Output, das war token-
# ASCII-Umlaute ("Gespraeche" statt "Gespräche") in der Übersetzung. # instabil und schaetzte regelmaessig content_de aus).
# Dictionary-basierte Korrektur schreibt nur deutsche Woerter um.
from services.post_refresh_qc import normalize_german_umlauts as _norm_de
for translation in analysis.get("translations", []):
article_id = translation.get("article_id")
if isinstance(article_id, int):
hd = translation.get("headline_de")
cd = translation.get("content_de")
if hd:
hd, _ = _norm_de(hd)
if cd:
cd, _ = _norm_de(cd)
await db.execute(
"UPDATE articles SET headline_de = ?, content_de = ? WHERE id = ? AND incident_id = ?",
(hd, cd, 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.

Datei anzeigen

@@ -77,7 +77,7 @@ REGELN:
{language_instruction} {language_instruction}
- Faktenbasiert und neutral - keine Spekulationen - Faktenbasiert und neutral - keine Spekulationen
- KRITISCH für source_url: Kopiere die EXAKTE URL aus den WebSearch-Ergebnissen. Erfinde oder konstruiere NIEMALS URLs aus Mustern oder Erinnerung. Wenn du die exakte URL eines Artikels nicht aus den Suchergebnissen hast, lass diesen Artikel komplett weg. - KRITISCH für source_url: Kopiere die EXAKTE URL aus den WebSearch-Ergebnissen. Erfinde oder konstruiere NIEMALS URLs aus Mustern oder Erinnerung. Wenn du die exakte URL eines Artikels nicht aus den Suchergebnissen hast, lass diesen Artikel komplett weg.
- Nutze removepaywalls.com für Paywall-geschützte Artikel (z.B. Spiegel+, Zeit+, SZ+): https://www.removepaywalls.com/search?url=ARTIKEL_URL - Nutze removepaywall.com für Paywall-geschützte Artikel (z.B. Spiegel+, Zeit+, SZ+): https://www.removepaywall.com/search?url=ARTIKEL_URL
- Nutze WebFetch um die 3-5 wichtigsten Artikel vollständig abzurufen und zusammenzufassen - Nutze WebFetch um die 3-5 wichtigsten Artikel vollständig abzurufen und zusammenzufassen
Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Erklärungen davor oder danach. Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Erklärungen davor oder danach.
@@ -124,7 +124,7 @@ Nutze spezifische Suchbegriffe für institutionelle Quellen. Ziel: 6-10 weitere
PHASE 4 — VERIFIKATION UND VERTIEFUNG: PHASE 4 — VERIFIKATION UND VERTIEFUNG:
Nutze WebFetch um die 6-10 wichtigsten Artikel vollständig abzurufen und ausführlich zusammenzufassen. Nutze WebFetch um die 6-10 wichtigsten Artikel vollständig abzurufen und ausführlich zusammenzufassen.
Priorisiere dabei Primärquellen und investigative Berichte. Priorisiere dabei Primärquellen und investigative Berichte.
Nutze removepaywalls.com für Paywall-geschützte Artikel (z.B. https://www.removepaywalls.com/search?url=ARTIKEL_URL) Nutze removepaywall.com für Paywall-geschützte Artikel (z.B. https://www.removepaywall.com/search?url=ARTIKEL_URL)
{language_instruction} {language_instruction}
@@ -199,14 +199,22 @@ AKTUELLE HEADLINES (die letzten Meldungen zu diesem Thema):
AUFGABE: AUFGABE:
Generiere 5 Begriffspaare (DE + EN), mit denen neue RSS-Artikel zu diesem Thema gefunden werden. Generiere 5 Begriffspaare (DE + EN), mit denen neue RSS-Artikel zu diesem Thema gefunden werden.
Ein Artikel gilt als relevant, wenn mindestens 2 dieser Begriffe im Titel oder der Beschreibung vorkommen. Ein Artikel gilt als relevant, wenn mindestens 2 dieser Begriffe im Titel oder der Beschreibung vorkommen
- bei spezifischen Begriffen (Eigennamen, lange Begriffe ab 7 Zeichen) reicht 1 Treffer.
REGELN: REGELN:
- Die ersten 2 Begriffspaare MUESSEN die zentralen Akteure/Laender/Themen sein (z.B. iran, israel, usa) — also die Begriffe, die in fast JEDEM Artikel zum Thema vorkommen - ZWINGEND: Eigennamen oder spezifische Begriffe aus dem THEMA (z.B. Personennamen, Tiernamen,
- Die letzten 3 Begriffspaare sind aktuelle Entwicklungen aus den Headlines (Orte, Akteure, Schluesselwoerter der aktuellen Phase) Ortsnamen wie "timmy", "buckelwal", "merz", "dobrindt") MUESSEN als eigene Begriffspaare
- Begriffe muessen so gewaehlt sein, dass sie in kurzen RSS-Titeln matchen (einzelne Woerter, keine Phrasen) enthalten sein. Solche Begriffe sind oft das einzige, was in kurzen Headlines vorkommt.
- Alle Begriffe in Kleinbuchstaben - Die ersten 2 Begriffspaare sind die zentralen Akteure/Laender/Themen (z.B. iran, israel,
- Exakt 5 Begriffspaare buckelwal, timmy) — also die Begriffe, die in fast JEDEM Artikel zum Thema vorkommen.
- Die uebrigen 3 Begriffspaare sind aktuelle Entwicklungen aus den Headlines (Orte, Akteure,
Schluesselwoerter der aktuellen Phase).
- Wenn DE und EN identisch sind (Eigennamen), trotzdem das Paar einreichen.
- Begriffe muessen so gewaehlt sein, dass sie in kurzen RSS-Titeln matchen (einzelne Woerter,
keine Phrasen, keine Konjunktionen).
- Alle Begriffe in Kleinbuchstaben.
- Exakt 5 Begriffspaare.
Antwort NUR als JSON-Array: 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"}}]"""
@@ -365,6 +373,17 @@ class ResearcherAgent:
if en and en != de: if en and en != de:
keywords.append(en) keywords.append(en)
# Bug-2-Fallback: Lagentitel-Wörter (>=4 Zeichen) zwingend in Keyword-Liste,
# falls Haiku sie weggelassen hat. Verhindert "Buckelwal timmy"-Bug, bei dem
# der Eigenname "timmy" fehlte und damit Headlines mit nur "Buckelwal" durchfielen.
STOPWORDS = {"der", "die", "das", "und", "oder", "von", "vom", "zum", "zur",
"the", "and", "for", "with", "ueber", "über", "von", "for"}
for word in (title or "").lower().split():
w = word.strip(".,;:!?\"\'()[]{}")
if len(w) >= 4 and w not in STOPWORDS and w not in keywords:
keywords.append(w)
logger.info(f"Lagentitel-Keyword '{w}' nachträglich injiziert")
if keywords: if keywords:
logger.info(f"Dynamische Keywords ({len(keywords)}): {keywords}") logger.info(f"Dynamische Keywords ({len(keywords)}): {keywords}")
return keywords if keywords else None, usage return keywords if keywords else None, usage

254
src/agents/translator.py Normale Datei
Datei anzeigen

@@ -0,0 +1,254 @@
"""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

Datei anzeigen

@@ -41,6 +41,10 @@ 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": [
@@ -91,3 +95,9 @@ TELEGRAM_API_ID = int(os.environ.get("TELEGRAM_API_ID", "0"))
TELEGRAM_API_HASH = os.environ.get("TELEGRAM_API_HASH", "") TELEGRAM_API_HASH = os.environ.get("TELEGRAM_API_HASH", "")
TELEGRAM_SESSION_PATH = os.environ.get("TELEGRAM_SESSION_PATH", "/home/claude-dev/.telegram/telegram_session") TELEGRAM_SESSION_PATH = os.environ.get("TELEGRAM_SESSION_PATH", "/home/claude-dev/.telegram/telegram_session")
# Health-Check (genutzt von services/source_health.py)
HEALTH_CHECK_USER_AGENT = os.environ.get(
"HEALTH_CHECK_USER_AGENT",
"Mozilla/5.0 (compatible; AegisSight-HealthCheck/1.0)",
)
HEALTH_CHECK_TIMEOUT_S = float(os.environ.get("HEALTH_CHECK_TIMEOUT_S", "15.0"))

Datei anzeigen

@@ -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') ORDER BY id DESC LIMIT 1", "SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running', 'cancelled', 'error') ORDER BY id DESC LIMIT 1",
(incident_id,), (incident_id,),
) )
last_refresh = await cursor.fetchone() last_refresh = await cursor.fetchone()

Datei anzeigen

@@ -47,7 +47,7 @@ async def require_writable_license(
if lic.get("read_only"): if lic.get("read_only"):
reason = lic.get("read_only_reason") or "expired" reason = lic.get("read_only_reason") or "expired"
if reason == "budget_exceeded": if reason == "budget_exceeded":
detail = "Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren." detail = "Token-Budget aufgebraucht. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren."
elif reason == "expired": elif reason == "expired":
detail = "Lizenz abgelaufen. Nur Lesezugriff moeglich." detail = "Lizenz abgelaufen. Nur Lesezugriff moeglich."
elif reason == "no_license": elif reason == "no_license":

Datei anzeigen

@@ -54,7 +54,7 @@ class IncidentCreate(BaseModel):
refresh_interval: int = Field(default=15, ge=10, le=10080) refresh_interval: int = Field(default=15, ge=10, le=10080)
refresh_start_time: Optional[str] = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") refresh_start_time: Optional[str] = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
retention_days: int = Field(default=0, ge=0, le=999) retention_days: int = Field(default=0, ge=0, le=999)
international_sources: bool = True international_sources: bool = False
include_telegram: bool = False include_telegram: bool = False
visibility: str = Field(default="public", pattern="^(public|private)$") visibility: str = Field(default="public", pattern="^(public|private)$")

Datei anzeigen

@@ -228,3 +228,25 @@ async def error_step(db, ws_manager, *, step_id: Optional[int], refresh_log_id:
"status": "error", "status": "error",
"pass_number": pass_number, "pass_number": pass_number,
}, visibility, created_by, tenant_id) }, visibility, created_by, tenant_id)
async def cancel_active_steps(db, *, refresh_log_id: int) -> int:
"""Schliesst alle noch aktiven Pipeline-Schritte eines Refreshs als 'cancelled' ab.
Wird vom Orchestrator nach einem User-Cancel aufgerufen. Ohne diesen Schritt
bleibt der zuletzt aktive Step-Eintrag verwaist und der Pipeline-Endpoint
liefert dauerhaft 'Schritt X laeuft' an die UI.
"""
try:
cur = await db.execute(
"""UPDATE refresh_pipeline_steps
SET status = 'cancelled', completed_at = ?
WHERE refresh_log_id = ? AND status = 'active'""",
(_now_db(), refresh_log_id),
)
await db.commit()
return cur.rowcount or 0
except Exception as e:
logger.warning(f"Pipeline cancel_active_steps DB-Fehler: {e}")
return 0

Datei anzeigen

@@ -1,282 +1,361 @@
"""Quellen-Health-Check Engine - prüft Erreichbarkeit, Feed-Validität, Duplikate.""" """Quellen-Health-Check Engine - prüft Erreichbarkeit, Feed-Validität, Duplikate."""
import asyncio import asyncio
import logging import logging
import json import json
from urllib.parse import urlparse import uuid
from urllib.parse import urlparse
import httpx
import feedparser import httpx
import aiosqlite import feedparser
import aiosqlite
logger = logging.getLogger("osint.source_health")
try:
from config import HEALTH_CHECK_USER_AGENT, HEALTH_CHECK_TIMEOUT_S
async def run_health_checks(db: aiosqlite.Connection) -> dict: except ImportError:
"""Führt alle Health-Checks für aktive Grundquellen durch.""" HEALTH_CHECK_USER_AGENT = "Mozilla/5.0 (compatible; AegisSight-HealthCheck/1.0)"
logger.info("Starte Quellen-Health-Check...") HEALTH_CHECK_TIMEOUT_S = 15.0
# Alle aktiven Grundquellen laden # Phase 18: alternative User-Agents fuer Bot-Block-Bypass
cursor = await db.execute( USER_AGENT_GOOGLEBOT = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
"SELECT id, name, url, domain, source_type, article_count, last_seen_at " USER_AGENT_BROWSER = (
"FROM sources WHERE status = 'active' AND tenant_id IS NULL" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
) "(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
sources = [dict(row) for row in await cursor.fetchall()] )
REMOVEPAYWALLS_PREFIX = "https://www.removepaywall.com/search?url="
# Aktuelle Health-Check-Ergebnisse löschen (werden neu geschrieben)
await db.execute("DELETE FROM source_health_checks") # HTTP-Codes, die einen Retry mit anderem UA rechtfertigen
await db.commit() RETRY_ON_STATUS = {403, 406, 429}
checks_done = 0 logger = logging.getLogger("osint.source_health")
issues_found = 0
# 1. Erreichbarkeit + Feed-Validität (nur Quellen mit URL) async def run_health_checks(db: aiosqlite.Connection) -> dict:
sources_with_url = [s for s in sources if s["url"]] """Führt Health-Checks für alle aktiven Quellen durch (global + Tenant)."""
logger.info("Starte Quellen-Health-Check...")
async with httpx.AsyncClient(
timeout=15.0, # Alle aktiven Quellen laden (global UND Tenant-spezifisch)
follow_redirects=True, cursor = await db.execute(
headers={"User-Agent": "Mozilla/5.0 (compatible; OSINT-Monitor/1.0)"}, "SELECT id, name, url, domain, source_type, article_count, last_seen_at, "
) as client: "COALESCE(fetch_strategy, 'default') AS fetch_strategy "
for i in range(0, len(sources_with_url), 5): "FROM sources WHERE status = 'active' "
batch = sources_with_url[i:i + 5] )
tasks = [_check_source_reachability(client, s) for s in batch] sources = [dict(row) for row in await cursor.fetchall()]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Bisherigen Stand in History archivieren, dann frisch starten
for source, result in zip(batch, results): run_id = uuid.uuid4().hex[:12]
if isinstance(result, Exception): await db.execute(
await _save_check( "INSERT INTO source_health_history "
db, source["id"], "reachability", "error", "(run_id, source_id, check_type, status, message, details, checked_at) "
f"Prüfung fehlgeschlagen: {result}", "SELECT ?, source_id, check_type, status, message, details, checked_at "
) "FROM source_health_checks",
issues_found += 1 (run_id,),
else: )
for check in result: await db.execute("DELETE FROM source_health_checks")
await _save_check( await db.commit()
db, source["id"], check["type"], check["status"], logger.info(f"Health-Check Run {run_id}: vorigen Stand archiviert")
check["message"], check.get("details"),
) checks_done = 0
if check["status"] != "ok": issues_found = 0
issues_found += 1
checks_done += 1 # 1. Erreichbarkeit + Feed-Validität (nur Quellen mit URL)
sources_with_url = [s for s in sources if s["url"]]
# 2. Veraltete Quellen (kein Artikel seit >30 Tagen)
for source in sources: async with httpx.AsyncClient(
if source["source_type"] in ("excluded", "web_source"): timeout=HEALTH_CHECK_TIMEOUT_S,
continue follow_redirects=True,
stale_check = _check_stale(source) headers={"User-Agent": HEALTH_CHECK_USER_AGENT},
if stale_check: ) as client:
await _save_check( for i in range(0, len(sources_with_url), 5):
db, source["id"], stale_check["type"], batch = sources_with_url[i:i + 5]
stale_check["status"], stale_check["message"], tasks = [_check_source_reachability(client, s) for s in batch]
) results = await asyncio.gather(*tasks, return_exceptions=True)
if stale_check["status"] != "ok":
issues_found += 1 for source, result in zip(batch, results):
if isinstance(result, Exception):
# 3. Duplikate erkennen await _save_check(
duplicates = _find_duplicates(sources) db, source["id"], "reachability", "error",
for dup in duplicates: f"Prüfung fehlgeschlagen: {result}",
await _save_check( )
db, dup["source_id"], "duplicate", "warning", issues_found += 1
dup["message"], json.dumps(dup.get("details", {})), else:
) for check in result:
issues_found += 1 await _save_check(
db, source["id"], check["type"], check["status"],
await db.commit() check["message"], check.get("details"),
logger.info( )
f"Health-Check abgeschlossen: {checks_done} Quellen geprüft, " if check["status"] != "ok":
f"{issues_found} Probleme gefunden" issues_found += 1
) checks_done += 1
return {"checked": checks_done, "issues": issues_found}
# 2. Veraltete Quellen (kein Artikel seit >30 Tagen)
for source in sources:
async def _check_source_reachability( if source["source_type"] in ("excluded", "web_source"):
client: httpx.AsyncClient, source: dict, continue
) -> list[dict]: stale_check = _check_stale(source)
"""Prüft Erreichbarkeit und Feed-Validität einer Quelle.""" if stale_check:
checks = [] await _save_check(
url = source["url"] db, source["id"], stale_check["type"],
stale_check["status"], stale_check["message"],
try: )
resp = await client.get(url) if stale_check["status"] != "ok":
issues_found += 1
if resp.status_code >= 400:
checks.append({ # 3. Duplikate erkennen
"type": "reachability", duplicates = _find_duplicates(sources)
"status": "error", for dup in duplicates:
"message": f"HTTP {resp.status_code} - nicht erreichbar", await _save_check(
"details": json.dumps({"status_code": resp.status_code, "url": url}), db, dup["source_id"], "duplicate", "warning",
}) dup["message"], json.dumps(dup.get("details", {})),
return checks )
issues_found += 1
if resp.status_code >= 300:
checks.append({ await db.commit()
"type": "reachability", logger.info(
"status": "warning", f"Health-Check abgeschlossen: {checks_done} Quellen geprüft, "
"message": f"HTTP {resp.status_code} - Weiterleitung", f"{issues_found} Probleme gefunden"
"details": json.dumps({ )
"status_code": resp.status_code, return {"checked": checks_done, "issues": issues_found}
"final_url": str(resp.url),
}),
}) async def _check_source_reachability(
else: client: httpx.AsyncClient, source: dict,
checks.append({ ) -> list[dict]:
"type": "reachability", """Prüft Erreichbarkeit und Feed-Validität einer Quelle.
"status": "ok",
"message": "Erreichbar", Phase 18: pro Quelle eine fetch_strategy ('default' | 'googlebot' | 'paywall' | 'skip').
}) Bei 'default' wird im Fehlerfall (403/406/429) ein Retry mit Googlebot-UA gemacht.
Bei 'paywall' wird auf removepaywall.com umgeleitet.
# Feed-Validität nur für RSS-Feeds Bei 'skip' wird kein Check ausgeführt.
if source["source_type"] == "rss_feed": """
text = resp.text[:20000] checks = []
if "<rss" not in text and "<feed" not in text and "<channel" not in text: url = source["url"]
checks.append({ strategy = source.get("fetch_strategy") or "default"
"type": "feed_validity",
"status": "error", # 'skip' -> kein Check (bekannte unerreichbare Quellen, z.B. Login-only)
"message": "Kein gültiger RSS/Atom-Feed", if strategy == "skip":
}) checks.append({
else: "type": "reachability", "status": "ok",
feed = await asyncio.to_thread(feedparser.parse, text) "message": "Health-Check uebersprungen (fetch_strategy=skip)",
if feed.get("bozo") and not feed.entries: })
checks.append({ return checks
"type": "feed_validity",
"status": "error", # URL-Schema sicherstellen
"message": "Feed fehlerhaft (bozo)", if url and not url.startswith(("http://", "https://")):
"details": json.dumps({ url = "https://" + url.lstrip("/")
"bozo_exception": str(feed.get("bozo_exception", "")),
}), # Initialen UA waehlen
}) initial_ua = HEALTH_CHECK_USER_AGENT
elif not feed.entries: initial_url = url
checks.append({ if strategy == "googlebot":
"type": "feed_validity", initial_ua = USER_AGENT_GOOGLEBOT
"status": "warning", elif strategy == "paywall":
"message": "Feed erreichbar aber leer", # Paywall-Quellen: Feed-URL direkt laden, aber mit Browser-UA (versucht Bot-Detection zu umgehen).
}) # removepaywall.com ist fuer Article-URLs, NICHT fuer RSS-Feed-Validity-Checks
else: # (gibt HTML statt XML zurueck). Researcher-Pipeline nutzt removepaywall fuer Inhalte.
checks.append({ initial_ua = USER_AGENT_BROWSER
"type": "feed_validity",
"status": "ok", try:
"message": f"Feed gültig ({len(feed.entries)} Einträge)", resp = await client.get(initial_url, headers={"User-Agent": initial_ua})
})
# Paywall-Quellen: 4xx ist erwartbar (Bot-Detection), als warning markieren statt error
except httpx.TimeoutException: if strategy == "paywall" and resp.status_code in RETRY_ON_STATUS:
checks.append({ checks.append({
"type": "reachability", "type": "reachability", "status": "warning",
"status": "error", "message": f"Paywall-Quelle, Direkt-Zugang HTTP {resp.status_code} (Researcher-Pipeline nutzt removepaywall.com fuer Inhalte)",
"message": "Timeout (15s)", })
}) return checks # Feed-Validity-Check skippen (Paywall liefert kein RSS)
except httpx.ConnectError as e:
checks.append({ # Bot-Block-Retry nur bei strategy='default'
"type": "reachability", if (
"status": "error", strategy == "default"
"message": f"Verbindung fehlgeschlagen: {e}", and resp.status_code in RETRY_ON_STATUS
}) ):
except Exception as e: retry = await client.get(url, headers={"User-Agent": USER_AGENT_GOOGLEBOT})
checks.append({ if retry.status_code < 400:
"type": "reachability", resp = retry # Retry hat geholfen
"status": "error", checks.append({
"message": f"{type(e).__name__}: {e}", "type": "reachability", "status": "warning",
}) "message": f"Erreichbar nur mit Googlebot-UA (Standard-UA bekam HTTP {initial_url and 'unknown' or 'XXX'})",
})
return checks
if resp.status_code >= 400:
checks.append({
def _check_stale(source: dict) -> dict | None: "type": "reachability",
"""Prüft ob eine Quelle veraltet ist (keine Artikel seit >30 Tagen).""" "status": "error",
if source["source_type"] == "excluded": "message": f"HTTP {resp.status_code} - nicht erreichbar",
return None "details": json.dumps({"status_code": resp.status_code, "url": url}),
})
article_count = source.get("article_count") or 0 return checks
last_seen = source.get("last_seen_at")
if resp.status_code >= 300:
if article_count == 0: checks.append({
return { "type": "reachability",
"type": "stale", "status": "warning",
"status": "warning", "message": f"HTTP {resp.status_code} - Weiterleitung",
"message": "Noch nie Artikel geliefert", "details": json.dumps({
} "status_code": resp.status_code,
"final_url": str(resp.url),
if last_seen: }),
try: })
from datetime import datetime else:
last_dt = datetime.fromisoformat(last_seen) checks.append({
now = datetime.now() "type": "reachability",
age_days = (now - last_dt).days "status": "ok",
if age_days > 30: "message": "Erreichbar",
return { })
"type": "stale",
"status": "warning", # Feed-Validität nur für RSS-Feeds
"message": f"Letzter Artikel vor {age_days} Tagen", if source["source_type"] == "rss_feed":
} text = resp.text[:20000]
except (ValueError, TypeError): if "<rss" not in text and "<feed" not in text and "<channel" not in text:
pass checks.append({
"type": "feed_validity",
return None "status": "error",
"message": "Kein gültiger RSS/Atom-Feed",
})
def _find_duplicates(sources: list[dict]) -> list[dict]: else:
"""Findet doppelte Quellen (gleiche URL).""" feed = await asyncio.to_thread(feedparser.parse, text)
duplicates = [] if feed.get("bozo") and not feed.entries:
url_map = {} checks.append({
"type": "feed_validity",
for s in sources: "status": "error",
if not s["url"]: "message": "Feed fehlerhaft (bozo)",
continue "details": json.dumps({
url_norm = s["url"].lower().rstrip("/") "bozo_exception": str(feed.get("bozo_exception", "")),
if url_norm in url_map: }),
existing = url_map[url_norm] })
duplicates.append({ elif not feed.entries:
"source_id": s["id"], checks.append({
"message": f"Doppelte URL wie '{existing['name']}' (ID {existing['id']})", "type": "feed_validity",
"details": {"duplicate_of": existing["id"], "type": "url"}, "status": "warning",
}) "message": "Feed erreichbar aber leer",
else: })
url_map[url_norm] = s else:
checks.append({
return duplicates "type": "feed_validity",
"status": "ok",
"message": f"Feed gültig ({len(feed.entries)} Einträge)",
async def _save_check( })
db: aiosqlite.Connection, source_id: int, check_type: str,
status: str, message: str, details: str = None, except httpx.TimeoutException:
): checks.append({
"""Speichert ein Health-Check-Ergebnis.""" "type": "reachability",
await db.execute( "status": "error",
"INSERT INTO source_health_checks " "message": "Timeout (15s)",
"(source_id, check_type, status, message, details) " })
"VALUES (?, ?, ?, ?, ?)", except httpx.ConnectError as e:
(source_id, check_type, status, message, details), checks.append({
) "type": "reachability",
"status": "error",
"message": f"Verbindung fehlgeschlagen: {e}",
async def get_health_summary(db: aiosqlite.Connection) -> dict: })
"""Gibt eine Zusammenfassung der letzten Health-Check-Ergebnisse zurück.""" except Exception as e:
cursor = await db.execute(""" checks.append({
SELECT "type": "reachability",
h.id, h.source_id, s.name, s.domain, s.url, s.source_type, "status": "error",
h.check_type, h.status, h.message, h.details, h.checked_at "message": f"{type(e).__name__}: {e}",
FROM source_health_checks h })
JOIN sources s ON s.id = h.source_id
ORDER BY return checks
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
s.name
""") def _check_stale(source: dict) -> dict | None:
checks = [dict(row) for row in await cursor.fetchall()] """Prüft ob eine Quelle veraltet ist (keine Artikel seit >30 Tagen)."""
if source["source_type"] == "excluded":
error_count = sum(1 for c in checks if c["status"] == "error") return None
warning_count = sum(1 for c in checks if c["status"] == "warning")
ok_count = sum(1 for c in checks if c["status"] == "ok") article_count = source.get("article_count") or 0
last_seen = source.get("last_seen_at")
cursor = await db.execute(
"SELECT MAX(checked_at) as last_check FROM source_health_checks" if article_count == 0:
) return {
row = await cursor.fetchone() "type": "stale",
last_check = row["last_check"] if row else None "status": "warning",
"message": "Noch nie Artikel geliefert",
return { }
"last_check": last_check,
"total_checks": len(checks), if last_seen:
"errors": error_count, try:
"warnings": warning_count, from datetime import datetime
"ok": ok_count, last_dt = datetime.fromisoformat(last_seen)
"checks": checks, now = datetime.now()
} age_days = (now - last_dt).days
if age_days > 30:
return {
"type": "stale",
"status": "warning",
"message": f"Letzter Artikel vor {age_days} Tagen",
}
except (ValueError, TypeError):
pass
return None
def _find_duplicates(sources: list[dict]) -> list[dict]:
"""Findet doppelte Quellen (gleiche URL)."""
duplicates = []
url_map = {}
for s in sources:
if not s["url"]:
continue
url_norm = s["url"].lower().rstrip("/")
if url_norm in url_map:
existing = url_map[url_norm]
duplicates.append({
"source_id": s["id"],
"message": f"Doppelte URL wie '{existing['name']}' (ID {existing['id']})",
"details": {"duplicate_of": existing["id"], "type": "url"},
})
else:
url_map[url_norm] = s
return duplicates
async def _save_check(
db: aiosqlite.Connection, source_id: int, check_type: str,
status: str, message: str, details: str = None,
):
"""Speichert ein Health-Check-Ergebnis."""
await db.execute(
"INSERT INTO source_health_checks "
"(source_id, check_type, status, message, details) "
"VALUES (?, ?, ?, ?, ?)",
(source_id, check_type, status, message, details),
)
async def get_health_summary(db: aiosqlite.Connection) -> dict:
"""Gibt eine Zusammenfassung der letzten Health-Check-Ergebnisse zurück."""
cursor = await db.execute("""
SELECT
h.id, h.source_id, s.name, s.domain, s.url, s.source_type,
h.check_type, h.status, h.message, h.details, h.checked_at
FROM source_health_checks h
JOIN sources s ON s.id = h.source_id
ORDER BY
CASE h.status WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
s.name
""")
checks = [dict(row) for row in await cursor.fetchall()]
error_count = sum(1 for c in checks if c["status"] == "error")
warning_count = sum(1 for c in checks if c["status"] == "warning")
ok_count = sum(1 for c in checks if c["status"] == "ok")
cursor = await db.execute(
"SELECT MAX(checked_at) as last_check FROM source_health_checks"
)
row = await cursor.fetchone()
last_check = row["last_check"] if row else None
return {
"last_check": last_check,
"total_checks": len(checks),
"errors": error_count,
"warnings": warning_count,
"ok": ok_count,
"checks": checks,
}

Datei anzeigen

@@ -1,4 +1,4 @@
"""KI-gestützte Quellen-Vorschläge via Haiku.""" """KI-gestützte Quellen-Vorschläge via Haiku."""
import json import json
import logging import logging
import re import re
@@ -12,8 +12,8 @@ logger = logging.getLogger("osint.source_suggester")
async def generate_suggestions(db: aiosqlite.Connection) -> int: async def generate_suggestions(db: aiosqlite.Connection) -> int:
"""Generiert Quellen-Vorschläge basierend auf Health-Checks und Lückenanalyse.""" """Generiert Quellen-Vorschläge basierend auf Health-Checks und Lückenanalyse."""
logger.info("Starte Quellen-Vorschläge via Haiku...") logger.info("Starte Quellen-Vorschläge via Haiku...")
# 1. Aktuelle Quellen laden # 1. Aktuelle Quellen laden
cursor = await db.execute( cursor = await db.execute(
@@ -33,13 +33,13 @@ async def generate_suggestions(db: aiosqlite.Connection) -> int:
""") """)
issues = [dict(row) for row in await cursor.fetchall()] issues = [dict(row) for row in await cursor.fetchall()]
# 3. Alte pending-Vorschläge entfernen (älter als 30 Tage) # 3. Alte pending-Vorschläge entfernen (älter als 30 Tage)
await db.execute( await db.execute(
"DELETE FROM source_suggestions " "DELETE FROM source_suggestions "
"WHERE status = 'pending' AND created_at < datetime('now', '-30 days')" "WHERE status = 'pending' AND created_at < datetime('now', '-30 days')"
) )
# 4. Quellen-Zusammenfassung für Haiku # 4. Quellen-Zusammenfassung für Haiku
categories = {} categories = {}
for s in sources: for s in sources:
cat = s["category"] cat = s["category"]
@@ -67,7 +67,7 @@ async def generate_suggestions(db: aiosqlite.Connection) -> int:
f"{issue['check_type']} = {issue['status']} - {issue['message']}\n" f"{issue['check_type']} = {issue['status']} - {issue['message']}\n"
) )
prompt = f"""Du bist ein OSINT-Analyst und verwaltest die Quellensammlung eines Lagebildmonitors für Sicherheitsbehörden. prompt = f"""Du bist ein OSINT-Analyst und verwaltest die Quellensammlung eines Lagebildmonitors für Sicherheitsbehörden.
Aktuelle Quellensammlung:{source_summary}{issues_summary} Aktuelle Quellensammlung:{source_summary}{issues_summary}
@@ -78,13 +78,13 @@ Beachte:
2. Fehlende wichtige OSINT-Quellen: Schlage "add_source" mit konkreter RSS-Feed-URL vor 2. Fehlende wichtige OSINT-Quellen: Schlage "add_source" mit konkreter RSS-Feed-URL vor
3. Fokus auf deutschsprachige + wichtige internationale Nachrichtenquellen 3. Fokus auf deutschsprachige + wichtige internationale Nachrichtenquellen
4. Nur Quellen vorschlagen, die NICHT bereits vorhanden sind 4. Nur Quellen vorschlagen, die NICHT bereits vorhanden sind
5. Maximal 5 Vorschläge 5. Maximal 5 Vorschläge
Antworte NUR mit einem JSON-Array. Jedes Element: Antworte NUR mit einem JSON-Array. Jedes Element:
{{ {{
"type": "add_source|deactivate_source|fix_url|remove_source", "type": "add_source|deactivate_source|fix_url|remove_source",
"title": "Kurzer Titel", "title": "Kurzer Titel",
"description": "Begründung", "description": "Begründung",
"priority": "low|medium|high", "priority": "low|medium|high",
"source_id": null, "source_id": null,
"data": {{ "data": {{
@@ -104,7 +104,7 @@ Nur das JSON-Array, kein anderer Text."""
json_match = re.search(r'\[.*\]', response, re.DOTALL) json_match = re.search(r'\[.*\]', response, re.DOTALL)
if not json_match: if not json_match:
logger.warning("Keine Vorschläge von Haiku erhalten (kein JSON)") logger.warning("Keine Vorschläge von Haiku erhalten (kein JSON)")
return 0 return 0
suggestions = json.loads(json_match.group(0)) suggestions = json.loads(json_match.group(0))
@@ -164,14 +164,14 @@ Nur das JSON-Array, kein anderer Text."""
await db.commit() await db.commit()
logger.info( logger.info(
f"Quellen-Vorschläge: {count} neue Vorschläge generiert " f"Quellen-Vorschläge: {count} neue Vorschläge generiert "
f"(Haiku: {usage.input_tokens} in / {usage.output_tokens} out / " f"(Haiku: {usage.input_tokens} in / {usage.output_tokens} out / "
f"${usage.cost_usd:.4f})" f"${usage.cost_usd:.4f})"
) )
return count return count
except Exception as e: except Exception as e:
logger.error(f"Fehler bei Quellen-Vorschlägen: {e}", exc_info=True) logger.error(f"Fehler bei Quellen-Vorschlägen: {e}", exc_info=True)
return 0 return 0
@@ -218,7 +218,7 @@ async def apply_suggestion(
(url,), (url,),
) )
if await cursor.fetchone(): if await cursor.fetchone():
result["action"] = "übersprungen (URL bereits vorhanden)" result["action"] = "übersprungen (URL bereits vorhanden)"
new_status = "rejected" new_status = "rejected"
else: else:
await db.execute( await db.execute(
@@ -230,7 +230,7 @@ async def apply_suggestion(
) )
result["action"] = f"Quelle '{name}' angelegt" result["action"] = f"Quelle '{name}' angelegt"
else: else:
result["action"] = "übersprungen (keine URL)" result["action"] = "übersprungen (keine URL)"
new_status = "rejected" new_status = "rejected"
elif stype == "deactivate_source": elif stype == "deactivate_source":
@@ -242,7 +242,7 @@ async def apply_suggestion(
) )
result["action"] = "Quelle deaktiviert" result["action"] = "Quelle deaktiviert"
else: else:
result["action"] = "übersprungen (keine source_id)" result["action"] = "übersprungen (keine source_id)"
elif stype == "remove_source": elif stype == "remove_source":
source_id = suggestion["source_id"] source_id = suggestion["source_id"]
@@ -250,9 +250,9 @@ async def apply_suggestion(
await db.execute( await db.execute(
"DELETE FROM sources WHERE id = ?", (source_id,), "DELETE FROM sources WHERE id = ?", (source_id,),
) )
result["action"] = "Quelle gelöscht" result["action"] = "Quelle gelöscht"
else: else:
result["action"] = "übersprungen (keine source_id)" result["action"] = "übersprungen (keine source_id)"
elif stype == "fix_url": elif stype == "fix_url":
source_id = suggestion["source_id"] source_id = suggestion["source_id"]
@@ -264,7 +264,7 @@ async def apply_suggestion(
) )
result["action"] = f"URL aktualisiert auf {new_url}" result["action"] = f"URL aktualisiert auf {new_url}"
else: else:
result["action"] = "übersprungen (keine source_id oder URL)" result["action"] = "übersprungen (keine source_id oder URL)"
await db.execute( await db.execute(
"UPDATE source_suggestions SET status = ?, reviewed_at = CURRENT_TIMESTAMP " "UPDATE source_suggestions SET status = ?, reviewed_at = CURRENT_TIMESTAMP "

Datei anzeigen

@@ -549,6 +549,31 @@ 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;

Datei anzeigen

@@ -72,6 +72,11 @@
<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>
@@ -118,8 +123,14 @@
<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()">Quellen verwalten</button> <button class="btn btn-secondary btn-full btn-small" onclick="App.openSourceManagement()" title="Quellen verwalten">
<button class="btn btn-secondary btn-full btn-small sidebar-feedback-btn" onclick="App.openFeedback()">Feedback senden</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>
<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>
--> -->
@@ -351,9 +362,9 @@
<label>Quellen</label> <label>Quellen</label>
<div class="toggle-group"> <div class="toggle-group">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" id="inc-international" checked> <input type="checkbox" id="inc-international">
<span class="toggle-switch"></span> <span class="toggle-switch"></span>
<span class="toggle-text">Internationale Quellen einbeziehen <span class="info-icon tooltip-below" data-tooltip="Aktiviert: Sucht auch in englischsprachigen und internationalen Medien.&#10;&#10;Deaktiviert: Nur deutschsprachige Quellen."><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"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></span> <span class="toggle-text">Internationale Quellen einbeziehen <span class="info-icon tooltip-below" data-tooltip="Aktiviert: Sucht auch in englischsprachigen und internationalen Medien.&#10;&#10;Deaktiviert (Standard): Nur deutschsprachige Quellen - empfohlen für DACH-Lagen."><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"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></span>
</label> </label>
</div> </div>
<div class="toggle-group" style="margin-top: 8px;"> <div class="toggle-group" style="margin-top: 8px;">
@@ -738,5 +749,6 @@
</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>

195
src/static/js/ai-disclaimer.js Normale Datei
Datei anzeigen

@@ -0,0 +1,195 @@
/**
* 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();
}
})();

Datei anzeigen

@@ -523,7 +523,7 @@ const App = {
let text = 'Nur Lesezugriff'; let text = 'Nur Lesezugriff';
const reason = user.read_only_reason; const reason = user.read_only_reason;
if (reason === 'budget_exceeded') { if (reason === 'budget_exceeded') {
text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.'; text = 'Token-Budget aufgebraucht – nur Lesezugriff. Für Aufstockung oder Upgrade bitte info@aegis-sight.de kontaktieren.';
} else if (reason === 'expired') { } else if (reason === 'expired') {
text = 'Lizenz abgelaufen – nur Lesezugriff'; text = 'Lizenz abgelaufen – nur Lesezugriff';
} else if (reason === 'no_license') { } else if (reason === 'no_license') {
@@ -618,6 +618,10 @@ 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);
}
}); });
} }
@@ -1926,6 +1930,11 @@ 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 {

Datei anzeigen

@@ -354,9 +354,22 @@ 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 // Title - haengt von Status ab (queued = wartet, cancelling = bricht ab, sonst laeuft)
const titleEl = document.getElementById('progress-popup-title'); const titleEl = document.getElementById('progress-popup-title');
if (titleEl) titleEl.textContent = state.isFirst ? 'Erste Recherche l\u00e4uft' : 'Aktualisierung l\u00e4uft'; if (titleEl) {
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');

Datei anzeigen

@@ -19,6 +19,7 @@ const Pipeline = {
_incidentId: null, _incidentId: null,
_definition: null, // PIPELINE_STEPS vom Backend _definition: null, // PIPELINE_STEPS vom Backend
_stateByKey: {}, // step_key -> {status, count_value, count_secondary, pass_number} _stateByKey: {}, // step_key -> {status, count_value, count_secondary, pass_number}
_snapshotState: null, // deep-copy von _stateByKey vor Refresh-Start (fuer Cancel-Restore)
_isResearch: false, _isResearch: false,
_passTotal: 1, _passTotal: 1,
_lastRefreshHeader: null, _lastRefreshHeader: null,
@@ -42,10 +43,11 @@ const Pipeline = {
if (this._wsBound) return; if (this._wsBound) return;
if (typeof WS !== 'undefined' && WS.on) { if (typeof WS !== 'undefined' && WS.on) {
WS.on('pipeline_step', (msg) => this._onWsStep(msg)); WS.on('pipeline_step', (msg) => this._onWsStep(msg));
// Bei Refresh-Complete den finalen Stand neu laden, damit Zahlen gefroren sichtbar bleiben // Erfolg: API-State neu laden (finaler Stand sichtbar)
WS.on('refresh_complete', (msg) => this._onRefreshDone(msg)); WS.on('refresh_complete', (msg) => this._onRefreshDoneSuccess(msg));
WS.on('refresh_cancelled', (msg) => this._onRefreshDone(msg)); // Cancel/Error: vor-Refresh-Snapshot zurueckspielen, damit Pipeline nicht im Mix-Zustand stehen bleibt
WS.on('refresh_error', (msg) => this._onRefreshDone(msg)); WS.on('refresh_cancelled', (msg) => this._onRefreshDoneCancel(msg));
WS.on('refresh_error', (msg) => this._onRefreshDoneError(msg));
this._wsBound = true; this._wsBound = true;
} }
// Hover-Tooltip-Element vorbereiten // Hover-Tooltip-Element vorbereiten
@@ -68,6 +70,7 @@ const Pipeline = {
async bindToIncident(incidentId) { async bindToIncident(incidentId) {
this._incidentId = incidentId; this._incidentId = incidentId;
this._stateByKey = {}; this._stateByKey = {};
this._snapshotState = null; // Snapshot ist immer lagen-spezifisch
this._isResearch = false; this._isResearch = false;
this._passTotal = 1; this._passTotal = 1;
this._lastRefreshHeader = null; this._lastRefreshHeader = null;
@@ -101,6 +104,20 @@ const Pipeline = {
this._render(); this._render();
this._renderMini(); 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) { } catch (e) {
console.warn('Pipeline laden fehlgeschlagen:', e); console.warn('Pipeline laden fehlgeschlagen:', e);
this._renderEmpty('Pipeline-Daten konnten nicht geladen werden.'); this._renderEmpty('Pipeline-Daten konnten nicht geladen werden.');
@@ -166,14 +183,65 @@ const Pipeline = {
} }
}, },
_onRefreshDone(msg) { /**
* 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; 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 // Daten frisch nachladen, damit Header (Dauer) und finale Zahlen passen
setTimeout(() => { setTimeout(() => {
if (this._incidentId != null) this.bindToIncident(this._incidentId); if (this._incidentId != null) this.bindToIncident(this._incidentId);
}, 600); }, 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. */ /** Vollbild-Pipeline (Tab "Analysepipeline") als 3x3-Snake rendern. */
_render() { _render() {
const stage = document.getElementById('pipeline-stage'); const stage = document.getElementById('pipeline-stage');