54 Commits

Autor SHA1 Nachricht Datum
Claude Code
672a8ef922 Orchestrator: eigener Worker pro Organisation (parallele Lanes)
Ersetzt die globale Warteschlange mit einem einzelnen Worker durch
Per-Tenant-Lanes: jede Organisation bekommt eine eigene Queue und einen
eigenen Worker-Task, die unabhaengig und parallel laufen. Lanes werden
lazy angelegt und bei Leerlauf (60s) wieder beendet.

- Skalarer Zustand (_current_task, _cancel_event) -> Maps pro incident_id
  (_current_tasks, _cancel_events); _queued_ids/_cancel_requested bleiben
  global (IDs sind lane-uebergreifend eindeutig).
- enqueue_refresh/cancel_refresh: Signatur unveraendert, routen intern per
  tenant_id in die passende Lane. Oeffentliche Lagen (tenant_id NULL) in
  fester PUBLIC_LANE.
- Optionales Ventil ORCHESTRATOR_MAX_PARALLEL (Default 0 = unbegrenzt)
  begrenzt bei Bedarf gleichzeitige Recherchen ueber alle Lanes.
- /refreshing-Endpoint auf _current_tasks-Map umgestellt, Response-Format
  unveraendert (pro Tenant laeuft weiterhin hoechstens eine Lage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:35:59 +00:00
claude-dev
3ae72e5d07 fix(email): SMTP-Versand mit 3 Versuchen und 20s-Timeout
Die IONOS-Farm (smtp.ionos.de, 10 Backends im DNS-Round-Robin) hatte am
10.07. teils tote/lahme Backends - ein einzelner Sendeversuch schlug dann
fehl (Timed out waiting for server ready message). Jeder Retry oeffnet
eine neue Verbindung und trifft per Round-Robin ein anderes Backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:37:19 +00:00
claude-dev
485861f09d Bericht-Export: "Neueste Entwicklungen" sauber formatieren
Live-Monitoring-Lagen (adhoc) zeigten im Export die Entwicklungen als
Bullet-Liste mit "[DD.MM. HH:MM] Text" und Quellen-Links. Jetzt:

- Abschnitt heisst "Neueste Entwicklungen" statt "Zusammenfassung"
  (Ueberschrift, Inhaltsverzeichnis). Title folgt dem Inhalt.
- Pro Eintrag eigene Datum/Uhrzeit-Zeile (TT.MM.JJ, HH:MM Uhr),
  darunter als eigener Absatz der Meldungstext.
- Keine Links: Quellen-Klammern {Name|URL} und [N]-Zitate werden entfernt.
- Gilt fuer PDF und DOCX. Research-Lagen und KI-Executive-Summary
  unveraendert.

Nebenbei: DOCX-Export crashte bei Beschreibungen ueber 255 Zeichen
(python-docx Core-Property "subject" Limit). Subject wird jetzt gekappt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 06:34:47 +00:00
Claude Code
66176f357e feat(fimi): FIMI-Abgleich als messbarer Pipeline-Schritt (pipeline_tracker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:21:19 +00:00
Claude Code
d57b410dd6 fix(fimi): Backfill-Runner respektiert DB_PATH-Env (Staging-Service-DB liegt ausserhalb des Repos)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:29:32 +00:00
Claude Code
ef2f638238 chore(fimi): Backfill-Runner fuer alle ungepruefte Artikel (gechunkt, robust)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:06:54 +00:00
Claude Code
8b84447ad4 feat(fimi): EUvsDisinfo-Pflicht-Disclaimer + Doku
Rechtslage: EUvsDisinfo (EEAS East StratCom Task Force), Datensatz CC BY-SA 4.0.
Pflichten: Attribution (erfuellt via Case-Links), keine Verfaelschung, Disclaimer
"keine offizielle EU-Position". Disclaimer dezent als graue Fusszeile der
FIMI-Qualitaetsleiste (UI.fimiDisclaimerHtml) und im Tooltip der Einzeltreffer.
CLAUDE.md um FIMI-Abschnitt inkl. Rechtslage ergaenzt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:55:35 +00:00
Claude Code
f32b8a8ec6 fix(fimi): Verifizierer-Prompt schaerfen gegen thematische False Positives
Embedding-Naehe verleitet das LLM, blosse Faktum-Erwaehnung als Verbreitung
zu werten (ukr. Generalstab berichtet ueber Ausbilder -> faelschlich als
Marionetten-Narrativ markiert). Prompt trennt jetzt strikt HALTUNG von
thematischer Naehe und nennt vier konkrete Negativbeispiele. Reduziert die
Treffer in Lage 35 von 21 auf 13 Artikel, FPs (Generalstab, Meduza,
OSINTdefender) entfernt, echte Verbreiter (Medvedev u.a.) bleiben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:51:14 +00:00
Claude Code
acac401034 feat(fimi): Frontend Andockpunkte 1-3 + Verifizierer-Robustheit
- Andockpunkt 1: dezenter Inline-Hinweis am Artikel (Quellen-Detailliste)
  mit Provenienz (EUvsDisinfo) + Case-Link, nur bei bestaetigtem Treffer.
- Andockpunkt 2: Track-Record-Badge pro Quelle in der Quellenuebersicht.
- Andockpunkt 3: Qualitaetsleiste ueber dem Lagebild (geprueft/Treffer/
  Narrative), aufklappbare Top-Narrative mit Belegen.
- fimi_matcher: URLs aus dem Artikeltext entfernen + Prompt-Praeambel gegen
  Tool-Nutzung, sonst scheiterte die Haiku-Verifikation an WebFetch-Versuchen
  (error_max_turns).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:43:11 +00:00
Claude Code
46b2acfc36 feat(fimi): Backend-Endpoints fimi-matches + fimi-summary, Match-Count in sources-summary
- GET /{id}/fimi-matches: Treffer gruppiert nach Artikel inkl. Provenienz
  (Claim, Widerlegung, Case-URL, Zitat) fuer Andockpunkt 1.
- GET /{id}/fimi-summary: Aggregat (geprueft, Treffer, Narrative, Quellen)
  fuer die Lagebild-Qualitaetsachse (Andockpunkt 3).
- sources-summary um fimi_match_count erweitert (Andockpunkt 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:32:41 +00:00
Claude Code
68f0792440 feat(fimi): Pipeline-Hook + match_article_ids
Nach dem Translator-Schritt werden die in einem Refresh neu hinzugekommenen
Artikel gegen den Falschbehauptungsbestand abgeglichen (nur neue Artikel,
nicht der ganze Bestand). Fehler brechen den Refresh nicht ab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:27:41 +00:00
Claude Code
1b3d6dbd57 feat(fimi): Fundament Counter-Disinformation-Match (Embedding + LLM-Verifikation)
Zweistufiger Abgleich von Monitor-Artikeln gegen den EUvsDisinfo-
Falschbehauptungsbestand, vollstaendig im Monitor (kein Vigil-Call):

- services/embeddings.py: SentenceTransformer-Singleton (paraphrase-
  multilingual-MiniLM-L12-v2), Modell-Cache mit Vigil geteilt.
- fimi_claims-Tabelle + scripts/import_fimi_claims.py: Einmal-/Sync-Import
  der 19.629 EUvsDisinfo-Claims inkl. Embedding-BLOB und Case-URL.
- services/fimi_matcher.py: Stufe 1 Embedding-Vorfilter (numpy-Matrix im RAM,
  Kosinus), Stufe 2 Haiku-Verifikation (verbreitet vs. berichtet/widerlegt),
  speichert nur bestaetigte Verbreitungen + woertliches Zitat.
- article_fimi_matches-Tabelle + fimi_checked_at-Marker auf articles.
- requirements.txt: torch, sentence-transformers, transformers, numpy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:23:14 +00:00
Claude Code
e20b3de0fa Lagebild: keine Stichwort-Fragmente und blanken Quellennummern-Dumps
Folgeregel zum Neu-am-Fix: verbietet Telegramm-Verkuerzungen
(Stichwort + [Nr]) und Auffangbloecke ohne Aussage (Fruehere Belege
[...]). Jede Quellennummer muss an einem vollstaendigen Satz haengen,
am Abschnitts- oder Lagebild-Ende keine reine Quellennummern-Liste.
2026-06-02 16:15:15 +00:00
Claude Code
d570e13dc6 Lagebild: keine datierten Neu-am-Verlaufsbloecke mehr
Der inkrementelle Analyse-Prompt liess das LLM neue Erkenntnisse als
datierte Changelog-Bloecke (Neu am DD.MM.) anhaengen, die nie eingefaltet
wurden. Beim Iran-Lagebild summierten sich so 151 solcher Bloecke. Punkt 3
fordert jetzt das Einarbeiten in den thematischen Abschnitt; zusaetzliche
STRUKTUR-Regel loest bestehende Neu-am-Bloecke auf. Die chronologische Sicht
bleibt der separaten Kachel Neueste Entwicklungen vorbehalten.
2026-06-02 15:52:59 +00:00
Claude Code
7777b77abd feat(pipeline): Translator als Pipeline-Step + Watchdog-Limits erhoehen
Folgefix zu 952df87. Der Translator-Block laeuft post-summary bei jp_demo
40+ Min und war bisher fuer das Frontend unsichtbar und fuer den Watchdog
ein blinder Fleck (kein Pipeline-Step-Eintrag).

Aenderungen:
- pipeline_tracker.py: neuer Step 'translate' zwischen 'summary' und 'qc'
  (DE+EN Label/Tooltip). Bewusst conditional sichtbar: erscheint nur, wenn
  fremdsprachige Artikel ohne DE-Uebersetzung vorliegen UND
  translator_enabled fuer die Org an ist.
- orchestrator.py: Translator-Block umrandet mit _pipe_start('translate')
  und _pipe_done('translate', count_value=uebersetzt, count_secondary=
  pending). Translator-Fehler schliesst Step trotzdem sauber ab.
  Bedingung 'pending_translations and translator_enabled' ersetzt das
  alte 'pending_translations' - skipped den Block sauber wenn Org-Override
  deaktiviert (war vorher redundant in translate_articles selbst).
- main.py: ORPHAN_IDLE_LIMIT 30->60 Min, ORPHAN_HARD_LIMIT 90->120 Min.
  Deckt jp_demo Translator-Phase (beobachtet bis 41 Min) mit Puffer ab,
  ohne echte Haenger durchzulassen.

Resultierend: Frontend zeigt den Uebersetzungs-Schritt mit Fortschritt
(uebersetzt/gesamt). Watchdog killt nicht mehr vorzeitig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:22:34 +00:00
Claude Code
952df87afa fix(watchdog): Refresh nicht killen wenn Pipeline noch Fortschritt zeigt
Der bisherige Watchdog markierte jeden running-Refresh nach 15 Min als
verwaist. Bei jp_demo-Lagen laeuft nach summary aber noch der Translator
(synchron, ~20 Min bei 200+ Artikeln), der den Refresh legitim ueber das
Limit traegt - er wurde dann faelschlich abgebrochen und der Orchestrator
hing in-memory weiter mit incident in _current_task.

Neuer Watchdog:
- ORPHAN_IDLE_LIMIT (30 Min): wird der Refresh nur als verwaist markiert,
  wenn seit dieser Zeit kein refresh_pipeline_steps-Eintrag Fortschritt
  zeigte (started_at oder completed_at)
- ORPHAN_HARD_LIMIT (90 Min): absolute Obergrenze gegen echte Haenger
- Wenn ueberhaupt keine Pipeline-Steps existieren -> als verwaist markieren

Folge: Long-Running-Refreshes (Translator-Block) laufen sauber durch,
nur echte Haenger werden bereinigt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:05:10 +00:00
7f7b30c1d6 Release-Notes: Exportdialog: Ersteller manuell eintragbar 2026-05-22 21:10:28 +02:00
Claude Code
d986d611cf feat(export): Ersteller im Export-Dialog manuell eingebbar
Der Export-Dialog hat ein neues optionales Feld "Ersteller". Ist es
gefuellt, wird dieser Name im Bericht als Ersteller verwendet; bleibt es
leer, gilt wie bisher die E-Mail des Lage-Erstellers.

- export_incident: optionaler Query-Parameter creator, hat Vorrang vor
  der E-Mail-Ableitung
- exportReport (api.js) haengt creator an die Export-URL
- submitExport (app.js) liest das neue Feld aus
- Eingabefeld im Export-Modal (dashboard.html)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:08:26 +00:00
7954a78964 Promote develop → main (2026-05-22 18:55 UTC) 2026-05-22 20:55:44 +02:00
Claude (claude-dev)
453c505a7e fix(export): Cache-Buster fuer app.js/api.js erhoehen
Die Branding-Auswahl im Export blieb wirkungslos, weil der
Browser die alten gecachten app.js/api.js weiterverwendete.
Versions-Query der beiden Skripte angehoben.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:52:38 +00:00
0b335263c9 Promote develop → main (2026-05-22 18:49 UTC) 2026-05-22 20:49:21 +02:00
Claude (claude-dev)
279df0f56b feat(export): neutrale Export-Variante ohne Firmenbranding
Beim Bericht-Export lässt sich im Modal nun zwischen "Mit
AegisSight-Branding" und "Ohne Firmen-Branding" wählen. Im
neutralen Modus entfallen Logo, AegisSight-Zeile auf dem
Deckblatt und Branding-Footer; die Datei-Metadaten werden
neutralisiert. Das Deckblatt mit Titel, Stand und Ersteller
bleibt erhalten. Betrifft PDF und DOCX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:39:21 +00:00
889044cc3b Promote develop → main (2026-05-22 18:16 UTC) 2026-05-22 20:16:59 +02:00
Claude Code
0c34f67194 fix(sources): X-Quellen im Monitor speicherbar machen
SOURCE_TYPE_PATTERN kannte kein x_account und SOURCE_CATEGORY_PATTERN
kein x. Dadurch schlug das Speichern einer X-Quelle ueber die Monitor-
Oberflaeche mit HTTP 422 fehl: bei neuen X-Quellen am source_type, beim
Bearbeiten bestehender X-Quellen an der Kategorie x. Beide Patterns
ergaenzt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:16:54 +00:00
64f9841240 Promote develop → main (2026-05-22 18:16 UTC) 2026-05-22 20:16:51 +02:00
Claude Code
1b8961ca12 fix(sources): Typ-Filter in der Fall-Quellenuebersicht immer anzeigen
Die Filter-Chips wurden nur eingeblendet, wenn ein Fall Telegram- oder
X-Quellen hatte. Bei reinen Web-Faellen (z.B. in der Org jp_demo) fehlte
die Filterleiste damit komplett. Sie wird jetzt immer angezeigt, sobald
Quellen vorhanden sind, und zeigt zugleich, welche Quellentypen der Fall
enthaelt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:40:33 +00:00
773715a38e Promote develop → main (2026-05-22 13:45 UTC) 2026-05-22 15:45:51 +02:00
Claude Code
f69fa1b95e feat(sources): Quellenuebersicht der Lage nach Typ filterbar
Die Quellenuebersicht innerhalb einer Lage zeigt jetzt Filter-Chips
(Alle / Web / Telegram / X) und blendet die Quellen-Boxen nach
Quellentyp ein und aus. Die Chips erscheinen nur, wenn neben Web auch
Telegram- oder X-Quellen vorkommen.

- sources-summary-Endpoint liefert pro Quelle einen source_type,
  abgeleitet aus dem source-Praefix (X: / Telegram: / sonst Web)
- Filter-Chips und data-type in renderSourceOverviewFromSummary
- App.filterSourceOverview blendet die Boxen nach Typ
- Chip-Styles in style.css

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:37:44 +00:00
f1a395bb94 Promote develop → main (2026-05-22 13:32 UTC) 2026-05-22 15:32:31 +02:00
Claude Code
a0f4572a01 feat(sources): Quellenuebersicht nach Quellentyp filterbar
Der Typ-Filter im Quellen-Modal kennt jetzt auch podcast_feed, damit
alle Quellentypen (RSS, Web, Telegram, X, Podcast) filterbar sind.
Zusaetzlich zeigt jede Quelle ein korrektes Typ-Badge -- vorher zeigten
Telegram, X und Podcast faelschlich "Web".

- podcast_feed im sources-filter-type-Dropdown
- _sourceTypeLabel-Helfer, korrekte Typ-Badges im Gruppen-Header und in
  den Feed-Zeilen, x_account im Info-Tooltip-typeMap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:31:04 +00:00
9598063728 Promote develop → main (2026-05-22 09:37 UTC) 2026-05-22 11:37:35 +02:00
Claude Code
cc1f9af273 fix(x): twscrape auf GitHub-main pinnen (x-client-transaction-id-Fix)
twscrape 0.17.0 von PyPI scheitert am x-client-transaction-id-Generator
(IndexError, twscrape-Issue #248). Der main-Branch enthaelt den Fix.
Pin auf einen festen Commit fuer Reproduzierbarkeit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:54:05 +00:00
a61e45f752 Promote develop → main (2026-05-22 07:41 UTC) 2026-05-22 09:41:18 +02:00
3f45ae66df Release-Notes: X (Twitter) als neue Informationsquelle verfügbar 2026-05-22 09:41:15 +02:00
Claude Code
9c50439785 feat(x): X (Twitter) als Bezugsquelle pro Lage
X-Accounts werden analog zu Telegram als Quelle (source_type=x_account)
konfiguriert und pro Lage ueber include_x zugeschaltet. Der Scraper
(feeds/x_parser.py, twscrape) liest Account-Timelines, optional ueber
einen HTTP-Proxy mit Fallback auf direkten Abruf ueber die Server-IP.

- DB-Migration include_x, Pydantic-Modelle, incidents-Router
- Orchestrator-X-Pipeline plus Haiku-Account-Vorselektion
- sources-Router /x/validate, x_account-Typ in Stats und Frontend
- Lage-Einstellungen: X-Toggle neben international und Telegram
- twscrape als Abhaengigkeit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:52:19 +00:00
f1200743e6 Recency Frische-Suchfeed (#36) 2026-05-22 02:33:07 +02:00
86b12a156e feat(recency): Frische-Suchfeed (when:14d) + Aktualitaets-Score
Damit die Pipeline das aktuelle Bild einfaengt, nicht nur das relevanteste
(oft Monate alt). Bei der Test-Lage Qilin war der neueste Artikel 7 Wochen
alt, die Masse 6-7 Monate — weil Google-News-Volltextsuche nach Relevanz
rankt, nicht nach Datum.

- build_news_search_feeds: neuer Parameter recency_days. Wenn gesetzt, wird
  der Google-News-Operator "when:Nd" an die Query gehaengt — der Feed liefert
  nur Artikel der letzten N Tage. Eigene Domain-Gruppe '...-recent'.
- orchestrator._rss_pipeline: baut jetzt ZWEI Suchfeed-Saetze — einen
  Kontext-Feed (alle Zeiten) und einen Frische-Feed (when:14d). Beide laufen
  durch dieselbe Pipeline, Dedup entfernt Ueberschneidungen.
- rss_parser._fetch_feed: relevance_score bekommt einen Aktualitaets-Bonus
  (<=3d +0.35, <=14d +0.20, <=60d +0.05) bzw. -Malus (>180d -0.15, >365d
  -0.30). Damit ueberleben frische Artikel den Domain-Cap statt von alten
  verdraengt zu werden.

Nur adhoc-Pfad betroffen — research-Lagen ueberspringen die RSS-Pipeline
ohnehin und behalten ihre volle historische Tiefe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:32:55 +02:00
002584bdb1 Geo-Centroid + GNews-Eigennamen (#35) 2026-05-22 02:13:43 +02:00
309c97f40a fix(geo+recall): Länder-Centroid statt Hauptstadt + Eigennamen in GNews-Query
Zwei Fixes aus der jp_demo-Verifikation:

1. Geoparsing — Länder mit Centroid statt Hauptstadt
   Bisher bekam ein Land die Koordinaten seiner Hauptstadt. Damit landeten
   alle "Japan"-Marker exakt auf Tokyo (35.69, 139.69) und die Karte
   suggerierte faelschlich ein Ereignis in der Hauptstadt. Neue Tabelle
   _COUNTRY_CENTROIDS (37 Laender) verortet ein Land in seiner geografischen
   Mitte (Japan: 36.20, 138.25). Laender ohne Centroid-Eintrag fallen auf die
   Hauptstadt zurueck.

2. Recall — Eigennamen in den Google-News-Suchfeed erzwingen
   Beim ersten Refresh fehlt die Headlines-Historie, daher kamen die GNews-
   Such-Keywords aus der Feed-Selektion. Haiku legt Eigennamen (z.B. "Qilin")
   in die en-Liste, die ja-Liste hatte nur Allgemeinbegriffe — die ja-Query
   suchte ohne "Qilin". build_news_search_feeds stellt nicht-englischen
   Sprach-Queries jetzt die 2 wichtigsten en-Keywords voran (Eigennamen
   kommen auch in fremdsprachigen Artikeln lateinisch vor). Damit ist schon
   der erste Refresh spezifisch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:13:30 +02:00
51276af97a Publisher aus GNews source-Tag (#34) 2026-05-22 01:19:05 +02:00
4e9d9f92f1 fix(rss): echten Publisher aus Google-News <source>-Tag uebernehmen
Google-News-Feeds (Site-Search wie auch der neue Volltext-Suchfeed) buendeln
Artikel vieler echter Publisher unter einer Feed-URL. Bisher bekamen alle
Artikel den generischen Feed-Namen als 'source' — der Faktencheck zaehlte
damit 25 Artikel verschiedener Zeitungen als EINE Quelle, und die
Quellenuebersicht war unbrauchbar.

Fix: Bei news.google.com-Feeds wird der echte Publisher aus dem <source>-Tag
des Feed-Items uebernommen (feedparser: entry.source.title). Fallback: der
Publisher-Teil hinter dem letzten ' - ' im Google-News-Titel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:18:50 +02:00
14b98b59e0 Recall: GNews-Suchfeeds (#33) 2026-05-22 01:02:58 +02:00
0e4c78d50a feat(recall): dynamische Google-News-Volltext-Suchfeeds pro Lage
Recall-Problem: Die Pipeline durchsuchte nur ~28 feste site:-RSS-Feeds plus
Claude-WebSearch. Japanische Security-Vendor-Blogs, Fachportale und
Regionalmedien (Cybertrust, ITmedia, INTERNET Watch, Reuters Japan ...)
tauchten in keinem festen Feed auf. Bei der Test-Lage "Qilin Ransomware
Japan" fand die Pipeline 20 Kandidaten — eine generische Google-News-JP-
Suche zum selben Thema liefert 49.

Fix: researcher.build_news_search_feeds baut pro Refresh einen Google-News-
Volltext-Suchfeed je Sprache (news.google.com/rss/search?q=keywords&hl=..&gl=..).
Query = Top-4-Keywords der jeweiligen Sprache aus der Keyword-Extraktion.
Der Orchestrator haengt diese Feeds an die selektierten site:-Feeds an; sie
laufen durch dieselbe Pipeline (Keyword-Match, Pre-Topic-Translate,
Topic-Filter). Precision bleibt, Recall steigt.

- researcher.py: build_news_search_feeds + _GNEWS_LOCALE-Tabelle.
- orchestrator._rss_pipeline: Suchfeeds aus source_language_whitelist
  (jp_demo: ['ja']) bzw. output+research_language (normale Orgs) gebaut
  und an selected_feeds angehaengt.
- rss_parser._apply_domain_cap: Suchfeeds (domain 'google-news-search-<lang>')
  bekommen Cap 25 statt 10 — sie sind der Recall-Treiber, Topic-Filter
  uebernimmt die Precision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:02:47 +02:00
f7fc09c864 jp_demo Pipeline (#32) 2026-05-22 00:29:00 +02:00
16d1133442 feat(public-mood): Haiku-Moderationspass fuer Foren-Beitraege
Vor der Stimmungs-Zusammenfassung laeuft ein separater Haiku-Call, der pro
Forum-Beitrag entscheidet:
  - publishable: unveraendert uebernehmen
  - redact: thematisch wertvoll, aber PII/Beleidigungen — Haiku liefert eine
    bereinigte Kurzfassung
  - discard: Hassrede gegen Gruppen, NSFW, glaubhafte Drohungen, reines
    Trolling — entfernen

Damit liefert die jp_demo-Org keine ungefilterten 5ch/Hatena/Note-Posts
in die Lagen-Anzeige. Fail-open: Bei API-/Parse-Fehler wird die Original-
liste durchgereicht (Pipeline bricht nicht ab).

- analyzer.moderate_forum_articles: Batch (max 25/Call), JSON-Output, Logging
  pro Entscheidungs-Klasse.
- orchestrator: Moderation laeuft vor generate_public_mood, gefilterte Liste
  geht in die Stimmungs-Zusammenfassung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:28:30 +02:00
d65f0180d9 feat(public-mood): Stimmungs-Kachel aus Foren-Quellen
Eigene Pipeline-Stufe nach factcheck, vor summary, die Foren-Artikel
(media_type='forum') zu einer Themen-Zusammenfassung verarbeitet. Wird als
separate Dashboard-Kachel "Öffentliche Stimmung" angezeigt — getrennt von
Lagebild und Faktencheck, damit anonyme Forenposts nicht mit belegter
Faktenlage verwechselt werden.

- DB-Migration: incidents.public_mood (TEXT) + public_mood_updated_at (TS).
- pipeline_tracker: neuer Pipeline-Step "public_mood" (DE/EN-Labels).
- analyzer.generate_public_mood: Haiku-Call der Foren-Beitraege pro Quelle
  gruppiert und 3-6 thematische Bullets erzeugt, mit expliziter Quellen-
  Herkunft pro Bullet. Bei zu duennem Material gibt's keinen Output.
- orchestrator: neuer Schritt zwischen Factcheck und Summary. Laedt alle
  Foren-Artikel der Lage (via JOIN auf sources), uebergibt sie an den
  Stimmungs-Agent, speichert den Markdown-Text in incidents.public_mood.
- Topic-Filter (analyzer.filter_relevant_articles) markiert Foren-Quellen
  mit [FORUM]-Tag und bekommt im Prompt die Regel, Foren-Artikel weicher
  zu bewerten (Lage-Keyword im Titel reicht). Sie sollen in der Stimmungs-
  Kachel landen, nicht voreilig verworfen werden.
- IncidentResponse-Modell: public_mood/public_mood_updated_at ergaenzt.
- Frontend: neuer Tab "Öffentliche Stimmung" (nur sichtbar wenn Inhalt da),
  eigene Kachel mit Warn-Hinweis "keine Faktenlage". UI.renderPublicMood
  als einfacher Bullet-Renderer.
- dashboard.html Cache-Buster fuer components.js + app.js gebumpt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:20:17 +02:00
379d14518c feat(multitenancy): Sprach-Whitelist + Translator-Override + Forum-Quellenklasse
Vorbereitung fuer jp_demo-Organisation: drei separate Sprach-Settings statt
einer einzigen output_language.

org_settings.py:
- get_source_language_whitelist: Liste erlaubter Quellsprachen als JSON-Array
  (z.B. ["ja"] beschraenkt RSS/Telegram auf japanische Quellen).
- get_research_language: Sprache fuer WebSearch-Prompts (Default: output_language).
- get_translator_enabled: Pro-Org-Override des globalen TRANSLATOR_ENABLED-Flags.
- LANGUAGE_DISPLAY_NAMES um ja/zh/ko/ru/ar/fa/he/fr/es erweitert.

source_rules.py:
- get_feeds_with_metadata filtert nach source_language_whitelist, wenn gesetzt.
- Feeds ohne primary_language fallen bei aktiver Whitelist raus (gewollt).
- SELECT um media_type erweitert, damit es im Feed-Dict ankommt.

orchestrator.py:
- Laedt research_language, source_language_whitelist, translator_enabled aus
  den Org-Settings.
- Wenn Whitelist gesetzt: international_sources-Flag wird ignoriert.
- research_language_iso wird an researcher.search() weitergegeben.
- translate_articles bekommt enabled-Parameter aus Org-Setting.
- Geoparsing ueberspringt media_type='forum' Artikel.
- SELECT * FROM articles wird zu JOIN sources, damit media_type beim Reload
  am Article-Dict haengt.

researcher.py:
- search() akzeptiert research_language_iso. Asymmetrische Sprach-Auswahl
  (Recherche != Output) erzeugt eigene Prompt-Anweisung "primaer in Quell-
  sprache, englische Region-Outlets erlaubt".

translator.py:
- translate_articles akzeptiert enabled-Parameter. Ueberschreibt die globale
  TRANSLATOR_ENABLED-Konstante pro Aufruf.

factchecker.py:
- _format_articles_text filtert Artikel mit media_type='forum' aus. Anonyme
  Foren-Posts gelten nicht als Faktenbeleg.

rss_parser.py:
- _fetch_feed traegt media_type aus feed_config ins Article-Dict ein,
  damit downstream Pipeline-Schritte Foren-Quellen erkennen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:12:56 +02:00
7fe62df529 Promote develop → main (2026-05-21 19:21 UTC) 2026-05-21 21:21:44 +02:00
claude-dev
75038939b4 feat(topic-filter): jeden verworfenen Artikel einzeln loggen + Pre-Topic-Content auf 500 Zeichen erhöhen
Beim Aktualisieren von Lage 96 (Verfassungsänderung Japan) ist der Topic-Filter
in den letzten Refreshes auf 2/15, 4/26 bzw. 7/23 zurückgefallen. Die jp-RSS-
Treffer aus Asahi-Politik, NHK-Politik und Mainichi werden offenbar verworfen,
aber ohne Detail-Log lässt sich nicht beurteilen, ob das gerechtfertigt ist.

- analyzer.filter_relevant_articles: pro verworfenem Artikel eine INFO-Zeile
  mit laufendem Index, Quelle, Original-Headline und (falls vorhanden) der
  englischen Pre-Topic-Übersetzung. Ohne zusätzlichen Claude-Call, nur Logging
  des bereits vorhandenen Materials.
- translator._TOPIC_TRANSLATE_CONTENT_MAX von 240 auf 500 erhöht. Bei dichten
  Kanji- oder kyrillischen Headlines reichten 240 Zeichen oft nicht aus, um
  dem nachgelagerten Topic-Filter den thematischen Kontext zu vermitteln.
  Mehrkosten pro Refresh: vernachlässigbar (Haiku, einmal pro Refresh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:03:36 +00:00
23a709f3d5 Promote develop → main (2026-05-21 17:10 UTC) 2026-05-21 19:10:03 +02:00
3196424ec9 Release-Notes: Sprachunterstützung für Artikel-Überschriften verbessert 2026-05-21 19:10:01 +02:00
claude-dev
a41c8ae529 feat(articles): headline_en persistieren + Sprache aus Quell-Konfig übernehmen
Zwei Lücken beim Befund Lage 96 (Verfassungsänderung Japan): die japanische
Asahi-Shimbun-Quelle wurde durch das Sprach-aware Keyword-Matching (#27) und
Pre-Topic-Translate (#28) erstmals durchgereicht, landete aber mit
language='en' und ohne englische Headline in der DB. Damit ist sie im
Frontend nur als Kanji-Headline zu lesen und das Summary-LLM kann den
Treffer nicht aussagekräftig referenzieren.

1. INSERT INTO articles erweitert um headline_en und content_en. Werte
   stammen primär vom Translator (headline_en, falls TRANSLATOR_ENABLED den
   Pfad einmal in Englisch befüllt), Fallback auf die für den Topic-Filter
   angefertigte Mini-Übersetzung (headline_en_for_topic /
   content_en_for_topic). So liegt die englische Variante dauerhaft in der
   DB statt nur während des Refresh-Laufs im Speicher.

2. RSS- und Telegram-Parser setzen 'language' nun primär aus der Quell-/
   Kanal-Konfiguration (primary_language). Vorher war es hart 'de' wenn die
   Headline deutsch wirkte, sonst 'en' - mit dem Resultat, dass ein
   Kanji-Titel als language='en' landete. Mit dem Fix bekommen Asahi & Co.
   korrekt language='ja', russische Telegram-Kanäle 'ru' etc.

- src/agents/orchestrator.py: INSERT erweitert, Kommentar zur Fallback-Logik
- src/feeds/rss_parser.py: language aus feed_config.primary_language
- src/feeds/telegram_parser.py: channel_lang durch _fetch_channel reichen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:05:47 +00:00
dd6a7d66a4 Domain-Cap Google-News-RSS Fix (#29) 2026-05-21 01:54:02 +02:00
4b193d5784 fix(rss): Domain-Cap respektiert Quell-Domain statt URL-Domain
Bisher gruppierte der Domain-Cap nach der URL-Domain. Bei den 14 japanischen
Quellen, die wir über Google-News-Site-Search-RSS einspielen (MOFA, METI, MOD,
PSIA, Kyodo, Nikkei, Sankei, Tokyo-Shimbun, Chunichi, Ryukyu-Shimpo, Yahoo
Japan, NISC und der Hilfs-Bridge-Endpoint), zeigen alle Artikel-Links auf
news.google.com/articles/... — der Cap warf sie alle in einen Topf und
schnitt 10 davon weg.

Lösung: _fetch_feed gibt jetzt feed_config["domain"] (aus sources.domain,
also "mod.go.jp", "kyodo.com", ...) als source_domain mit ins Artikel-Dict.
_apply_domain_cap nutzt diese bevorzugt vor der URL-Domain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 01:53:50 +02:00
35 geänderte Dateien mit 3245 neuen und 195 gelöschten Zeilen

Datei anzeigen

@@ -221,6 +221,49 @@ Changelog-Kategorien in TaskMate:
- 35 = Changelog Website
- 36 = Changelog TaskMate
## FIMI / Counter-Disinformation (Passiver Modus)
Abgleich von Monitor-Artikeln gegen den EUvsDisinfo-Falschbehauptungsbestand,
vollstaendig im Monitor (kein Vigil-Call). Zweistufig:
```yaml
stufe_1_embedding_vorfilter:
modell: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 (384-dim)
service: src/services/embeddings.py (Lazy-Singleton, Modell-Cache ~/.cache/huggingface mit Vigil geteilt)
matcher: src/services/fimi_matcher.py (Claim-Embeddings als numpy-Matrix im RAM, Kosinus)
threshold: 0.55 Floor, 0.65 fuer Uebergabe an Stufe 2
zweck: thematisch nahe Kandidaten finden (hoher Recall)
stufe_2_llm_verifikation:
modell: CLAUDE_MODEL_FAST (Haiku), tools=None
zweck: "verbreitet die Behauptung" vs. "berichtet/widerlegt" trennen (Embedding ist themen-, nicht haltungssensitiv)
ergebnis: nur bestaetigte Verbreitungen werden gespeichert, inkl. woertlichem Zitat
env: FIMI_VERIFY_ENABLED (default true), FIMI_VERIFY_CONCURRENCY (default 4)
daten:
tabelle_claims: fimi_claims (id=Vigil-claim.id, embedding-BLOB, source_ref euvsdisinfo:<slug>, case_url)
tabelle_treffer: article_fimi_matches (article_id, fimi_claim_id, score, role, matched_text)
marker: articles.fimi_checked_at (verhindert Re-Encoding gepruefter Artikel)
import: scripts/import_fimi_claims.py (Sync aus vigil-data/vigil.db, idempotenter UPSERT)
pipeline:
hook: orchestrator nach dem Translator-Schritt, nur neue Artikel des Refreshes (match_article_ids)
endpoints:
GET /incidents/{id}/fimi-matches: Treffer pro Artikel inkl. Provenienz (Andockpunkt 1)
GET /incidents/{id}/fimi-summary: Aggregat fuers Lagebild (Andockpunkt 3)
sources-summary: fimi_match_count pro Quelle (Andockpunkt 2)
frontend:
andockpunkt_1: dezenter Inline-Hinweis am Artikel (Quellen-Detailliste)
andockpunkt_2: Track-Record-Badge pro Quelle
andockpunkt_3: Qualitaetsleiste ueber dem Lagebild + aufklappbare Top-Narrative
rechtslage_euvsdisinfo:
quelle: EUvsDisinfo, Projekt des EEAS (East StratCom Task Force)
lizenz: Forschungsdatensatz CC BY-SA 4.0; EU-Inhalte mit Quellenangabe weiterverwendbar
pflichten: Attribution (Quelle + Case-Link), keine Verfaelschung, Disclaimer "keine offizielle EU-Position"
disclaimer_ort: Fusszeile der FIMI-Qualitaetsleiste (UI.fimiDisclaimerHtml) + Tooltip der Einzeltreffer
provenienz_leitplanke: Monitor wertet nie selbst, zeigt nur was EUvsDisinfo als widerlegt fuehrt
offene_punkte:
- Verifizierer-Prompt feinjustieren (seltene FP bei serioesen Medien, die ueber eine Aussage berichten)
- Per-Satz-Extraktion (Vigil Phase 2) als Praezisionsstufe optional nachruestbar
```
## Staging-Umgebung
```yaml

Datei anzeigen

@@ -1,4 +1,29 @@
[
{
"version": "2026-05-22T19:10Z",
"date": "2026-05-22",
"title": "Exportdialog: Ersteller manuell eintragbar",
"items": [
"Im Export-Dialog kann der Ersteller jetzt manuell eingegeben werden."
]
},
{
"version": "2026-05-22T07:41Z",
"date": "2026-05-22",
"title": "X (Twitter) als neue Informationsquelle verfügbar",
"items": [
"Nachrichten und Beiträge von X (Twitter) können jetzt als Quelle für Lageberichte genutzt werden."
]
},
{
"version": "2026-05-21T17:10Z",
"date": "2026-05-21",
"title": "Sprachunterstützung für Artikel-Überschriften verbessert",
"items": [
"Englische Überschriften werden jetzt korrekt gespeichert und angezeigt.",
"Die Sprache eines Artikels wird automatisch aus der jeweiligen Quelle übernommen."
]
},
{
"version": "2026-05-13T22:38Z",
"date": "2026-05-13",

Datei anzeigen

@@ -11,6 +11,8 @@ python-multipart
aiosmtplib
geonamescache>=2.0
telethon
# X/Twitter-Scraper (feeds/x_parser.py)
twscrape @ git+https://github.com/vladkens/twscrape.git@206f0942fe41149da28530399f7c772ec00be17a
# Bericht-Export (PDF via WeasyPrint + DOCX via python-docx)
Jinja2>=3.1
weasyprint>=68.0
@@ -21,3 +23,10 @@ pdfplumber>=0.11
pytesseract>=0.3
pdf2image>=1.17
Pillow>=10.0
# FIMI / Counter-Disinformation: Embedding-Match gegen EUvsDisinfo-Falschbehauptungen
# (services/embeddings.py, services/fimi_matcher.py). Modell-Cache wird mit Vigil
# geteilt (~/.cache/huggingface). Versionen wie Vigil-venv fuer Kompatibilitaet.
torch==2.12.0
sentence-transformers==3.4.1
transformers==4.57.6
numpy==2.4.5

97
scripts/backfill_fimi.py Ausführbare Datei
Datei anzeigen

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Backfill: alle noch ungeprueften Artikel gegen den Falschbehauptungsbestand
abgleichen (Embedding-Vorfilter + LLM-Verifikation).
Geht alle Lagen mit ungeprueften Artikeln durch, kleine zuerst (schnelle,
frueh testbare Ergebnisse), grosse zuletzt. Pro Lage in Batches, damit die
Score-Matrix (Artikel x Claims) den RAM nicht sprengt. Robust: Fehler
einzelner Batches stoppen den Lauf nicht; bei Artikeln, die wiederholt
scheitern (kein Fortschritt), wird die Lage abgebrochen statt endlos zu
schleifen.
Aufruf (im Staging-Verzeichnis, mit dessen venv):
HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 FIMI_VERIFY_CONCURRENCY=5 \
./venv/bin/python scripts/backfill_fimi.py
"""
from __future__ import annotations
import asyncio
import os
import sys
import time
sys.path.insert(0, "src")
import aiosqlite
from services import fimi_matcher
# Wie config.py: DB_PATH-Env hat Vorrang (Staging-Service nutzt eine eigene
# DB ausserhalb des Repos). Sonst der Repo-Default.
DB_PATH = os.environ.get("DB_PATH") or "data/osint.db"
BATCH = 120
def _ts() -> str:
return time.strftime("%H:%M:%S")
async def main() -> None:
db = await aiosqlite.connect(DB_PATH)
db.row_factory = aiosqlite.Row
t0 = time.time()
n_claims = await fimi_matcher.ensure_matrix(db)
print(f"[{_ts()}] Matrix: {n_claims} Claims geladen", flush=True)
cursor = await db.execute(
"""SELECT incident_id, COUNT(*) AS n
FROM articles WHERE fimi_checked_at IS NULL AND incident_id IS NOT NULL
GROUP BY incident_id ORDER BY n"""
)
incidents = [(r["incident_id"], r["n"]) for r in await cursor.fetchall()]
total = sum(n for _, n in incidents)
print(f"[{_ts()}] START: {len(incidents)} Lagen, {total} ungepruefte Artikel", flush=True)
grand = {"articles": 0, "candidates": 0, "articles_with_match": 0, "stored": 0, "errors": 0}
for iid, n in incidents:
done = 0
prev_remaining = None
while True:
res = await fimi_matcher.match_incident_articles(
db, iid, only_unchecked=True, limit=BATCH
)
if res["articles"] == 0:
break
done += res["articles"]
for k in grand:
grand[k] += res.get(k, 0)
cur = await db.execute(
"SELECT COUNT(*) FROM articles WHERE incident_id = ? AND fimi_checked_at IS NULL",
(iid,),
)
remaining = (await cur.fetchone())[0]
print(
f"[{_ts()}] Lage {iid}: +{res['articles']} ({done}/{n}), "
f"Treffer {res['articles_with_match']}, Fehler {res['errors']}, "
f"verbleibend {remaining}",
flush=True,
)
if remaining == 0:
break
if prev_remaining is not None and remaining >= prev_remaining:
print(
f"[{_ts()}] Lage {iid}: kein Fortschritt (verbleibend {remaining}), "
f"Abbruch wegen wiederholt fehlschlagender Artikel",
flush=True,
)
break
prev_remaining = remaining
print(f"[{_ts()}] == Lage {iid} fertig: {done} Artikel verarbeitet ==", flush=True)
await db.close()
dt = time.time() - t0
print(f"[{_ts()}] FERTIG nach {dt/60:.1f} min: {grand}", flush=True)
if __name__ == "__main__":
asyncio.run(main())

116
scripts/import_fimi_claims.py Ausführbare Datei
Datei anzeigen

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Einmal-/Sync-Import des EUvsDisinfo-Falschbehauptungsbestands in den Monitor.
Kopiert die Claims (Text, Verdict, Widerlegung, Quell-Referenz, Embedding-BLOB)
aus der Vigil-Datenbank in die Monitor-Tabelle fimi_claims. Die Embeddings
werden als BLOB 1:1 uebernommen (384-dim float32, L2-normalisiert) und im
Monitor mit demselben Modell (paraphrase-multilingual-MiniLM-L12-v2) gematcht.
Idempotent: UPSERT auf der stabilen Vigil-claim.id. Bestehende Treffer in
article_fimi_matches bleiben dadurch gueltig.
Aufruf (Staging):
python scripts/import_fimi_claims.py \
--vigil-db /home/claude-dev/vigil-data/vigil.db \
--osint-db /home/claude-dev/AegisSight-Monitor-staging/data/osint.db
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
EUVSDISINFO_REPORT_BASE = "https://euvsdisinfo.eu/report/"
def case_url_from_source_ref(source_ref: str | None) -> str | None:
"""Leitet die EUvsDisinfo-Case-URL aus 'euvsdisinfo:<slug>' ab."""
if not source_ref:
return None
prefix = "euvsdisinfo:"
if source_ref.startswith(prefix):
slug = source_ref[len(prefix):].strip().strip("/")
if slug:
return f"{EUVSDISINFO_REPORT_BASE}{slug}/"
return None
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--vigil-db", required=True, help="Pfad zur Vigil-SQLite-DB (Quelle)")
ap.add_argument("--osint-db", required=True, help="Pfad zur Monitor-SQLite-DB (Ziel)")
ap.add_argument("--limit", type=int, default=0, help="Optional: nur N Claims importieren (Test)")
args = ap.parse_args()
src = sqlite3.connect(args.vigil_db)
src.row_factory = sqlite3.Row
q = (
"SELECT id, text, text_normalized, language, verdict, verdict_summary, "
"source_id, embedding, first_seen_at FROM claims WHERE embedding IS NOT NULL"
)
if args.limit:
q += f" LIMIT {int(args.limit)}"
rows = src.execute(q).fetchall()
src.close()
print(f"Vigil: {len(rows)} Claims mit Embedding gelesen", flush=True)
dst = sqlite3.connect(args.osint_db)
dst.execute("PRAGMA busy_timeout=10000")
# Sicherstellen, dass die Zieltabelle existiert (falls Skript vor init_db laeuft)
dst.execute(
"""CREATE TABLE IF NOT EXISTS fimi_claims (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
text_normalized TEXT,
language TEXT,
verdict TEXT NOT NULL DEFAULT 'false',
verdict_summary TEXT,
source_ref TEXT,
case_url TEXT,
embedding BLOB,
first_seen_at TIMESTAMP,
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
dst.execute("CREATE INDEX IF NOT EXISTS idx_fimi_claims_source_ref ON fimi_claims(source_ref)")
inserted = 0
with_url = 0
for r in rows:
case_url = case_url_from_source_ref(r["source_id"])
if case_url:
with_url += 1
dst.execute(
"""INSERT INTO fimi_claims
(id, text, text_normalized, language, verdict, verdict_summary,
source_ref, case_url, embedding, first_seen_at, imported_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
text=excluded.text,
text_normalized=excluded.text_normalized,
language=excluded.language,
verdict=excluded.verdict,
verdict_summary=excluded.verdict_summary,
source_ref=excluded.source_ref,
case_url=excluded.case_url,
embedding=excluded.embedding,
first_seen_at=excluded.first_seen_at,
imported_at=CURRENT_TIMESTAMP""",
(
r["id"], r["text"], r["text_normalized"], r["language"],
r["verdict"] or "false", r["verdict_summary"], r["source_id"],
case_url, r["embedding"], r["first_seen_at"],
),
)
inserted += 1
dst.commit()
total = dst.execute("SELECT COUNT(*) FROM fimi_claims").fetchone()[0]
dst.close()
print(f"Monitor: {inserted} Claims upserted ({with_url} mit Case-URL), "
f"fimi_claims enthaelt jetzt {total} Eintraege", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())

Datei anzeigen

@@ -124,7 +124,7 @@ BISHERIGE QUELLEN:
AUFTRAG:
1. Aktualisiere das Lagebild basierend auf den neuen Meldungen. Das Lagebild soll so ausführlich wie nötig sein, um alle wesentlichen Themenstränge abzudecken
2. Behalte bestätigte Fakten aus dem bisherigen Lagebild bei
3. Ergänze neue Erkenntnisse und markiere wichtige neue Entwicklungen
3. Arbeite neue Erkenntnisse direkt in den thematisch passenden Abschnitt ein. Erzeuge KEINE datierten Verlaufsblöcke wie "Neu am DD.MM." oder "Neu seit ...". Das Lagebild ist eine zusammenhängende thematische Darstellung des AKTUELLEN Stands, kein chronologisches Änderungsprotokoll. Die zeitliche Abfolge der jüngsten Ereignisse wird separat in der Kachel "Neueste Entwicklungen" gepflegt und darf hier NICHT als Datums-Changelog dupliziert werden
4. Aktualisiere die Quellenverweise — neue Quellen bekommen fortlaufende Nummern nach den bisherigen
5. Entferne nur nachweislich widerlegte Informationen. Behalte alle thematischen Abschnitte bei, auch wenn sie nicht durch neue Meldungen aktualisiert werden
@@ -133,6 +133,8 @@ STRUKTUR:
- Wenn sich Daten strukturiert vergleichen lassen (z.B. Produkte, Unternehmen, Kennzahlen, Modelle), verwende eine Markdown-Tabelle (| Spalte1 | Spalte2 | ... mit Trennzeile |---|---|)
- KEIN Fettdruck (**) verwenden
- ERZEUGE KEINE Sektion "## ZUSAMMENFASSUNG", "## ÜBERBLICK" oder "## KERNPUNKTE". Falls das BISHERIGE LAGEBILD eine solche Sektion enthält, ENTFERNE sie vollständig beim Aktualisieren. Die neuesten Entwicklungen werden separat als eigene Kachel gepflegt und dürfen im Lagebild NICHT dupliziert werden.
- KEINE datierten Verlaufsmarker im Lagebild. Einleitungen wie "Neu am 31.05./01.06.:", "Neu seit gestern:" oder vergleichbare Datums-Changelog-Phrasen sind nicht erlaubt. Falls das BISHERIGE LAGEBILD solche Blöcke enthält, LÖSE SIE AUF: integriere ihren Inhalt in den thematisch passenden Abschnitt und ENTFERNE die "Neu am"-Einleitung samt reiner Datumsgruppierung restlos. Innerhalb eines Abschnitts steht der aktuelle Stand vorne, ältere Belege werden im Fließtext zeitlich eingeordnet (z.B. "Ende Mai berichtete ...").
- KEINE stichwortartigen Fragmente und KEINE blanken Quellennummern-Sammlungen. Verboten sind Telegramm-Verkürzungen wie "Teheran-Bluff-Vorwurf [2897]. NYT-Abraham-Accords [2890]." sowie Auffangblöcke ohne Aussage wie "Frühere Belege [2806][2807]...". Jede Quellennummer muss an einem vollständigen, eigenständigen Satz hängen. Falls das BISHERIGE LAGEBILD solche Fragment- oder Sammelblöcke enthält, formuliere sie zu vollständigen Sätzen aus oder lass die betreffende Quellennummer weg. Am Ende eines Abschnitts oder des Lagebildes darf KEINE reine Aufzählung von Quellennummern stehen.
REGELN:
- Neutral und sachlich - keine Wertungen oder Spekulationen
@@ -260,6 +262,7 @@ REGELN:
- Eine Meldung gilt auch dann als relevant, wenn sie das Thema aus einer gegnerischen/kritischen Perspektive behandelt — es geht um thematische Zugehörigkeit, nicht um Ausrichtung.
- FREMDSPRACHIGE QUELLEN (CJK, Arabisch, Hebräisch, Kyrillisch): Wo verfügbar steht eine "Übersetzung:"-Zeile unter der Originalüberschrift. NUTZE die Übersetzung für deine Bewertung. Verwirf einen fremdsprachigen Artikel NICHT pauschal aus Sicherheit, wenn die Übersetzung das Lagethema sichtbar berührt — wende dieselben Maßstäbe an wie auf englische Artikel.
- Im Zweifel bei lateinisch geschriebenen Quellen: NICHT relevant. Im Zweifel bei nicht-lateinischen Quellen mit übersetzter, thematisch passender Überschrift: relevant.
- FOREN-QUELLEN ([FORUM]-Tag hinter dem Quellennamen, z.B. 5ch, Hatena, Note): WEICHER bewerten. Sie liefern keine Faktenlage, sondern Stimmungsmaterial fuer eine separate Kachel. Wenn das Lage-Keyword im Thread-Titel oder in der ersten Zeile des Inhalts vorkommt UND der Beitrag nicht offensichtlich off-topic ist (Hobby, Sport ohne Bezug, reine Werbung), DURCHLASSEN. Im Zweifel bei Foren-Quellen: relevant.
Antworte AUSSCHLIESSLICH als JSON-Objekt — KEINE Erklärung, KEINE Einleitung:
{{"relevant_ids": [1, 3, 7]}}"""
@@ -530,7 +533,11 @@ class AnalyzerAgent:
# Pre-Topic-Translation für fremdsprachige Headlines (gesetzt vom Orchestrator)
headline_en = article.get("headline_en_for_topic")
content_en = article.get("content_en_for_topic")
lines.append(f"[{i}] Quelle: {source}")
# Foren-Quellen explizit markieren, damit Haiku sie weicher bewertet
# (Stimmungs-Material, nicht Faktenlage — eigener Filter-Modus im Prompt)
is_forum = (article.get("media_type") or "").lower() == "forum"
source_label = f"{source} [FORUM]" if is_forum else source
lines.append(f"[{i}] Quelle: {source_label}")
lines.append(f" Überschrift: {headline}")
if headline_en and headline_en.strip().lower() != (headline or "").strip().lower():
lines.append(f" Übersetzung: {headline_en}")
@@ -566,7 +573,10 @@ class AnalyzerAgent:
}
filtered = [a for i, a in enumerate(articles, 1) if i in relevant_set]
rejected = len(articles) - len(filtered)
rejected_articles = [
(idx, a) for idx, a in enumerate(articles, 1) if idx not in relevant_set
]
rejected = len(rejected_articles)
if not filtered and articles:
logger.warning(
f"Topic-Filter hat ALLE {len(articles)} Artikel verworfen — "
@@ -578,6 +588,14 @@ class AnalyzerAgent:
f"Topic-Filter: {len(filtered)}/{len(articles)} Artikel thematisch relevant "
f"({rejected} verworfen)"
)
for idx, a in rejected_articles:
src = a.get("source", "Unbekannt")
hl = (a.get("headline_de") or a.get("headline") or "").strip()
hl_en = (a.get("headline_en_for_topic") or "").strip()
if hl_en and hl_en.lower() != hl.lower():
logger.info("Topic-Filter REJECT [%d] %s | %s | EN: %s", idx, src, hl[:120], hl_en[:120])
else:
logger.info("Topic-Filter REJECT [%d] %s | %s", idx, src, hl[:120])
return filtered, usage
async def generate_latest_developments(
@@ -656,6 +674,246 @@ class AnalyzerAgent:
logger.info(f"Latest-Developments: {len(bullets)} Bullets aus Lagebild generiert")
return output, usage
async def moderate_forum_articles(
self,
forum_articles: list[dict],
) -> tuple[list[dict], ClaudeUsage | None]:
"""Vorab-Moderation fuer Foren-Beitraege (5ch, Hatena, Note ...).
Schickt eine Batch von bis zu 25 Foren-Beitraegen an Haiku, der pro
Beitrag entscheidet:
- "publishable" -> Beitrag wird unveraendert in die Stimmungs-Kachel uebernommen.
- "redact" -> der Beitrag bleibt, aber sein Content wird auf eine kurze,
entschaerfte Version reduziert (Klarnamen, persoenliche Daten, persoenliche
Beleidigungen entfernt). Die Headline darf bleiben, wenn sie selbst clean ist.
- "discard" -> Beitrag wird aus der Liste entfernt (Hassrede gegen Gruppen,
NSFW, glaubhafte Drohungen, doxxing).
Returns:
(gefilterte_liste, usage) — die Liste enthaelt publishable + redacted
Artikel (in Original-Reihenfolge). Discarded werden weggeworfen. Bei
API-/Parse-Fehler wird die Originalliste unveraendert zurueckgegeben
(Fail-Open, damit die Pipeline nicht hartfaellt — Haiku im Prompt
erinnert nochmal an Moderation).
"""
if not forum_articles:
return forum_articles, None
from config import CLAUDE_MODEL_FAST
# Pro Aufruf nicht mehr als 25 Beitraege (Token-Budget)
if len(forum_articles) > 25:
# In Batches verarbeiten, akkumulieren
kept: list[dict] = []
total_usage: ClaudeUsage | None = None
for i in range(0, len(forum_articles), 25):
batch = forum_articles[i:i + 25]
batch_kept, batch_usage = await self.moderate_forum_articles(batch)
kept.extend(batch_kept)
if batch_usage:
if total_usage is None:
total_usage = batch_usage
else:
try:
total_usage.add(batch_usage) # type: ignore[attr-defined]
except Exception:
pass
return kept, total_usage
items = []
for i, a in enumerate(forum_articles):
headline = (a.get("headline_de") or a.get("headline_en_for_topic") or a.get("headline") or "").strip()
content = (a.get("content_de") or a.get("content_en_for_topic") or a.get("content_original") or "").strip()
items.append({
"i": i,
"source": (a.get("source") or "Forum").strip(),
"headline": headline[:200],
"content": content[:600],
})
prompt = f"""Du bist ein Moderations-Agent fuer ANONYME FOREN-/COMMUNITY-BEITRAEGE (5ch, Hatena, Note).
Diese Beitraege gehen in eine Stimmungs-Kachel eines OSINT-Lagemonitorings ein, das auch von Behoerden gelesen werden kann.
Pro Beitrag entscheide:
- "publishable": Beitrag ist sachlich-bezogen, ohne Hassrede gegen Gruppen, ohne Klarnamen Dritter, ohne sexuelle Inhalte, ohne Drohungen. Keine Aenderung noetig.
- "redact": Beitrag ist im Kern thematisch wertvoll, enthaelt aber persoenliche Daten, persoenliche Beleidigungen oder Klarnamen Dritter. Gib eine bereinigte Kurzfassung des Inhalts (1-3 Saetze) zurueck, die das thematische Argument behaelt aber alle PII/Beleidigungen entfernt.
- "discard": Beitrag ist Hassrede gegen ethnische/religioese/sexuelle Gruppen, NSFW, glaubhafte Drohung, oder reines Trolling ohne Themenbezug.
EINGABE:
{json.dumps(items, ensure_ascii=False)}
Antworte AUSSCHLIESSLICH mit einem JSON-Array. Pro Beitrag genau ein Objekt:
[
{{"i": 0, "decision": "publishable"}},
{{"i": 1, "decision": "redact", "clean_content": "Kurzfassung ohne PII."}},
{{"i": 2, "decision": "discard"}}
]
Keine Erklaerung, keine Einleitung, kein Markdown, nur das Array."""
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
except Exception as e:
logger.warning("Forum-Moderation Claude-Call fehlgeschlagen, fail-open: %s", e)
return forum_articles, None
# Robustes JSON-Parsing
text = (result or "").strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```\s*$", "", text)
text = text.strip()
try:
decisions = json.loads(text)
except json.JSONDecodeError:
m = re.search(r"\[.*\]", text, re.DOTALL)
if m:
try:
decisions = json.loads(m.group(0))
except json.JSONDecodeError:
decisions = None
else:
decisions = None
if not isinstance(decisions, list):
logger.warning("Forum-Moderation: kein JSON-Array, fail-open. Sample: %r", text[:200])
return forum_articles, usage
decision_map: dict[int, dict] = {}
for d in decisions:
if isinstance(d, dict) and isinstance(d.get("i"), int):
decision_map[d["i"]] = d
kept: list[dict] = []
stats = {"publishable": 0, "redact": 0, "discard": 0, "unknown": 0}
for i, art in enumerate(forum_articles):
d = decision_map.get(i)
if not d:
# Keine Entscheidung fuer diesen Beitrag -> als publishable behandeln (fail-open)
kept.append(art)
stats["unknown"] += 1
continue
decision = (d.get("decision") or "").strip().lower()
if decision == "discard":
stats["discard"] += 1
continue
if decision == "redact":
clean = (d.get("clean_content") or "").strip()
if clean:
new_art = dict(art)
new_art["content_original"] = clean
new_art["content_de"] = clean if (art.get("content_de") or "") else None
new_art["_moderation"] = "redacted"
kept.append(new_art)
stats["redact"] += 1
continue
# Redact ohne clean_content -> sicherheitshalber discard
stats["discard"] += 1
continue
# Default / "publishable"
kept.append(art)
stats["publishable"] += 1
logger.info(
"Forum-Moderation: %d publishable, %d redacted, %d discarded, %d ohne Entscheidung",
stats["publishable"], stats["redact"], stats["discard"], stats["unknown"],
)
return kept, usage
async def generate_public_mood(
self,
title: str,
description: str,
forum_articles: list[dict],
output_language: str = "Deutsch",
) -> tuple[str | None, ClaudeUsage | None]:
"""Generiert die Kachel 'Öffentliche Stimmung' aus Foren-Quellen.
Eingabe: Artikel mit media_type='forum' (5ch-Threads, Hatena-Bookmarks,
Note-Trending-Posts etc.). Ausgabe: 3-6 Markdown-Bullets, jeder Bullet
fasst ein dominantes Thema/eine Bruchlinie der Diskussion zusammen und
nennt explizit die Quellen-Herkunft (z.B. "Auf 5ch /seiji/ ueberwiegen
ablehnende Stimmen ...").
WICHTIG: Das ist Stimmungsmaterial, NICHT Faktenlage. Der Prompt weist
Claude explizit an, Eigenaussagen aus Foren nicht als Fakt zu zitieren.
Returns: (markdown_text, usage) oder (None, usage) bei leerer/kaputter
Antwort. Bei keinen Foren-Artikeln: (None, None).
"""
if not forum_articles:
return None, None
from config import CLAUDE_MODEL_FAST
# Pro Quelle gruppieren, damit Claude die Herkunft kennt
by_source: dict[str, list[dict]] = {}
for a in forum_articles:
src = (a.get("source") or "Forum (unbekannt)").strip()
by_source.setdefault(src, []).append(a)
# Artikel-Block bauen, kompakt aber mit Herkunft
lines: list[str] = []
for src, items in by_source.items():
lines.append(f"\n=== Quelle: {src} ({len(items)} Beitrag/-e) ===")
for it in items[:15]: # max 15 pro Quelle, sonst sprengt das den Prompt
headline = it.get("headline_de") or it.get("headline_en_for_topic") or it.get("headline", "")
content = (
it.get("content_de")
or it.get("content_en_for_topic")
or it.get("content_original")
or ""
)
lines.append(f"- {headline[:200]}")
if content:
lines.append(f" {content[:300]}")
articles_block = "\n".join(lines)
prompt = f"""Du bist ein OSINT-Analyst. Aus den folgenden ANONYMEN FOREN-/COMMUNITY-BEITRAEGEN sollst du das Stimmungsbild der oeffentlichen Online-Diskussion fuer eine Lage extrahieren.
LAGE: {title}
KONTEXT: {description}
FOREN-BEITRAEGE (gruppiert nach Quelle):
{articles_block}
AUFGABE:
Erstelle eine kompakte Themen-Zusammenfassung in {output_language}: 3-6 Markdown-Bullet-Points, jeder Bullet fasst ein dominantes Thema, eine Forderung oder eine Bruchlinie der Diskussion zusammen. Pro Bullet 1-3 Saetze.
REGELN:
- DIES IST KEINE FAKTENLAGE. Du fasst zusammen, wie online diskutiert wird, nicht was wahr ist.
- Quellen-Herkunft je Bullet EXPLIZIT nennen ("auf 5ch /seiji/ ueberwiegen ablehnende Reaktionen...", "Hatena-Kommentare betonen ueberwiegend ...", "Note-Autoren schreiben ueberwiegend ...").
- KEINE Eigenaussagen aus Forenposts als Faktenbehauptung uebernehmen.
- KEINE Klarnamen, persoenliche Daten oder Beleidigungen Dritter zitieren.
- Bei klaren Pro-/Contra-Lagern beide Seiten beschreiben.
- Wenn das Material zu duenn oder off-topic ist, gib explizit "Material zu duenn fuer Stimmungsbild" zurueck statt zu spekulieren.
- Markdown: nur "- " Bullets, keine Ueberschriften, kein Fettdruck, keine Inline-Quellenverweise [1].
- KEINE Gedankenstriche (—, –) verwenden — stattdessen Kommas, Doppelpunkte oder neue Saetze.
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).
Antworte AUSSCHLIESSLICH mit dem Markdown-Text der Bullets, ohne Einleitung, ohne Erklaerung."""
try:
result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
except Exception as e:
logger.warning(f"Public-Mood Claude-Call fehlgeschlagen: {e}")
return None, None
text = (result or "").strip()
if not text or "zu duenn" in text.lower() or "too thin" in text.lower():
logger.info("Public-Mood: Material zu duenn, kein Stimmungsbild generiert")
return None, usage
# Sanity-Check: mindestens 1 Bullet (- am Zeilenanfang)
if not any(line.lstrip().startswith("-") for line in text.split("\n")):
logger.warning("Public-Mood: Claude-Antwort enthaelt keine Bullets, Sample: %r", text[:200])
return None, usage
logger.info(
"Public-Mood: %d Forum-Beitraege aus %d Quellen zu Stimmungsbild zusammengefasst",
len(forum_articles), len(by_source),
)
return text, usage
@staticmethod
def _parse_latest_developments(text: str, new_articles: list[dict] | None = None) -> list[str]:
"""Extrahiert '- [DD.MM. HH:MM] ...'-Zeilen aus der Claude-Antwort.

Datei anzeigen

@@ -431,9 +431,27 @@ class FactCheckerAgent:
"""Prüft Fakten über Claude CLI gegen unabhängige Quellen."""
def _format_articles_text(self, articles: list[dict], max_articles: int = 20) -> str:
"""Formatiert Artikel als Text für den Prompt."""
"""Formatiert Artikel als Text für den Prompt.
Foren-Quellen (media_type='forum', z.B. 5ch/Hatena/Note) werden hier
ausgeschlossen — sie sind Stimmungsmaterial, kein Faktenbeleg. Ein
anonymer Forenpost darf nicht als "Quelle bestaetigt Behauptung X"
gelten.
"""
# Falls media_type am Dict vorhanden ist, Foren-Quellen ausfiltern.
# Bei Article-Dicts aus dem RSS-/Pre-Topic-Pfad ist das Feld gesetzt;
# bei Reload aus der DB muss der Orchestrator das per JOIN annotieren.
non_forum = [a for a in articles if (a.get("media_type") or "").lower() != "forum"]
skipped = len(articles) - len(non_forum)
if skipped > 0:
logger.info(
"Faktencheck: %d Foren-Quellen (media_type='forum') ausgeschlossen, "
"%d Artikel als Faktenbeleg-Kandidaten",
skipped, len(non_forum),
)
articles_text = ""
for i, article in enumerate(articles[:max_articles]):
for i, article in enumerate(non_forum[:max_articles]):
articles_text += f"\n--- Meldung {i+1} ---\n"
articles_text += f"Quelle: {article.get('source', 'Unbekannt')}\n"
source_url = article.get('source_url', '')

Datei anzeigen

@@ -31,6 +31,28 @@ def _get_geonamescache():
return _gc
# Geografische Zentren (Centroids) der Laender, keyed nach ISO-2-Code.
# Wird genutzt, wenn ein Artikel ein LAND nennt (kein konkreter Ort). Vorher
# wurde dem Land die Hauptstadt zugewiesen — das stapelte z.B. alle "Japan"-
# Marker exakt auf Tokyo und suggerierte faelschlich ein Ereignis in der
# Hauptstadt. Das Centroid liegt in der Landesmitte und ist neutral.
# Laender, die hier fehlen, fallen auf die Hauptstadt zurueck (alte Logik).
_COUNTRY_CENTROIDS = {
"AF": (33.94, 67.71), "AT": (47.52, 14.55), "AZ": (40.14, 47.58),
"CH": (46.82, 8.23), "CN": (35.86, 104.20), "CY": (35.13, 33.43),
"DE": (51.17, 10.45), "EG": (26.82, 30.80), "ES": (40.46, -3.75),
"FR": (46.23, 2.21), "GB": (54.70, -3.28), "GR": (39.07, 21.82),
"IL": (31.05, 34.85), "IN": (20.59, 78.96), "IQ": (33.22, 43.68),
"IR": (32.43, 53.69), "IT": (41.87, 12.57), "JO": (30.59, 36.24),
"JP": (36.20, 138.25), "KP": (40.34, 127.51), "KR": (35.91, 127.77),
"KW": (29.31, 47.48), "LB": (33.85, 35.86), "NL": (52.13, 5.29),
"OM": (21.47, 55.98), "PK": (30.38, 69.35), "PS": (31.95, 35.23),
"QA": (25.32, 51.18), "RU": (61.52, 105.32), "SA": (23.89, 45.08),
"SY": (34.80, 38.997), "TR": (38.96, 35.24), "UA": (48.38, 31.17),
"US": (39.83, -98.58), "YE": (15.55, 48.52), "TW": (23.80, 121.00),
}
# Bekannte Laendernamen (deutsch/englisch/alternativ -> ISO-2 Code + Hauptstadt-Koordinaten)
_COUNTRY_ALIASES = {
"libanon": {"code": "LB", "name": "Lebanon", "lat": 33.8938, "lon": 35.5018},
@@ -106,9 +128,12 @@ def _geocode_offline(name: str, country_code: str = "") -> Optional[dict]:
# 1. Bekannte Laender-Aliase (schnellster + sicherster Pfad)
alias = _COUNTRY_ALIASES.get(name_lower)
if alias:
# Land -> geografisches Zentrum (Centroid) statt Hauptstadt, wo bekannt.
centroid = _COUNTRY_CENTROIDS.get(alias["code"])
lat, lon = centroid if centroid else (alias["lat"], alias["lon"])
return {
"lat": alias["lat"],
"lon": alias["lon"],
"lat": lat,
"lon": lon,
"country_code": alias["code"],
"normalized_name": alias["name"],
"confidence": 0.95,
@@ -118,9 +143,20 @@ def _geocode_offline(name: str, country_code: str = "") -> Optional[dict]:
countries = gc.get_countries()
for code, country in countries.items():
if country.get("name", "").lower() == name_lower:
# Land -> Centroid (Landesmitte), wo bekannt. Das verhindert, dass
# alle "Japan"-Marker exakt auf Tokyo gestapelt werden.
centroid = _COUNTRY_CENTROIDS.get(code)
if centroid:
return {
"lat": centroid[0],
"lon": centroid[1],
"country_code": code,
"normalized_name": country["name"],
"confidence": 0.9,
}
# Kein Centroid hinterlegt -> Fallback auf die Hauptstadt.
capital = country.get("capital", "")
if capital:
# Hauptstadt geocoden, aber als Land benennen
cap_alias = _COUNTRY_ALIASES.get(capital.lower())
if cap_alias:
return {

Datei anzeigen

@@ -2,6 +2,7 @@
import asyncio
import json
import logging
import os
import re
from datetime import datetime
from config import TIMEZONE
@@ -34,6 +35,7 @@ CATEGORY_REPUTATION = {
"international": 0.75, # CNN, Guardian, NYT, Al Jazeera, France24
"regional": 0.65, # regionale Tageszeitungen
"telegram": 0.5, # OSINT-Kanaele — gemischte Qualitaet
"x": 0.4, # X/Twitter-Accounts, hohes Rauschen
"sonstige": 0.4, # unkategorisiert
"boulevard": 0.3, # Bild, Sun etc.
}
@@ -400,48 +402,83 @@ async def _send_email_notifications_for_incident(
class AgentOrchestrator:
"""Verwaltet die Claude-Agenten-Queue und koordiniert Recherche-Zyklen."""
"""Koordiniert die Recherche-Zyklen: pro Organisation eine unabhaengige,
parallel laufende Worker-Lane."""
# Lane-Schluessel fuer oeffentliche/systemweite Lagen (tenant_id IS NULL).
PUBLIC_LANE = 0
# Sekunden ohne Auftrag, nach denen eine Lane sich beendet und aufraeumt.
IDLE_TIMEOUT = 60
def __init__(self):
self._queue: asyncio.Queue = asyncio.Queue()
self._running = False
self._current_task: Optional[int] = None
# Session-Start des aktuellen Tasks (UTC ISO mit 'Z'). Ueberspannt Multi-Pass
# und Retries innerhalb derselben Queue-Abarbeitung — verhindert, dass der
# Frontend-Timer beim Seiten-Reload auf den Pass/Retry-Start zurueckspringt.
self._current_task_started_at: Optional[str] = None
self._ws_manager = None
self._queued_ids: set[int] = set()
# Pro Organisation (tenant_id) eine eigene Queue + ein eigener Worker-Task.
# So arbeiten Organisationen unabhaengig und parallel, statt sich eine
# globale Warteschlange zu teilen. Lanes werden bei Bedarf angelegt und bei
# Leerlauf wieder beendet. Der Lock schuetzt das Anlegen/Beenden gegen
# gleichzeitiges Einreihen.
self._lanes: dict[int, asyncio.Queue] = {}
self._lane_workers: dict[int, asyncio.Task] = {}
self._lanes_lock = asyncio.Lock()
# Globale Zustaende — incident-IDs sind lane-uebergreifend eindeutig.
self._queued_ids: set[int] = set() # eingereiht (in irgendeiner Lane)
# incident_id -> Session-Start (UTC ISO mit 'Z'). Ersetzt den frueheren
# Einzelwert; haelt den Frontend-Timer ueber Multi-Pass/Retry stabil.
self._current_tasks: dict[int, str] = {}
self._cancel_requested: set[int] = set()
self._cancel_event: asyncio.Event | None = None
# incident_id -> Cancel-Event des laufenden Tasks. cancel_refresh laeuft in
# einem anderen async-Kontext als der Worker und kann die ContextVar nicht
# nutzen, daher diese direkte Zuordnung.
self._cancel_events: dict[int, asyncio.Event] = {}
# Optionales Sicherheitsventil gegen Ueberlastung des gemeinsamen Claude-
# Kontos: begrenzt die Zahl GLEICHZEITIG laufender Recherchen ueber alle
# Lanes. Default 0 = unbegrenzt (jede Organisation voellig unabhaengig).
_max = int(os.getenv("ORCHESTRATOR_MAX_PARALLEL", "0") or "0")
self._global_sem: Optional[asyncio.Semaphore] = asyncio.Semaphore(_max) if _max > 0 else None
def set_ws_manager(self, ws_manager):
"""WebSocket-Manager setzen für Echtzeit-Updates."""
self._ws_manager = ws_manager
async def start(self):
"""Queue-Worker starten."""
"""Orchestrator aktivieren. Worker-Lanes werden pro Organisation bei der
ersten Anfrage angelegt (lazy)."""
self._running = True
asyncio.create_task(self._worker())
logger.info("Agenten-Orchestrator gestartet")
async def stop(self):
"""Queue-Worker stoppen."""
"""Orchestrator stoppen und alle Lane-Worker beenden."""
self._running = False
async with self._lanes_lock:
workers = list(self._lane_workers.values())
self._lane_workers.clear()
self._lanes.clear()
for task in workers:
task.cancel()
logger.info("Agenten-Orchestrator gestoppt")
async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual", user_id: int = None) -> bool:
"""Refresh-Auftrag in die Queue stellen. Gibt False zurueck wenn bereits in Queue/aktiv."""
if incident_id in self._queued_ids or self._current_task == incident_id:
"""Refresh-Auftrag in die Lane der Organisation stellen. Gibt False zurueck
wenn die Lage dort bereits wartet oder gerade laeuft."""
if incident_id in self._queued_ids or incident_id in self._current_tasks:
logger.info(f"Refresh fuer Lage {incident_id} uebersprungen: bereits aktiv/in Queue")
return False
visibility, created_by, tenant_id = await self._get_incident_visibility(incident_id)
lane_key = tenant_id if tenant_id else self.PUBLIC_LANE
self._queued_ids.add(incident_id)
await self._queue.put((incident_id, trigger_type, user_id))
queue_size = self._queue.qsize()
logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Queue: {queue_size}, Trigger: {trigger_type})")
async with self._lanes_lock:
queue = self._lanes.get(lane_key)
if queue is None:
queue = asyncio.Queue()
self._lanes[lane_key] = queue
self._lane_workers[lane_key] = asyncio.create_task(self._worker(lane_key, queue))
logger.info(f"Neue Worker-Lane fuer Organisation {lane_key} gestartet")
self._queued_ids.add(incident_id)
queue.put_nowait((incident_id, trigger_type, user_id))
queue_size = queue.qsize()
logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Lane {lane_key}, Queue: {queue_size}, Trigger: {trigger_type})")
if self._ws_manager:
await self._ws_manager.broadcast_for_incident({
@@ -454,11 +491,12 @@ class AgentOrchestrator:
async def cancel_refresh(self, incident_id: int) -> bool:
"""Fordert Abbruch eines laufenden oder wartenden Refreshes an."""
# Check if it's the currently running task
if self._current_task == incident_id:
# Laeuft die Lage gerade?
if incident_id in self._current_tasks:
self._cancel_requested.add(incident_id)
if self._cancel_event:
self._cancel_event.set()
ev = self._cancel_events.get(incident_id)
if ev:
ev.set()
logger.info(f"Cancel angefordert fuer laufende Lage {incident_id}")
if self._ws_manager:
try:
@@ -472,25 +510,33 @@ class AgentOrchestrator:
}, vis, cb, tid)
return True
# Check if it's in the queue (not yet started)
# Wartet die Lage noch in ihrer Lane?
if incident_id in self._queued_ids:
self._queued_ids.discard(incident_id)
# Remove from asyncio queue (rebuild without this ID)
# Betroffene Lane bestimmen und die ID aus deren Queue entfernen.
try:
_vis, _cb, _tid = await self._get_incident_visibility(incident_id)
except Exception:
_tid = None
lane_key = _tid if _tid else self.PUBLIC_LANE
removed = False
new_items = []
while not self._queue.empty():
try:
item = self._queue.get_nowait()
iid = item[0] if isinstance(item, tuple) else item
if iid == incident_id:
removed = True
self._queue.task_done()
else:
new_items.append(item)
except Exception:
break
for item in new_items:
self._queue.put_nowait(item)
async with self._lanes_lock:
queue = self._lanes.get(lane_key)
if queue is not None:
new_items = []
while not queue.empty():
try:
item = queue.get_nowait()
except Exception:
break
iid = item[0] if isinstance(item, tuple) else item
if iid == incident_id:
removed = True
queue.task_done()
else:
new_items.append(item)
for item in new_items:
queue.put_nowait(item)
logger.info(f"Lage {incident_id} aus Warteschlange entfernt (removed={removed})")
@@ -518,12 +564,22 @@ class AgentOrchestrator:
self._cancel_requested.discard(incident_id)
raise asyncio.CancelledError("Vom Nutzer abgebrochen")
async def _worker(self):
"""Verarbeitet Refresh-Aufträge sequentiell."""
async def _worker(self, lane_key: int, queue: asyncio.Queue):
"""Verarbeitet die Auftraege EINER Organisation sequentiell. Verschiedene
Organisationen laufen in eigenen Lanes parallel. Bei Leerlauf beendet sich
die Lane selbst und wird beim naechsten Auftrag neu angelegt."""
while self._running:
try:
item = await asyncio.wait_for(self._queue.get(), timeout=5.0)
item = await asyncio.wait_for(queue.get(), timeout=self.IDLE_TIMEOUT)
except asyncio.TimeoutError:
# Leerlauf: Lane beenden. Unter Lock gegen gleichzeitiges Einreihen,
# damit kein Auftrag in einer verwaisten Queue liegen bleibt.
async with self._lanes_lock:
if queue.empty() and self._lanes.get(lane_key) is queue:
self._lanes.pop(lane_key, None)
self._lane_workers.pop(lane_key, None)
logger.info(f"Worker-Lane fuer Organisation {lane_key} bei Leerlauf beendet")
return
continue
if len(item) == 3:
@@ -532,12 +588,12 @@ class AgentOrchestrator:
incident_id, trigger_type = item
user_id = None
self._queued_ids.discard(incident_id)
self._current_task = incident_id
# Session-Start EINMAL setzen — bleibt ueber Multi-Pass/Retry hinweg stabil
self._current_task_started_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
self._cancel_event = asyncio.Event()
_cancel_event_var.set(self._cancel_event)
logger.info(f"Starte Refresh für Lage {incident_id} (Trigger: {trigger_type})")
self._current_tasks[incident_id] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
cancel_event = asyncio.Event()
self._cancel_events[incident_id] = cancel_event
_cancel_event_var.set(cancel_event)
logger.info(f"Starte Refresh für Lage {incident_id} (Lane {lane_key}, Trigger: {trigger_type})")
RETRY_DELAYS = [0, 120, 300] # Sekunden: sofort, 2min, 5min
TRANSIENT_ERRORS = (asyncio.TimeoutError, TimeoutError, ConnectionError, OSError)
@@ -547,6 +603,10 @@ class AgentOrchestrator:
def _is_transient_cli(err: Exception) -> bool:
return isinstance(err, ClaudeCliError) and err.error_type in ("rate_limit", "timeout")
# Optionales globales Ventil (Default aus): begrenzt gleichzeitige Recherchen.
sem = self._global_sem
if sem is not None:
await sem.acquire()
try:
# Research-Lagen: Automatisch 3 Durchläufe nur beim ersten Refresh
incident_type, has_summary = await self._get_incident_info(incident_id)
@@ -625,11 +685,13 @@ class AgentOrchestrator:
"data": {"error": str(last_error)},
}, _vis, _cb, _tid)
finally:
self._current_task = None
self._current_task_started_at = None
self._cancel_event = None
if sem is not None:
sem.release()
self._current_tasks.pop(incident_id, None)
self._cancel_events.pop(incident_id, None)
self._cancel_requested.discard(incident_id)
_cancel_event_var.set(None)
self._queue.task_done()
queue.task_done()
async def _mark_refresh_cancelled(self, incident_id: int):
"""Markiert den laufenden Refresh-Log-Eintrag als cancelled und schliesst
@@ -744,14 +806,43 @@ class AgentOrchestrator:
description = incident["description"] or ""
incident_type = incident["type"] or "adhoc"
international = bool(incident["international_sources"]) if "international_sources" in incident.keys() else True
# Wenn die Org eine Sprach-Whitelist gesetzt hat, ist 'international' bedeutungslos —
# die Whitelist gewinnt. Wir setzen 'international' auf True, damit der nachgelagerte
# Code alle (durch Whitelist gefilterten) Feeds in Betracht zieht. Tatsaechliche
# Einschraenkung passiert in get_feeds_with_metadata.
# Hinweis: source_lang_whitelist wird weiter unten geladen.
include_telegram = bool(incident["include_telegram"]) if "include_telegram" in incident.keys() else False
include_x = bool(incident["include_x"]) if "include_x" in incident.keys() else False
visibility = incident["visibility"] if "visibility" in incident.keys() else "public"
created_by = incident["created_by"] if "created_by" in incident.keys() else None
tenant_id = incident["tenant_id"] if "tenant_id" in incident.keys() else None
# Org-Sprache fuer alle KI-Agenten (Lagebild, Faktencheck, Recherche)
from services.org_settings import get_org_language, language_display
from services.org_settings import (
get_org_language, language_display, get_research_language,
get_source_language_whitelist, get_translator_enabled,
)
output_language_iso = await get_org_language(db, tenant_id) if tenant_id else "de"
output_language = language_display(output_language_iso)
# research_language steuert nur den WebSearch-Prompt ("suche in Sprache X").
# Default = output_language_iso. Bei jp_demo wird das auf 'ja' gesetzt, waehrend
# output_language_iso 'de' bleibt (Lagebild auf Deutsch, Recherche auf Japanisch).
research_language_iso = await get_research_language(db, tenant_id) if tenant_id else output_language_iso
# source_language_whitelist schraenkt RSS-/Telegram-Quellenpool ein (z.B. ['ja']).
# Wenn gesetzt, wird das incident-level Flag international_sources ignoriert
# (Whitelist ist explizit, das Flag ist Default-Verhalten).
source_lang_whitelist = await get_source_language_whitelist(db, tenant_id) if tenant_id else None
# Pro-Org-Override des globalen TRANSLATOR_ENABLED-Flags.
translator_enabled = await get_translator_enabled(db, tenant_id)
# Whitelist gewinnt ueber das incident-Flag international_sources:
# wenn die Org eine Sprach-Whitelist hat, sind alle gewaehlten Feeds
# ohnehin "Wunsch-Sprache" — kein Splitting in primary/international noetig.
if source_lang_whitelist:
international = True
logger.info(
"Org %s hat source_language_whitelist=%s gesetzt; "
"incident.international_sources wird ignoriert",
tenant_id, source_lang_whitelist,
)
previous_summary = incident["summary"] or ""
previous_sources_json = incident["sources_json"] if "sources_json" in incident.keys() else None
previous_developments = incident["latest_developments"] if "latest_developments" in incident.keys() else None
@@ -894,7 +985,32 @@ class AgentOrchestrator:
# Feed-Selektion-Keywords nur als Fallback wenn dynamische fehlen
if not keywords:
keywords = feed_sel_keywords
articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords)
# --- Recall-Boost: dynamische Google-News-Volltext-Suchfeeds ---
# Statt nur feste site:-Feeds zu durchsuchen, baut die Pipeline
# pro Sprache einen Google-News-Suchfeed aus den Keywords. Damit
# erreichen wir Quellen, die in keinem festen Feed stehen
# (Vendor-Blogs, Fachportale, Regionalmedien).
from agents.researcher import build_news_search_feeds
if source_lang_whitelist:
_gnews_langs = list(source_lang_whitelist)
else:
_gnews_langs = list({output_language_iso, research_language_iso})
# Zwei Sets: ein Kontext-Feed (alle Zeiten) + ein Frische-Feed
# (when:14d). Der Frische-Feed garantiert, dass das aktuelle
# Bild eingefangen wird, auch wenn aeltere Artikel relevanter
# ranken. Beide laufen durch dieselbe Pipeline; Dedup entfernt
# Ueberschneidungen.
_gnews_feeds = build_news_search_feeds(keywords, _gnews_langs)
_gnews_recent = build_news_search_feeds(keywords, _gnews_langs, recency_days=14)
_all_gnews = _gnews_feeds + _gnews_recent
if _all_gnews:
logger.info(
f"Google-News-Suchfeeds ergaenzt: {len(_gnews_feeds)} Kontext "
f"+ {len(_gnews_recent)} Frische (when:14d)"
)
articles = await rss_parser.search_feeds_selective(
title, selected_feeds + _all_gnews, keywords=keywords,
)
else:
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords, user_id=user_id)
@@ -936,6 +1052,7 @@ class AgentOrchestrator:
preferred_sources=preferred_sources,
output_language=output_language,
output_language_iso=output_language_iso,
research_language_iso=research_language_iso,
)
logger.info(
f"Claude-Recherche: {len(results)} Ergebnisse"
@@ -1024,20 +1141,67 @@ class AgentOrchestrator:
logger.info(f"Telegram-Pipeline: {len(articles)} Nachrichten")
return articles, None
async def _x_pipeline():
"""X-Account-Suche (Twitter) mit KI-basierter Account-Selektion."""
from feeds.x_parser import XParser
x_parser = XParser()
# Alle X-Accounts laden
all_accounts = await x_parser._get_x_accounts(tenant_id=tenant_id)
if not all_accounts:
logger.info("Keine X-Accounts konfiguriert")
return [], None
# KI waehlt relevante Accounts aus
x_researcher = ResearcherAgent()
selected_accounts, x_sel_usage = await x_researcher.select_relevant_x_accounts(
title, description, all_accounts
)
if x_sel_usage:
usage_acc.add(x_sel_usage)
selected_ids = [acc["id"] for acc in selected_accounts]
logger.info(f"X-Selektion: {len(selected_ids)} von {len(all_accounts)} Accounts")
# Dynamische Keywords fuer X (eigener Aufruf, da parallel zu RSS)
cursor_x_hl = await db.execute(
"""SELECT COALESCE(headline_de, headline) as hl
FROM articles WHERE incident_id = ?
AND COALESCE(headline_de, headline) IS NOT NULL
ORDER BY collected_at DESC LIMIT 30""",
(incident_id,),
)
x_headlines = [row["hl"] for row in await cursor_x_hl.fetchall() if row["hl"]]
x_keywords, x_kw_usage = await x_researcher.extract_dynamic_keywords(title, x_headlines)
if x_kw_usage:
usage_acc.add(x_kw_usage)
articles = await x_parser.search_accounts(
title, tenant_id=tenant_id, keywords=x_keywords, account_ids=selected_ids
)
logger.info(f"X-Pipeline: {len(articles)} Posts")
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/X)
pipelines = [_rss_pipeline(), _web_search_pipeline(), _podcast_pipeline()]
telegram_idx = x_idx = None
if include_telegram:
telegram_idx = len(pipelines)
pipelines.append(_telegram_pipeline())
if include_x:
x_idx = len(pipelines)
pipelines.append(_x_pipeline())
pipeline_results = await asyncio.gather(*pipelines)
(rss_articles, rss_feed_usage) = pipeline_results[0]
(search_results, search_usage, search_parse_failed) = pipeline_results[1]
(podcast_articles, _podcast_usage) = pipeline_results[2]
telegram_articles = pipeline_results[3][0] if include_telegram else []
telegram_articles = pipeline_results[telegram_idx][0] if telegram_idx is not None else []
x_articles = pipeline_results[x_idx][0] if x_idx is not None else []
# Podcast-Artikel in die RSS-Liste einfuegen (gleicher Downstream-Pfad)
if podcast_articles:
@@ -1056,7 +1220,7 @@ class AgentOrchestrator:
self._check_cancelled(incident_id)
# Alle Ergebnisse zusammenführen
all_results = rss_articles + search_results + telegram_articles
all_results = rss_articles + search_results + telegram_articles + x_articles
# Pipeline-Schritt 2: Nachrichten sammeln (fertig)
try:
_delivering_sources = len({a.get("source", "") for a in all_results if a.get("source")})
@@ -1174,18 +1338,28 @@ class AgentOrchestrator:
new_count = 0
new_articles_for_analysis = []
for article in new_candidates:
# headline_en / content_en: zuerst die vollwertige Übersetzung
# vom Translator (wenn TRANSLATOR_ENABLED), sonst die für den
# Topic-Filter angefertigte Mini-Übersetzung wiederverwenden.
# Ohne diesen Fallback würden fremdsprachige Artikel zwar
# gefiltert, aber ohne englische Headline in der DB landen und
# später im Frontend bzw. im Summary-LLM unlesbar bleiben.
headline_en = article.get("headline_en") or article.get("headline_en_for_topic")
content_en = article.get("content_en") or article.get("content_en_for_topic")
cursor = await db.execute(
"""INSERT INTO articles (incident_id, headline, headline_de, source,
source_url, content_original, content_de, language, published_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO articles (incident_id, headline, headline_de, headline_en, source,
source_url, content_original, content_de, content_en, language, published_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
incident_id,
article.get("headline", ""),
article.get("headline_de"),
headline_en,
article.get("source", "Unbekannt"),
article.get("source_url"),
article.get("content_original"),
article.get("content_de"),
content_en,
article.get("language", "de"),
article.get("published_at"),
tenant_id,
@@ -1199,14 +1373,25 @@ class AgentOrchestrator:
await db.commit()
# Geoparsing: Orte aus neuen Artikeln extrahieren und speichern
if new_articles_for_analysis:
# Foren-Quellen (media_type='forum') ausschliessen: 5ch/Hatena/Note-Posts haben
# keinen eigenen, fuer das Lagebild interessanten geographischen Bezug; spart Haiku-Calls.
articles_for_geoparsing = [
a for a in new_articles_for_analysis
if (a.get("media_type") or "").lower() != "forum"
]
if new_articles_for_analysis and not articles_for_geoparsing:
logger.info(
"Geoparsing uebersprungen: alle %d neuen Artikel sind Forum-Quellen",
len(new_articles_for_analysis),
)
if articles_for_geoparsing:
# Pipeline-Schritt 5: Orte erkennen (Start)
await _pipe_start("geoparsing")
try:
from agents.geoparsing import geoparse_articles
incident_context = f"{title} - {description}"
logger.info(f"Geoparsing fuer {len(new_articles_for_analysis)} neue Artikel...")
geo_results, category_labels = await geoparse_articles(new_articles_for_analysis, incident_context)
logger.info(f"Geoparsing fuer {len(articles_for_geoparsing)} neue Artikel (Foren ausgeschlossen)...")
geo_results, category_labels = await geoparse_articles(articles_for_geoparsing, incident_context)
geo_count = 0
for art_id, locations in geo_results.items():
for loc in locations:
@@ -1284,7 +1469,12 @@ class AgentOrchestrator:
all_articles_preloaded = None
if not previous_summary or new_count == 0 or not existing_facts:
cursor = await db.execute(
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
# JOIN auf sources, damit media_type pro Artikel verfuegbar ist
# (Faktencheck schliesst Foren-Quellen aus, das Stimmungs-Modul nimmt
# nur diese). Bei Quellen ohne Match in sources bleibt media_type NULL.
"SELECT a.*, s.media_type AS media_type FROM articles a "
"LEFT JOIN sources s ON s.name = a.source "
"WHERE a.incident_id = ? ORDER BY a.collected_at DESC",
(incident_id,),
)
all_articles_preloaded = [dict(row) for row in await cursor.fetchall()]
@@ -1437,6 +1627,78 @@ class AgentOrchestrator:
logger.warning("build_fact_context_block fehlgeschlagen: %s", ctx_err, exc_info=True)
fact_context_block = ""
# Pipeline-Schritt 6b: Öffentliche Stimmung aus Foren-Quellen
# (nur Artikel mit media_type='forum'). Eigene Kachel, kein Faktencheck.
# Wird vor dem Lagebild-Schritt ausgefuehrt, damit das Lagebild bei
# Bedarf darauf verweisen kann (z.B. Demo-Lagen mit Bezug zur Stimmung).
try:
# Bestand aller Foren-Artikel der Lage laden (inkl. media_type via JOIN)
cursor_fm = await db.execute(
"SELECT a.*, s.media_type AS media_type FROM articles a "
"LEFT JOIN sources s ON s.name = a.source "
"WHERE a.incident_id = ?",
(incident_id,),
)
all_articles_with_mt = [dict(r) for r in await cursor_fm.fetchall()]
forum_articles_in_db = [
a for a in all_articles_with_mt
if (a.get("media_type") or "").lower() == "forum"
]
# Aus dem aktuellen Refresh-Lauf zusaetzliche Foren-Artikel ergaenzen
# (haben media_type aus feed_config, sind aber evtl. noch nicht in DB,
# wenn die Persistierung anders laeuft — Robustheit).
for art in new_articles_for_analysis:
if (art.get("media_type") or "").lower() != "forum":
continue
# Duplikate vermeiden ueber source_url
if any(a.get("source_url") == art.get("source_url") for a in forum_articles_in_db):
continue
forum_articles_in_db.append(art)
if forum_articles_in_db:
await _pipe_start("public_mood")
try:
mood_agent = AnalyzerAgent()
# 1. Moderationspass: Hassrede/PII/NSFW vorab filtern.
moderated_articles, mod_usage = await mood_agent.moderate_forum_articles(
forum_articles_in_db,
)
if mod_usage:
usage_acc.add(mod_usage)
# 2. Stimmungs-Zusammenfassung aus gefilterten Beitraegen.
mood_text, mood_usage = await mood_agent.generate_public_mood(
title, description, moderated_articles,
output_language=output_language,
)
if mood_usage:
usage_acc.add(mood_usage)
if mood_text:
await db.execute(
"UPDATE incidents SET public_mood = ?, public_mood_updated_at = ? WHERE id = ?",
(mood_text, now, incident_id),
)
await db.commit()
logger.info(
"Public-Mood gespeichert fuer Incident %d (%d -> %d Foren-Artikel nach Moderation)",
incident_id, len(forum_articles_in_db), len(moderated_articles),
)
await _pipe_done(
"public_mood",
count_value=len(moderated_articles),
count_secondary=(1 if mood_text else 0),
)
except Exception as mood_err:
logger.warning("Public-Mood fehlgeschlagen: %s", mood_err, exc_info=True)
await _pipe_done("public_mood", count_value=0, count_secondary=0)
else:
await _pipe_skip("public_mood")
except Exception as mood_outer_err:
logger.warning("Public-Mood-Block uebersprungen: %s", mood_outer_err)
try:
await _pipe_skip("public_mood")
except Exception:
pass
# Pipeline-Schritt 7: Lagebild verfassen (jetzt mit Faktenkontext)
await _pipe_start("summary")
logger.info(
@@ -1552,6 +1814,7 @@ class AgentOrchestrator:
# 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).
_translate_step_started = False
try:
tr_cursor = await db.execute(
"""SELECT id, headline, content_original, language
@@ -1563,7 +1826,10 @@ class AgentOrchestrator:
(incident_id,),
)
pending_translations = [dict(r) for r in await tr_cursor.fetchall()]
if pending_translations:
if pending_translations and translator_enabled:
# Pipeline-Schritt 9: Artikel uebersetzen (nur sichtbar wenn was zu uebersetzen)
await _pipe_start("translate")
_translate_step_started = True
logger.info(
"Translator fuer Incident %d: %d Artikel ohne DE-Uebersetzung",
incident_id, len(pending_translations),
@@ -1572,8 +1838,9 @@ class AgentOrchestrator:
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
translations = await translate_articles(
pending_translations,
output_lang="de",
output_lang=output_language_iso,
usage_accumulator=usage_acc,
enabled=translator_enabled,
)
for t in translations:
hd = t.get("headline_de")
@@ -1593,10 +1860,44 @@ class AgentOrchestrator:
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
incident_id, len(translations), len(pending_translations),
)
await _pipe_done("translate", count_value=len(translations), count_secondary=len(pending_translations))
except Exception as e:
logger.error("Translator-Fehler fuer Incident %d: %s", incident_id, e, exc_info=True)
if _translate_step_started:
await _pipe_done("translate", count_value=0, count_secondary=0)
# Refresh trotz Translator-Fehler weiterlaufen lassen
# --- FIMI: Abgleich gegen den EUvsDisinfo-Falschbehauptungsbestand ---
# Nur die in diesem Refresh neu hinzugekommenen Artikel (per ID), nach
# dem Translator, damit auch fremdsprachige Artikel ihren DE-Text fuer
# die LLM-Verifikation haben. Fehler duerfen den Refresh nicht brechen.
try:
_fimi_ids = [a.get("id") for a in new_articles_for_analysis if a.get("id")]
if _fimi_ids:
from services import fimi_matcher
await _pipe_start("fimi")
_fimi_res = await fimi_matcher.match_article_ids(db, _fimi_ids)
await _pipe_done(
"fimi",
count_value=_fimi_res.get("articles_with_match", 0),
count_secondary=_fimi_res.get("candidates", 0),
)
logger.info(
"FIMI-Abgleich Incident %d: %d Artikel, %d Kandidaten, "
"%d verbreiten Falschbehauptungen, %d Links",
incident_id, _fimi_res.get("articles", 0),
_fimi_res.get("candidates", 0),
_fimi_res.get("articles_with_match", 0),
_fimi_res.get("stored", 0),
)
except Exception as e:
logger.warning("FIMI-Abgleich fehlgeschlagen fuer Incident %d: %s",
incident_id, e, exc_info=True)
try:
await _pipe_done("fimi", count_value=0, count_secondary=0)
except Exception:
pass
# --- Neueste Entwicklungen (nur Live-Monitoring / adhoc) ---
# Basis ist jetzt das frisch generierte Lagebild (autoritativ, thematisch sauber).
# Zeitstempel und Quellen kommen aus den jüngsten belegenden Artikeln.

Datei anzeigen

@@ -2,12 +2,131 @@
import json
import logging
import re
import urllib.parse
from agents.claude_client import call_claude, ClaudeUsage
from config import CLAUDE_MODEL_FAST
logger = logging.getLogger("osint.researcher")
# Google-News-Locale pro ISO-Sprachcode: (hl, gl). ceid wird daraus gebaut.
_GNEWS_LOCALE = {
"ja": ("ja", "JP"),
"de": ("de", "DE"),
"en": ("en-US", "US"),
"ru": ("ru", "RU"),
"ko": ("ko", "KR"),
"zh": ("zh-CN", "CN"),
"fr": ("fr", "FR"),
"es": ("es", "ES"),
"it": ("it", "IT"),
"ar": ("ar", "EG"),
"he": ("iw", "IL"),
"fa": ("fa", "IR"),
}
def build_news_search_feeds(
keywords_by_lang: dict | list | None,
languages: list[str],
max_keywords: int = 4,
recency_days: int | None = None,
) -> list[dict]:
"""Baut dynamische Google-News-Volltext-Such-Feeds pro Sprache.
Statt nur feste site:-RSS-Feeds zu durchsuchen, erzeugt diese Funktion pro
Sprache einen Google-News-Suchfeed (news.google.com/rss/search?q=...). Damit
erreicht die Pipeline auch Quellen, die in keinem festen Feed stehen
(Security-Vendor-Blogs, Fachportale, Regionalmedien). Der Recall steigt
massiv; die Precision bleibt, weil der nachgelagerte Topic-Filter unveraendert
greift.
Args:
keywords_by_lang: Sprach-Dict {iso: [keyword,...]} aus der Keyword-Extraktion.
languages: ISO-Codes, fuer die ein Suchfeed gebaut werden soll.
max_keywords: wie viele (spezifischste) Keywords in die Such-Query gehen.
recency_days: wenn gesetzt, wird der Google-News-Operator "when:Nd" an die
Query gehaengt — der Feed liefert dann nur Artikel der letzten N Tage.
Fuer "Frische-Suchfeeds", die das aktuelle Bild garantiert einfangen.
Returns:
Liste von Feed-Config-Dicts (kompatibel mit RSSParser._fetch_feed).
"""
if not keywords_by_lang or not isinstance(keywords_by_lang, dict):
return []
feeds: list[dict] = []
seen_queries: set[str] = set()
for lang in languages:
lang_key = (lang or "").lower().strip()
locale = _GNEWS_LOCALE.get(lang_key)
if not locale:
continue
lang_kws = [str(k).strip() for k in (keywords_by_lang.get(lang_key) or []) if str(k).strip()]
en_kws = [str(k).strip() for k in (keywords_by_lang.get("en") or []) if str(k).strip()]
if lang_key == "en":
query_terms = en_kws[:max_keywords]
else:
# Fuer nicht-englische Sprachen: die ersten 2 englischen Keywords
# voranstellen. Haiku ordnet Eigennamen/Akronyme (z.B. "Qilin",
# "Asahi") nach vorne — und die kommen auch in fremdsprachigen
# Artikeln lateinisch vor. Ohne das fehlt beim ersten Refresh (noch
# keine Headlines-Historie) der entscheidende Eigenname in der Query.
# Danach 3 sprach-spezifische Keywords.
query_terms = en_kws[:2] + lang_kws[:3]
# Wenn fuer die Sprache gar keine Keywords da sind: ganz auf en.
if not lang_kws:
query_terms = en_kws[:max_keywords]
# Dedup, Reihenfolge erhalten
seen_terms: set[str] = set()
deduped: list[str] = []
for t in query_terms:
tl = t.lower()
if tl in seen_terms:
continue
seen_terms.add(tl)
deduped.append(t)
if not deduped:
continue
query = " ".join(deduped)
# when:Nd-Operator anhaengen (Google-News-Zeitfilter)
effective_query = query
if recency_days and recency_days > 0:
effective_query = f"{query} when:{recency_days}d"
if not effective_query or effective_query in seen_queries:
continue
seen_queries.add(effective_query)
hl, gl = locale
ceid_lang = hl.split("-")[0]
url = (
"https://news.google.com/rss/search?q="
+ urllib.parse.quote(effective_query)
+ f"&hl={hl}&gl={gl}&ceid={gl}:{ceid_lang}"
)
if recency_days and recency_days > 0:
name = f"Google News Suche ({lang_key}, letzte {recency_days}d): {query}"
domain = f"google-news-search-{lang_key}-recent"
else:
name = f"Google News Suche ({lang_key}): {query}"
domain = f"google-news-search-{lang_key}"
feeds.append({
"name": name,
"url": url,
# Eigene Domain-Gruppe, damit der Domain-Cap die Such-Feeds NICHT mit
# den site:-Google-News-Feeds in einen Topf wirft.
"domain": domain,
"primary_language": lang_key,
"category": "international",
"media_type": "",
})
logger.info("Google-News-Suchfeed (%s): q=%r", lang_key, effective_query)
return feeds
class ResearcherParseError(Exception):
"""Claude hat eine nicht-leere Antwort geliefert, aus der kein JSON extrahiert werden konnte."""
@@ -377,6 +496,24 @@ REGELN:
Antworte NUR mit einem JSON-Array der Kanal-Nummern, z.B.: [1, 3, 5, 12]"""
X_ACCOUNT_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Waehle aus dieser Liste von X-Accounts (Twitter) diejenigen aus, die fuer die Lage relevant sein koennten.
LAGE: {title}
KONTEXT: {description}
X-ACCOUNTS:
{account_list}
REGELN:
- Waehle alle Accounts die thematisch relevant sein koennten
- Lieber einen Account zu viel als zu wenig auswaehlen
- Beachte die Kategorie und Beschreibung jedes Accounts
- Allgemeine OSINT-Accounts sind oft relevant
- Bei geopolitischen Themen: Relevante Laender-/Regions-Accounts waehlen
Antworte NUR mit einem JSON-Array der Account-Nummern, z.B.: [1, 3, 5, 12]"""
class ResearcherAgent:
"""Führt OSINT-Recherchen über Claude CLI WebSearch durch."""
@@ -562,14 +699,27 @@ class ResearcherAgent:
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
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, output_language: str = "Deutsch", output_language_iso: str = "de") -> 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, preferred_sources: list[dict] = None, output_language: str = "Deutsch", output_language_iso: str = "de", research_language_iso: str | None = None) -> tuple[list[dict], ClaudeUsage | None, bool]:
"""Sucht nach Informationen zu einem Vorfall.
Args:
output_language / output_language_iso: Ausgabesprache (Lagebild-Sprache).
research_language_iso: optionaler Override fuer die Sprache, in der gesucht
werden soll. Default = output_language_iso. Bei jp_demo z.B. 'ja',
waehrend output_language_iso 'de' bleibt (Lagebild deutsch, Recherche japanisch).
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.
"""
# research_language defaultet auf output_language. Wenn das aber abweicht
# (z.B. jp_demo: research='ja', output='de'), ueberschreiben wir die
# Sprach-Anweisung im Prompt mit einer eigenen, dual-sprachigen Variante.
research_language_iso = (research_language_iso or output_language_iso or "de").lower()
# Display-Name der Recherche-Sprache fuer Prompts ("Japanese", "Russian", ...)
from services.org_settings import language_display as _lang_display
research_language_display = _lang_display(research_language_iso)
# Bevorzugte Web-Quellen als Prompt-Block (optional)
preferred_sources_block = ""
if preferred_sources:
@@ -589,8 +739,31 @@ class ResearcherAgent:
"aber nicht deine sonstige Recherche.\n"
)
# Asymmetrische Sprach-Auswahl: research_language weicht von output_language ab
# -> eigene Anweisung "primaer in research-language, englische Quellen aus der
# Region auch erlaubt". Sonst die bisherige Logik (primary_only vs international).
asymmetric_lang = research_language_iso != output_language_iso
def _build_lang_instruction(deep: bool) -> str:
if asymmetric_lang:
# jp_demo & Co.: Recherche in Quellsprache + lokale Englisch-Outlets.
return (
f"- Fokus liegt auf {research_language_display}-sprachigen Quellen "
f"(Behoerden, Qualitaetszeitungen, oeffentlich-rechtliche Medien dieser Sprache).\n"
f"- Englischsprachige Outlets mit Fokus auf demselben Sprachraum/Region sind "
f"ebenfalls willkommen (z.B. Japan Times, Nikkei Asia, Kyodo English fuer Japan; "
f"Moscow Times English fuer Russland).\n"
f"- Quellen ausserhalb des Sprachraums NUR, wenn sie exklusive Informationen "
f"ueber die Region liefern (z.B. Reuters/AFP/AP-Berichte aus der Region).\n"
f"- Antworte in der Ausgabesprache {output_language} (das Lagebild wird in "
f"{output_language} angezeigt), aber zitiere die Original-Headlines/Quellen unveraendert."
)
if deep:
return lang_deep_international(output_language) if international else lang_deep_primary_only(output_language)
return lang_international(output_language) if international else lang_primary_only(output_language)
if incident_type == "research":
lang_instruction = lang_deep_international(output_language) if international else lang_deep_primary_only(output_language)
lang_instruction = _build_lang_instruction(deep=True)
# Bestehende Artikel als Kontext für den Prompt aufbereiten
existing_context = ""
if existing_articles:
@@ -611,7 +784,7 @@ class ResearcherAgent:
preferred_sources_block=preferred_sources_block,
)
else:
lang_instruction = lang_international(output_language) if international else lang_primary_only(output_language)
lang_instruction = _build_lang_instruction(deep=False)
# Bestehende Artikel als Kontext: bei Folge-Refreshes findet Claude andere Quellen
existing_context = ""
if existing_articles:
@@ -861,3 +1034,62 @@ class ResearcherAgent:
logger.warning("Telegram-Selektion fehlgeschlagen (%s), nutze alle Kanaele", e)
return channels_metadata, None
async def select_relevant_x_accounts(
self,
title: str,
description: str,
accounts_metadata: list[dict],
) -> tuple[list[dict], ClaudeUsage | None]:
"""Laesst Claude die relevanten X-Accounts fuer eine Lage vorauswaehlen.
Nutzt Haiku (CLAUDE_MODEL_FAST) fuer diese einfache Aufgabe.
Returns:
(ausgewaehlte Accounts, usage) -- Bei Fehler: (alle Accounts, None)
"""
if len(accounts_metadata) <= 10:
logger.info("X-Selektion: Nur %d Accounts, nutze alle", len(accounts_metadata))
return accounts_metadata, None
account_lines = []
for i, acc in enumerate(accounts_metadata, 1):
cat = acc.get("category", "sonstige")
notes = (acc.get("notes") or "")[:100]
account_lines.append(f"{i}. {acc['name']} [{cat}] - {notes}")
prompt = X_ACCOUNT_SELECTION_PROMPT.format(
title=title,
description=description or "Keine weitere Beschreibung",
account_list="\n".join(account_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(
"X-Selektion: Kein JSON in Antwort, nutze alle Accounts. Sample: %s",
_truncate_for_log(result),
)
return accounts_metadata, usage
selected = []
for idx in indices:
if isinstance(idx, int) and 1 <= idx <= len(accounts_metadata):
selected.append(accounts_metadata[idx - 1])
if not selected:
logger.warning("X-Selektion: Keine gueltigen Indizes, nutze alle Accounts")
return accounts_metadata, usage
logger.info(
"X-Selektion: %d von %d Accounts ausgewaehlt",
len(selected), len(accounts_metadata)
)
return selected, usage
except Exception as e:
logger.warning("X-Selektion fehlgeschlagen (%s), nutze alle Accounts", e)
return accounts_metadata, None

Datei anzeigen

@@ -233,7 +233,7 @@ async def translate_articles_batch(
# sie wird auch dann gemacht, wenn der nachgelagerte Volltext-Translator
# deaktiviert ist (Pflicht für korrektes Topic-Filtering, sehr kleine Kosten).
_TOPIC_TRANSLATE_CONTENT_MAX = 240
_TOPIC_TRANSLATE_CONTENT_MAX = 500
def _needs_pretopic_translate(article: dict) -> bool:
@@ -373,20 +373,27 @@ async def translate_articles(
output_lang: str = "de",
batch_size: int = DEFAULT_BATCH_SIZE,
usage_accumulator: UsageAccumulator | None = None,
enabled: bool | 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).
enabled: Pro-Aufruf-Override des globalen TRANSLATOR_ENABLED-Flags. Wenn None,
greift das Modul-Default (config.TRANSLATOR_ENABLED, abgeleitet aus .env).
Der Orchestrator setzt das aus dem Org-Setting 'translator_enabled', damit
jp_demo (Translator zwingend an) trotz global deaktiviertem Flag funktioniert.
"""
if not articles:
return []
if not TRANSLATOR_ENABLED:
is_enabled = TRANSLATOR_ENABLED if enabled is None else bool(enabled)
if not is_enabled:
logger.info(
"Translator deaktiviert (TRANSLATOR_ENABLED=false), %d Artikel uebersprungen",
len(articles),
"Translator deaktiviert (enabled=%s, global TRANSLATOR_ENABLED=%s), %d Artikel uebersprungen",
enabled, TRANSLATOR_ENABLED, len(articles),
)
return []

Datei anzeigen

@@ -97,6 +97,19 @@ TELEGRAM_API_ID = int(os.environ.get("TELEGRAM_API_ID", "0"))
TELEGRAM_API_HASH = os.environ.get("TELEGRAM_API_HASH", "")
TELEGRAM_SESSION_PATH = os.environ.get("TELEGRAM_SESSION_PATH", "/home/claude-dev/.telegram/telegram_session")
# X / Twitter (twscrape) -- siehe feeds/x_parser.py
# Scraper liest Account-Timelines konfigurierter X-Quellen (source_type='x_account').
X_SCRAPER_ENABLED = os.environ.get("X_SCRAPER_ENABLED", "true").lower() == "true"
# twscrape-Account-Store (SQLite). Liegt ausserhalb des Repos.
X_ACCOUNTS_DB_PATH = os.environ.get("X_ACCOUNTS_DB_PATH", "/home/claude-dev/.x-scraper/accounts.db")
# HTTP-Proxy fuer den X-Egress (tinyproxy am RUTX11 ueber WireGuard).
# Leer = direkter Abruf ueber die Server-IP. Bei gesetztem Wert prueft der
# Parser den Proxy vor jedem Lauf und faellt bei Ausfall auf direkt zurueck.
X_PROXY_URL = os.environ.get("X_PROXY_URL", "")
# Max. Posts pro Account-Timeline und Recency-Fenster in Tagen.
X_POST_CAP_PER_ACCOUNT = int(os.environ.get("X_POST_CAP_PER_ACCOUNT", "40"))
X_RECENCY_DAYS = int(os.environ.get("X_RECENCY_DAYS", "14"))
# Health-Check (genutzt von services/source_health.py)
HEALTH_CHECK_USER_AGENT = os.environ.get(
"HEALTH_CHECK_USER_AGENT",

Datei anzeigen

@@ -355,6 +355,41 @@ CREATE TABLE IF NOT EXISTS organization_settings (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(organization_id, key)
);
-- FIMI / Counter-Disinformation: importierter Falschbehauptungs-Bestand
-- (EUvsDisinfo). Read-only Referenz, befuellt per scripts/import_fimi_claims.py.
-- Die id entspricht der Vigil-claim.id (stabil fuer Re-Sync via UPSERT).
CREATE TABLE IF NOT EXISTS fimi_claims (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
text_normalized TEXT,
language TEXT,
verdict TEXT NOT NULL DEFAULT 'false',
verdict_summary TEXT,
source_ref TEXT,
case_url TEXT,
embedding BLOB,
first_seen_at TIMESTAMP,
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_fimi_claims_source_ref ON fimi_claims(source_ref);
-- FIMI: Treffer zwischen Monitor-Artikeln und Falschbehauptungen.
-- Bewusst KEIN harter FK auf fimi_claims, damit ein Claim-Re-Sync die
-- bestehenden Treffer nicht kaskadierend loescht.
CREATE TABLE IF NOT EXISTS article_fimi_matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
fimi_claim_id INTEGER NOT NULL,
score REAL NOT NULL,
role TEXT DEFAULT 'match',
matched_text TEXT,
matched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
tenant_id INTEGER REFERENCES organizations(id),
UNIQUE(article_id, fimi_claim_id)
);
CREATE INDEX IF NOT EXISTS idx_afm_article ON article_fimi_matches(article_id);
CREATE INDEX IF NOT EXISTS idx_afm_claim ON article_fimi_matches(fimi_claim_id);
"""
@@ -403,6 +438,11 @@ async def init_db():
await db.commit()
logger.info("Migration: include_telegram zu incidents hinzugefuegt")
if "include_x" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN include_x INTEGER DEFAULT 0")
await db.commit()
logger.info("Migration: include_x zu incidents hinzugefuegt")
if "telegram_categories" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN telegram_categories TEXT DEFAULT NULL")
await db.commit()
@@ -429,6 +469,16 @@ async def init_db():
await db.commit()
logger.info("Migration: latest_developments zu incidents hinzugefuegt")
if "public_mood" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN public_mood TEXT")
await db.commit()
logger.info("Migration: public_mood zu incidents hinzugefuegt")
if "public_mood_updated_at" not in columns:
await db.execute("ALTER TABLE incidents ADD COLUMN public_mood_updated_at TIMESTAMP")
await db.commit()
logger.info("Migration: public_mood_updated_at zu incidents hinzugefuegt")
# Migration: Tabelle podcast_transcripts (URL-Cache fuer Transkripte)
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='podcast_transcripts'"
@@ -591,6 +641,14 @@ async def init_db():
await db.execute("ALTER TABLE articles ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
await db.commit()
# Migration: FIMI-Match-Marker fuer articles (wann zuletzt gegen den
# Falschbehauptungs-Bestand geprueft; verhindert Re-Encoding bereits
# gepruefter Artikel bei jedem Refresh)
if "fimi_checked_at" not in art_columns:
await db.execute("ALTER TABLE articles ADD COLUMN fimi_checked_at TIMESTAMP")
await db.commit()
logger.info("Migration: fimi_checked_at zu articles hinzugefuegt")
# Migration: tenant_id fuer fact_checks
cursor = await db.execute("PRAGMA table_info(fact_checks)")
fc_columns = [row[1] for row in await cursor.fetchall()]

Datei anzeigen

@@ -1,4 +1,5 @@
"""Async E-Mail-Versand via SMTP."""
import asyncio
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
@@ -17,6 +18,12 @@ from config import (
logger = logging.getLogger("osint.email")
# Die IONOS-SMTP-Farm (smtp.ionos.de) besteht aus ~10 Backends im DNS-Round-Robin;
# einzelne Backends koennen tot/ueberlastet sein (beobachtet 2026-07-10). Jeder
# Versuch oeffnet eine neue Verbindung und wuerfelt damit ein neues Backend.
MAIL_VERSUCHE = 3
MAIL_TIMEOUT_S = 20
async def send_email(to_email: str, subject: str, html_body: str) -> bool:
"""Sendet eine HTML-E-Mail.
@@ -38,17 +45,30 @@ async def send_email(to_email: str, subject: str, html_body: str) -> bool:
msg.attach(MIMEText(text_content, "plain", "utf-8"))
msg.attach(MIMEText(html_body, "html", "utf-8"))
try:
await aiosmtplib.send(
msg,
hostname=SMTP_HOST,
port=SMTP_PORT,
username=SMTP_USER if SMTP_USER else None,
password=SMTP_PASSWORD if SMTP_PASSWORD else None,
start_tls=SMTP_USE_TLS,
)
logger.info(f"E-Mail gesendet an {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"E-Mail-Versand fehlgeschlagen an {to_email}: {e}")
return False
letzter_fehler = None
for versuch in range(1, MAIL_VERSUCHE + 1):
try:
await aiosmtplib.send(
msg,
hostname=SMTP_HOST,
port=SMTP_PORT,
username=SMTP_USER if SMTP_USER else None,
password=SMTP_PASSWORD if SMTP_PASSWORD else None,
start_tls=SMTP_USE_TLS,
timeout=MAIL_TIMEOUT_S,
)
logger.info(f"E-Mail gesendet an {to_email}: {subject}")
return True
except Exception as e:
letzter_fehler = e
logger.warning(
f"E-Mail-Versand Versuch {versuch}/{MAIL_VERSUCHE} an {to_email} fehlgeschlagen: {e}"
)
if versuch < MAIL_VERSUCHE:
await asyncio.sleep(1.5)
logger.error(
f"E-Mail-Versand endgueltig fehlgeschlagen an {to_email} "
f"nach {MAIL_VERSUCHE} Versuchen: {letzter_fehler}"
)
return False

Datei anzeigen

@@ -6,6 +6,11 @@ import httpx
from datetime import datetime, timezone
from config import TIMEZONE, MAX_ARTICLES_PER_DOMAIN_RSS
from source_rules import _extract_domain
# Cap fuer dynamische Google-News-Suchfeeds — hoeher als der normale Domain-Cap,
# weil ein Suchfeed gezielt fuer breiten Recall gebaut wird. Topic-Filter
# entscheidet danach ueber die Precision.
MAX_ARTICLES_PER_DOMAIN_RSS_SEARCH = 25
from feeds.transcript_extractors._common import html_to_text
from services.post_refresh_qc import normalize_german_umlauts
from agents.researcher import keywords_for_language, flatten_keywords
@@ -171,6 +176,11 @@ class RSSParser:
name = feed_config["name"]
url = feed_config["url"]
articles = []
# Google-News-Feeds (Site-Search ODER Volltext-Suche) buendeln Artikel
# vieler echter Publisher. Pro Item steht der echte Publisher im
# <source>-Tag — den nutzen wir als source-Name, sonst zaehlt der
# Faktencheck 25 Artikel als "eine Quelle".
_is_google_news = "news.google.com" in (url or "")
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
@@ -208,23 +218,73 @@ class RSSParser:
if match_count >= min_matches:
published = None
published_dt = None
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).astimezone(TIMEZONE).isoformat()
published_dt = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
published = published_dt.astimezone(TIMEZONE).isoformat()
except (TypeError, ValueError):
pass
# Relevanz-Score: Anteil der gematchten Suchworte (0.0-1.0)
relevance_score = match_count / len(search_words) if search_words else 0.0
# Aktualitaets-Bonus/Malus: frische Artikel sollen den
# Domain-Cap (sortiert nach relevance_score) ueberleben und
# nicht von Monate alten verdraengt werden. Damit faengt die
# Pipeline das aktuelle Bild ein. Nur adhoc-Pfad — research
# nutzt diesen Code nicht.
if published_dt is not None:
age_days = (datetime.now(timezone.utc) - published_dt).days
if age_days <= 3:
relevance_score += 0.35
elif age_days <= 14:
relevance_score += 0.20
elif age_days <= 60:
relevance_score += 0.05
elif age_days > 365:
relevance_score -= 0.30
elif age_days > 180:
relevance_score -= 0.15
# Bei Google-News-Feeds: echten Publisher aus <source>-Tag holen
article_source = name
if _is_google_news:
src_obj = entry.get("source")
src_title = ""
if isinstance(src_obj, dict):
src_title = (src_obj.get("title") or "").strip()
elif src_obj:
src_title = str(getattr(src_obj, "title", "") or "").strip()
if src_title:
article_source = src_title
else:
# Google-News-Titel enden oft mit " - Publishername"
if " - " in title:
article_source = title.rsplit(" - ", 1)[-1].strip() or name
articles.append({
"headline": title,
"headline_de": title if self._is_german(title) else None,
"source": name,
"source": article_source,
"source_url": entry.get("link", ""),
# Die Quell-Domain aus der DB (z.B. "mod.go.jp"), nicht aus
# der URL — relevant für Google-News-RSS-Quellen, deren URLs
# alle "news.google.com" sind, obwohl sie für 14 verschiedene
# Behörden/Zeitungen stehen. Wird vom Domain-Cap genutzt.
"source_domain": feed_config.get("domain") or "",
# media_type aus dem Feed-Eintrag (z.B. "forum" fuer 5ch/Hatena/Note)
# damit downstream Pipeline-Schritte (Faktencheck, Geoparsing,
# Topic-Filter, Stimmungs-Kachel) Foren-Quellen erkennen koennen.
"media_type": feed_config.get("media_type") or "",
"content_original": summary[:1000] if summary else None,
"content_de": summary[:1000] if summary and self._is_german(summary) else None,
"language": "de" if self._is_german(title) else "en",
# Sprache primär aus der Quell-Konfiguration übernehmen
# (z.B. "ja" für Asahi Shimbun, "ru" für TASS). Nur wenn
# die Quelle kein primary_language gesetzt hat, auf die
# alte de/en-Heuristik zurückfallen. Sonst landen
# CJK/kyrillische Headlines fälschlich als language="en"
# und verlieren Pre-Topic-Übersetzung + Translator-Pfad.
"language": feed_config.get("primary_language") or ("de" if self._is_german(title) else "en"),
"published_at": published,
"relevance_score": relevance_score,
})
@@ -243,10 +303,16 @@ class RSSParser:
if not articles:
return articles
# Nach Domain gruppieren
# Nach Domain gruppieren. Bevorzugt source_domain (aus dem Feed-Eintrag,
# z.B. "mod.go.jp" bei einer Google-News-Site-Search-RSS-Quelle), fällt
# erst dann auf die URL-Domain zurück. Sonst landen alle Google-News-
# Feeds (14 ja-Quellen) im selben "news.google.com"-Topf und werden
# vom Cap auf 10 begrenzt.
by_domain: dict[str, list[dict]] = {}
for article in articles:
domain = _extract_domain(article.get("source_url", ""))
domain = (article.get("source_domain") or "").strip().lower()
if not domain:
domain = _extract_domain(article.get("source_url", ""))
if not domain:
domain = "__unknown__"
by_domain.setdefault(domain, []).append(article)
@@ -255,10 +321,15 @@ class RSSParser:
for domain, domain_articles in by_domain.items():
# Nach Relevanz sortieren (beste zuerst)
domain_articles.sort(key=lambda a: a.get("relevance_score", 0), reverse=True)
kept = domain_articles[:MAX_ARTICLES_PER_DOMAIN_RSS]
if len(domain_articles) > MAX_ARTICLES_PER_DOMAIN_RSS:
# Dynamische Google-News-Suchfeeds ("google-news-search-<lang>") sind
# der Recall-Treiber und bekommen einen hoeheren Cap als feste Feeds.
cap = (MAX_ARTICLES_PER_DOMAIN_RSS_SEARCH
if domain.startswith("google-news-search-")
else MAX_ARTICLES_PER_DOMAIN_RSS)
kept = domain_articles[:cap]
if len(domain_articles) > cap:
logger.info(
f"Domain-Cap: {domain} von {len(domain_articles)} auf {MAX_ARTICLES_PER_DOMAIN_RSS} Artikel begrenzt"
f"Domain-Cap: {domain} von {len(domain_articles)} auf {cap} Artikel begrenzt"
)
capped.extend(kept)

Datei anzeigen

@@ -104,7 +104,7 @@ class TelegramParser:
search_words = [w.lower() for w in search_words]
else:
search_words = fallback_words or []
tasks.append(self._fetch_channel(client, channel_id, search_words))
tasks.append(self._fetch_channel(client, channel_id, search_words, channel_lang=channel_lang))
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -150,7 +150,7 @@ class TelegramParser:
return []
async def _fetch_channel(self, client, channel_id: str, search_words: list[str],
limit: int = 50) -> list[dict]:
limit: int = 50, channel_lang: str | None = None) -> list[dict]:
"""Letzte N Nachrichten eines Kanals abrufen und nach Keywords filtern."""
articles = []
try:
@@ -217,7 +217,10 @@ class TelegramParser:
"source_url": source_url,
"content_original": content[:2000],
"content_de": content[:2000] if self._is_german(content) else None,
"language": "de" if self._is_german(content) else "en",
# Sprache primär aus der Kanal-Konfiguration übernehmen
# (z.B. "ru" für russische Kanäle). Sonst Fallback auf die
# de/en-Heuristik. Symmetrisch zur RSS-Pfad-Logik.
"language": channel_lang or ("de" if self._is_german(content) else "en"),
"published_at": published,
"relevance_score": relevance_score,
})

320
src/feeds/x_parser.py Normale Datei
Datei anzeigen

@@ -0,0 +1,320 @@
"""X (Twitter) Parser: Liest Posts aus konfigurierten X-Accounts via twscrape.
Egress laeuft -- wenn X_PROXY_URL gesetzt -- ueber den HTTP-Proxy am RUTX11
(Mobilfunk-IP). Faellt der Proxy aus, wird direkt ueber die Server-IP
abgerufen (Fallback). Gibt Artikel-Dicts im RSS-/Telegram-kompatiblen Format
zurueck.
"""
import asyncio
import logging
import os
from datetime import datetime, timezone, timedelta
import httpx
from config import (
TIMEZONE, X_ACCOUNTS_DB_PATH, X_PROXY_URL,
X_POST_CAP_PER_ACCOUNT, X_RECENCY_DAYS, X_SCRAPER_ENABLED,
)
logger = logging.getLogger("osint.x")
# Stoppwoerter (gleich wie RSS-/Telegram-Parser)
STOP_WORDS = {
"und", "oder", "der", "die", "das", "ein", "eine", "in", "im", "am", "an",
"auf", "fuer", "mit", "von", "zu", "zum", "zur", "bei", "nach", "vor",
"ueber", "unter", "ist", "sind", "hat", "the", "and", "for", "with", "from",
}
def _normalize_handle(raw: str) -> str:
"""X-Handle aus URL-/@-Form auf den nackten Benutzernamen normalisieren."""
h = (raw or "").strip()
for prefix in ("https://", "http://"):
if h.startswith(prefix):
h = h[len(prefix):]
for prefix in ("www.", "x.com/", "twitter.com/", "nitter.net/"):
if h.startswith(prefix):
h = h[len(prefix):]
h = h.lstrip("@").strip("/")
# Pfad-/Query-Reste abschneiden (z.B. handle/status/123 oder handle?lang=de)
for sep in ("/", "?"):
if sep in h:
h = h.split(sep)[0]
return h
class XParser:
"""Durchsucht konfigurierte X-Accounts nach relevanten Posts."""
async def _resolve_proxy(self) -> tuple[str | None, str | None]:
"""Proxy-Strategie aufloesen.
Returns (proxy_url, egress_ip):
- X_PROXY_URL leer -> (None, None): direkter Abruf ueber Server-IP.
- X_PROXY_URL gesetzt und erreichbar -> (proxy, egress_ip).
- X_PROXY_URL gesetzt aber tot -> (None, None): Fallback direkt + Warnung.
"""
if not X_PROXY_URL:
return None, None
try:
async with httpx.AsyncClient(proxy=X_PROXY_URL, timeout=8.0) as client:
resp = await client.get("https://api.ipify.org")
resp.raise_for_status()
egress_ip = resp.text.strip()
logger.info("X-Egress ueber Proxy %s aktiv (IP: %s)", X_PROXY_URL, egress_ip)
return X_PROXY_URL, egress_ip
except Exception as e:
logger.warning(
"X-Proxy %s nicht erreichbar (%s) -- Fallback auf direkte Server-IP",
X_PROXY_URL, e,
)
return None, None
async def _get_api(self, proxy: str | None):
"""twscrape-API-Objekt erstellen.
Gibt None zurueck wenn der Account-Store fehlt oder keine
nutzbaren Accounts vorhanden sind.
"""
if not os.path.exists(X_ACCOUNTS_DB_PATH):
logger.error("X-Account-Store nicht gefunden: %s", X_ACCOUNTS_DB_PATH)
return None
try:
from twscrape import API
except ImportError:
logger.error("twscrape nicht installiert: pip install twscrape")
return None
try:
api = API(X_ACCOUNTS_DB_PATH, proxy=proxy)
# Account-Pool pruefen -- ohne aktive Accounts liefert twscrape nichts
try:
accounts = await api.pool.get_all()
active = [a for a in accounts if getattr(a, "active", True)]
if not accounts:
logger.error("X-Account-Pool leer -- keine Accounts konfiguriert")
return None
if not active:
logger.error(
"X-Account-Pool: alle %d Accounts inaktiv/gesperrt", len(accounts)
)
return None
logger.info("X-Account-Pool: %d/%d Accounts aktiv", len(active), len(accounts))
except Exception as e:
# Pool-Status nicht ermittelbar -- trotzdem weiterversuchen
logger.debug("X-Account-Pool-Status nicht ermittelbar: %s", e)
return api
except Exception as e:
logger.error("X-API-Initialisierung fehlgeschlagen: %s", e)
return None
async def search_accounts(self, search_term: str, tenant_id: int = None,
keywords: dict | list = None,
account_ids: list[int] = None) -> list[dict]:
"""Liest Posts aus konfigurierten X-Accounts.
Args:
keywords: Sprach-Dict {iso_lang: [keyword,...]} oder flache Liste.
Match nutzt pro Account die "en"-Universalbegriffe + die
Keywords der Account-Sprache (primary_language aus sources).
Gibt Artikel-Dicts zurueck (kompatibel mit RSS-/Telegram-Format).
"""
if not X_SCRAPER_ENABLED:
logger.info("X-Scraper deaktiviert (X_SCRAPER_ENABLED=false)")
return []
from agents.researcher import keywords_for_language
accounts = await self._get_x_accounts(tenant_id, account_ids=account_ids)
if not accounts:
logger.info("Keine X-Accounts konfiguriert")
return []
proxy, _egress_ip = await self._resolve_proxy()
api = await self._get_api(proxy)
if not api:
logger.warning("X-API nicht verfuegbar, ueberspringe X-Pipeline")
return []
# Fallback-Suchwoerter wenn keine Keywords da sind
fallback_words: list[str] | None = None
if not keywords:
fallback_words = [
w for w in search_term.lower().split()
if w not in STOP_WORDS and len(w) >= 3
]
if not fallback_words:
fallback_words = search_term.lower().split()[:2]
cutoff = datetime.now(timezone.utc) - timedelta(days=X_RECENCY_DAYS)
# Accounts parallel abrufen
tasks = []
for acc in accounts:
handle = _normalize_handle(acc["url"] or acc["name"])
acc_lang = acc.get("primary_language")
if keywords:
search_words = [w.lower() for w in keywords_for_language(keywords, acc_lang)]
else:
search_words = fallback_words or []
tasks.append(self._fetch_account(api, handle, search_words, cutoff, acc_lang))
results = await asyncio.gather(*tasks, return_exceptions=True)
all_articles = []
for i, result in enumerate(results):
if isinstance(result, Exception):
logger.warning("X-Account %s: %s", accounts[i]["name"], result)
continue
all_articles.extend(result)
logger.info("X: %d relevante Posts aus %d Accounts", len(all_articles), len(accounts))
return all_articles
async def _get_x_accounts(self, tenant_id: int = None,
account_ids: list[int] = None) -> list[dict]:
"""Laedt X-Accounts aus der sources-Tabelle."""
try:
from database import get_db
db = await get_db()
try:
if account_ids and len(account_ids) > 0:
placeholders = ",".join("?" for _ in account_ids)
cursor = await db.execute(
f"""SELECT id, name, url, category, notes, primary_language FROM sources
WHERE source_type = 'x_account'
AND status = 'active'
AND id IN ({placeholders})""",
tuple(account_ids),
)
else:
cursor = await db.execute(
"""SELECT id, name, url, category, notes, primary_language FROM sources
WHERE source_type = 'x_account'
AND status = 'active'
AND (tenant_id IS NULL OR tenant_id = ?)""",
(tenant_id,),
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
finally:
await db.close()
except Exception as e:
logger.error("Fehler beim Laden der X-Accounts: %s", e)
return []
async def _fetch_account(self, api, handle: str, search_words: list[str],
cutoff: datetime, account_lang: str | None = None) -> list[dict]:
"""Letzte Posts eines X-Accounts abrufen und nach Keywords filtern."""
from twscrape import gather
articles: list[dict] = []
if not handle:
return articles
try:
user = await api.user_by_login(handle)
if not user:
logger.warning("X-Account @%s nicht gefunden", handle)
return articles
tweets = await gather(api.user_tweets(user.id, limit=X_POST_CAP_PER_ACCOUNT))
for tw in tweets:
# Reine Retweets ueberspringen (Original wird ohnehin erfasst)
if getattr(tw, "retweetedTweet", None) is not None:
continue
text = getattr(tw, "rawContent", None) or ""
# Quote-Tweet: zitierten Text anhaengen, damit Kontext erhalten bleibt
quoted = getattr(tw, "quotedTweet", None)
if quoted is not None:
q_text = getattr(quoted, "rawContent", "") or ""
if q_text:
text = "%s\n\n[Zitiert] %s" % (text, q_text)
if not text.strip():
continue
# Recency-Fenster
tw_date = getattr(tw, "date", None)
if tw_date is not None:
try:
if tw_date < cutoff:
continue
except TypeError:
pass
# Keyword-Matching (lockerer als RSS: 1 Match reicht,
# da Accounts bereits thematisch vorselektiert sind)
text_lower = text.lower()
match_count = sum(1 for w in search_words if w in text_lower)
if search_words and match_count < 1:
continue
lines = text.strip().split("\n")
headline = (lines[0][:200] if lines else text[:200]).strip()
published = None
if tw_date is not None:
try:
published = tw_date.astimezone(TIMEZONE).isoformat()
except Exception:
published = tw_date.isoformat()
source_url = getattr(tw, "url", None) or \
"https://x.com/%s/status/%s" % (handle, getattr(tw, "id", ""))
tw_lang = getattr(tw, "lang", None)
language = account_lang \
or (tw_lang if tw_lang and tw_lang != "und" else None) \
or ("de" if self._is_german(text) else "en")
relevance_score = (match_count / len(search_words)) if search_words else 0.0
articles.append({
"headline": headline,
"headline_de": headline if self._is_german(headline) else None,
"source": "X: @%s" % handle,
"source_url": source_url,
"content_original": text[:2000],
"content_de": text[:2000] if self._is_german(text) else None,
"language": language,
"published_at": published,
"relevance_score": relevance_score,
})
except Exception as e:
logger.warning("X-Account @%s: %s", handle, e)
return articles
async def validate_account(self, handle: str) -> dict | None:
"""Prueft ob ein X-Account erreichbar ist und gibt Account-Info zurueck."""
handle = _normalize_handle(handle)
if not handle:
return None
proxy, _ = await self._resolve_proxy()
api = await self._get_api(proxy)
if not api:
return None
try:
user = await api.user_by_login(handle)
if not user:
return None
return {
"valid": True,
"name": getattr(user, "displayname", None) or handle,
"username": getattr(user, "username", handle),
"description": getattr(user, "rawDescription", "") or "",
"subscribers": getattr(user, "followersCount", None),
}
except Exception as e:
logger.warning("X-Account-Validierung fehlgeschlagen fuer @%s: %s", handle, e)
return None
def _is_german(self, text: str) -> bool:
"""Einfache Heuristik ob ein Text deutsch ist."""
german_words = {"der", "die", "das", "und", "ist", "von", "mit", "fuer", "auf", "ein",
"eine", "den", "dem", "des", "sich", "wird", "nach", "bei", "auch",
"ueber", "wie", "aus", "hat", "zum", "zur", "als", "noch", "mehr",
"nicht", "aber", "oder", "sind", "vor", "einem", "einer", "wurde"}
words = set(text.lower().split())
return len(words & german_words) >= 2

Datei anzeigen

@@ -246,7 +246,14 @@ async def cleanup_expired():
)
logger.info(f"Lage {incident['id']} archiviert (Aufbewahrung abgelaufen)")
# Verwaiste running-Einträge bereinigen (> 15 Minuten ohne Abschluss)
# Verwaiste running-Einträge bereinigen.
# Pruefen auf Pipeline-Fortschritt: legitime Long-Runner (z.B. Translator
# nach summary fuer jp_demo mit 200+ Artikeln ~20 Min) duerfen nicht
# vorzeitig gekillt werden. Ein Refresh gilt als verwaist, wenn entweder
# (a) seit ORPHAN_IDLE_LIMIT Min kein Pipeline-Step Fortschritt zeigte,
# oder (b) das harte Limit ORPHAN_HARD_LIMIT Min ueberschritten wurde.
ORPHAN_IDLE_LIMIT = 60
ORPHAN_HARD_LIMIT = 120
cursor = await db.execute(
"SELECT id, incident_id, started_at FROM refresh_log WHERE status = 'running'"
)
@@ -258,12 +265,46 @@ async def cleanup_expired():
else:
started = started.astimezone(TIMEZONE)
age_minutes = (now - started).total_seconds() / 60
if age_minutes >= 15:
if age_minutes < ORPHAN_IDLE_LIMIT:
continue
# Letzter Pipeline-Step-Fortschritt (Start ODER Ende)
prog_cursor = await db.execute(
"""SELECT MAX(COALESCE(completed_at, started_at)) AS last_activity
FROM refresh_pipeline_steps WHERE refresh_log_id = ?""",
(orphan["id"],),
)
prog_row = await prog_cursor.fetchone()
last_activity_str = prog_row["last_activity"] if prog_row else None
is_orphan = False
reason = None
if age_minutes >= ORPHAN_HARD_LIMIT:
is_orphan = True
reason = f"Verwaist (>{int(age_minutes)} Min, hartes Limit {ORPHAN_HARD_LIMIT} Min)"
elif last_activity_str:
last_activity = datetime.fromisoformat(last_activity_str)
if last_activity.tzinfo is None:
last_activity = last_activity.replace(tzinfo=TIMEZONE)
else:
last_activity = last_activity.astimezone(TIMEZONE)
idle_minutes = (now - last_activity).total_seconds() / 60
if idle_minutes >= ORPHAN_IDLE_LIMIT:
is_orphan = True
reason = (
f"Verwaist (kein Pipeline-Fortschritt seit {int(idle_minutes)} Min, "
f"gesamt {int(age_minutes)} Min)"
)
else:
is_orphan = True
reason = f"Verwaist (keine Pipeline-Schritte nach {int(age_minutes)} Min)"
if is_orphan:
await db.execute(
"UPDATE refresh_log SET status = 'error', completed_at = ?, error_message = ? WHERE id = ?",
(now.strftime('%Y-%m-%d %H:%M:%S'), f"Verwaist (>{int(age_minutes)} Min ohne Abschluss, automatisch bereinigt)", orphan["id"]),
(now.strftime('%Y-%m-%d %H:%M:%S'), reason, orphan["id"]),
)
logger.warning(f"Verwaisten Refresh #{orphan['id']} für Lage {orphan['incident_id']} bereinigt ({int(age_minutes)} Min)")
logger.warning(f"Verwaisten Refresh #{orphan['id']} fuer Lage {orphan['incident_id']} bereinigt: {reason}")
# Alte Notifications bereinigen (> 7 Tage)
await db.execute("DELETE FROM notifications WHERE created_at < datetime('now', '-7 days')")

Datei anzeigen

@@ -57,6 +57,7 @@ class IncidentCreate(BaseModel):
retention_days: int = Field(default=0, ge=0, le=999)
international_sources: bool = False
include_telegram: bool = False
include_x: bool = False
visibility: str = Field(default="public", pattern="^(public|private)$")
@@ -71,6 +72,7 @@ class IncidentUpdate(BaseModel):
retention_days: Optional[int] = Field(default=None, ge=0, le=999)
international_sources: Optional[bool] = None
include_telegram: Optional[bool] = None
include_x: Optional[bool] = None
visibility: Optional[str] = Field(default=None, pattern="^(public|private)$")
@@ -98,8 +100,11 @@ class IncidentResponse(BaseModel):
visibility: str = "public"
summary: Optional[str]
latest_developments: Optional[str] = None
public_mood: Optional[str] = None
public_mood_updated_at: Optional[str] = None
international_sources: bool = True
include_telegram: bool = False
include_x: bool = False
created_by: int
created_by_username: str = ""
created_at: str
@@ -128,6 +133,7 @@ class IncidentListItem(BaseModel):
visibility: str = "public"
international_sources: bool = True
include_telegram: bool = False
include_x: bool = False
created_by: int
created_by_username: str = ""
created_at: str
@@ -140,8 +146,8 @@ class IncidentListItem(BaseModel):
# Sources (Quellenverwaltung)
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document)$"
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$"
SOURCE_TYPE_PATTERN = "^(rss_feed|web_source|excluded|telegram_channel|podcast_feed|pdf_document|x_account)$"
SOURCE_CATEGORY_PATTERN = "^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige|x)$"
SOURCE_STATUS_PATTERN = "^(active|inactive)$"
class SourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)

Datei anzeigen

@@ -7,6 +7,7 @@ import re
import uuid
from collections import defaultdict
from datetime import datetime
from html import escape as _html_escape
from pathlib import Path
import pikepdf
@@ -153,6 +154,66 @@ def _markdown_to_html(text: str) -> str:
return '\n'.join(result)
def _parse_developments_for_export(text: str) -> list[tuple[str, str]]:
"""Parst die 'Neuesten Entwicklungen' (latest_developments) fuer den Export.
Eingabeformat je Eintrag: '- [DD.MM. HH:MM] Text {Quelle|URL, ...}'.
Liefert (datum_label, body) je Eintrag in gespeicherter Reihenfolge.
Quellen-Klammern und [N]-Zitate werden entfernt — der Export zeigt bewusst
KEINE Links. Das gespeicherte Format enthaelt kein Jahr; fehlt es, wird das
aktuelle Jahr ergaenzt (Live-Monitoring-Berichte sind tagesaktuell).
"""
if not text:
return []
year2 = datetime.now(TIMEZONE).strftime("%y")
bullet_re = re.compile(
r"^\s*(?:[-*•]\s*)?\[\s*(\d{1,2})\.(\d{1,2})\.?(?:(\d{2,4}))?\s+(\d{1,2}:\d{2})\s*\]\s*(.+?)\s*$"
)
trailing_braces = re.compile(r"\s*\{[^{}]*\}\s*\.?\s*$")
citation_re = re.compile(r"\s*\[\d{1,5}[a-z]?\]")
result: list[tuple[str, str]] = []
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
m = bullet_re.match(line)
if not m:
continue
day, month, year, time = m.group(1), m.group(2), m.group(3), m.group(4)
body = m.group(5).strip()
# Quellen-Klammer am Ende und Inline-[N]-Zitate entfernen (keine Links)
body = trailing_braces.sub("", body).strip()
body = citation_re.sub("", body).strip()
if not body:
continue
yy = year[-2:] if year else year2
label = f"{int(day):02d}.{int(month):02d}.{yy}, {time} Uhr"
result.append((label, body))
return result
def _format_latest_developments_html(text: str) -> str:
"""Rendert die 'Neuesten Entwicklungen' als HTML-Block fuer den PDF-Export.
Pro Eintrag: Datum/Uhrzeit-Zeile, darunter (eigener Absatz) der Meldungstext.
Keine Quellen-Links. Faellt bei nicht-parsebarem Text auf _markdown_to_html zurueck.
"""
pairs = _parse_developments_for_export(text)
if not pairs:
return _markdown_to_html(text)
blocks = []
for label, body in pairs:
body_html = _html_escape(body)
body_html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', body_html)
blocks.append(
'<div class="dev-entry">'
f'<div class="dev-entry-date">{_html_escape(label)}</div>'
f'<div class="dev-entry-body">{body_html}</div>'
'</div>'
)
return "\n".join(blocks)
def _truncate_lagebild(summary_text: str, max_chars: int = 4000) -> str:
"""Lagebild für den Lagebericht auf die Zusammenfassung kürzen.
@@ -462,8 +523,12 @@ def _build_export_metadata(
organization_name: str | None,
top_locations: list[str] | None,
snapshot_count: int = 0,
include_branding: bool = True,
) -> dict:
"""Einheitlicher Metadaten-Dict fuer PDF (HTML-Meta-Tags) und DOCX (core_properties)."""
"""Einheitlicher Metadaten-Dict fuer PDF (HTML-Meta-Tags) und DOCX (core_properties).
include_branding=False neutralisiert alle AegisSight-Firmenbezeichnungen (White-Label-Export).
"""
is_research = incident.get("type") == "research"
type_label = "Hintergrundrecherche" if is_research else "Live-Monitoring"
category = "OSINT-Hintergrundrecherche" if is_research else "OSINT-Lagebericht"
@@ -475,6 +540,10 @@ def _build_export_metadata(
subject = (incident.get("description") or "").strip()
if not subject:
subject = f"{type_label} zu: {title_raw}"
# DOCX-Core-Property "subject" erzwingt ein 255-Zeichen-Limit; laengere
# Beschreibungen wuerden den Word-Export sonst mit ValueError abbrechen.
if len(subject) > 255:
subject = subject[:255]
# Keywords sammeln (Reihenfolge relevant für Anzeige, Dedup mit dict.fromkeys)
keywords: list[str] = ["OSINT", type_label]
@@ -546,23 +615,37 @@ def _build_export_metadata(
comments_lines.append("Orte: " + ", ".join(top_locations[:5]))
comments = "\n".join(comments_lines)
publisher = organization_name or "AegisSight"
identifier = f"urn:aegissight:incident:{incident.get('id', '0')}:{now.strftime('%Y%m%dT%H%M%S')}"
rights = (
"Vertrauliche Lageanalyse — AegisSight Monitor. "
"Weitergabe nur an autorisierte Empfänger."
)
# Branding-abhaengige Felder: bei include_branding=False neutralisiert (White-Label-Export)
if include_branding:
publisher = organization_name or "AegisSight"
author = creator or "AegisSight Monitor"
creator_app = "AegisSight Monitor"
producer = "WeasyPrint + AegisSight Monitor"
urn_ns = "aegissight"
rights = (
"Vertrauliche Lageanalyse — AegisSight Monitor. "
"Weitergabe nur an autorisierte Empfänger."
)
else:
publisher = organization_name or ""
author = creator or "Unbekannt"
creator_app = ""
producer = "WeasyPrint"
urn_ns = "report"
rights = "Vertrauliche Lageanalyse. Weitergabe nur an autorisierte Empfänger."
identifier = f"urn:{urn_ns}:incident:{incident.get('id', '0')}:{now.strftime('%Y%m%dT%H%M%S')}"
return {
"title": title,
"author": creator or "AegisSight Monitor",
"author": author,
"subject": subject,
"keywords": unique_keywords,
"keywords_comma": ", ".join(unique_keywords),
"keywords_semicolon": "; ".join(unique_keywords),
"category": category,
"comments": comments,
"creator_app": "AegisSight Monitor",
"creator_app": creator_app,
"producer": producer,
"language": "de-DE",
"created": created,
"modified": modified,
@@ -634,7 +717,7 @@ def _enrich_pdf_metadata(pdf_bytes: bytes, meta: dict) -> bytes:
# PDF Namespace
xmp["pdf:Keywords"] = meta.get("keywords_comma", "")
xmp["pdf:Producer"] = "WeasyPrint + AegisSight Monitor"
xmp["pdf:Producer"] = meta.get("producer", "WeasyPrint + AegisSight Monitor")
# XMP Namespace
xmp["xmp:CreatorTool"] = meta.get("creator_app", "AegisSight Monitor")
@@ -681,6 +764,7 @@ async def generate_pdf(
organization_name: str | None = None,
top_locations: list[str] | None = None,
snapshot_count: int = 0,
include_branding: bool = True,
) -> bytes:
"""PDF-Report via WeasyPrint generieren."""
# Sections aus scope ableiten wenn nicht explizit angegeben
@@ -692,27 +776,39 @@ async def generate_pdf(
else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
# Zusammenfassungs-Quelle bestimmen:
# - Research: ZUSAMMENFASSUNG/UEBERBLICK aus dem Bericht extrahieren.
# - Live-Monitoring (adhoc): "Neueste Entwicklungen" aus latest_developments,
# ohne Quellen-Links, Datum/Uhrzeit als eigene Zeile.
# - sonst: KI-Executive-Summary (executive_summary_html).
is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident)
latest_dev = (incident.get("latest_developments") or "").strip()
zusammenfassung_html = executive_summary_html
bericht_summary = incident.get("summary", "")
zusammenfassung_title = "Zusammenfassung"
summary_has_links = True
if is_research and bericht_summary:
extracted_html, remaining = _extract_zusammenfassung(bericht_summary, all_sources)
if extracted_html:
zusammenfassung_html = extracted_html
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining
elif not is_research and latest_dev:
dev_html = _format_latest_developments_html(latest_dev)
if dev_html:
zusammenfassung_html = dev_html
zusammenfassung_title = "Neueste Entwicklungen"
summary_has_links = False # Quellen bewusst entfernt
# Auch das (nicht-research) Executive Summary linkifizieren — ggf. enthaelt es Zitate
if not is_research and zusammenfassung_html:
# KI-/Research-Zusammenfassung linkifizieren; Developments bleiben linkfrei
if not is_research and summary_has_links and zusammenfassung_html:
zusammenfassung_html = _linkify_citations_html(zusammenfassung_html, all_sources)
meta = _build_export_metadata(
incident, articles, fact_checks, all_sources, creator, scope, sections,
organization_name, top_locations, snapshot_count=snapshot_count,
include_branding=include_branding,
)
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
@@ -741,6 +837,7 @@ async def generate_pdf(
timeline=_prepare_timeline(articles) if scope == "full" else [],
articles=articles if scope == "full" else [],
meta=meta,
include_branding=include_branding,
)
# Artikel pub_date aufbereiten
@@ -764,6 +861,7 @@ async def generate_docx(
organization_name: str | None = None,
top_locations: list[str] | None = None,
snapshot_count: int = 0,
include_branding: bool = True,
) -> bytes:
"""Word-Report via python-docx generieren."""
doc = Document()
@@ -777,24 +875,32 @@ async def generate_docx(
else: # full
sections = {"zusammenfassung", "bericht", "faktencheck", "quellen", "timeline"}
# Fuer Research-Lagen: Zusammenfassung aus dem Bericht extrahieren
# Zusammenfassungs-Quelle bestimmen (analog generate_pdf):
# Research -> Bericht-Extrakt, Live-Monitoring -> "Neueste Entwicklungen", sonst KI.
is_research = incident.get("type") == "research"
all_sources = _prepare_sources(incident)
latest_dev = (incident.get("latest_developments") or "").strip()
zusammenfassung_text = executive_summary_text
bericht_summary = incident.get("summary") or "Keine Zusammenfassung verfügbar."
zusammenfassung_title = "Zusammenfassung"
zusammenfassung_lines: list[str] = []
zusammenfassung_developments: list[tuple[str, str]] = []
if is_research and bericht_summary:
extracted_lines, remaining = _extract_zusammenfassung_lines(bericht_summary)
if extracted_lines:
zusammenfassung_lines = extracted_lines
zusammenfassung_title = "Zusammenfassung"
bericht_summary = remaining
elif not is_research and latest_dev:
dev_pairs = _parse_developments_for_export(latest_dev)
if dev_pairs:
zusammenfassung_developments = dev_pairs
zusammenfassung_title = "Neueste Entwicklungen"
meta = _build_export_metadata(
incident, articles, fact_checks, all_sources, creator, scope, sections,
organization_name, top_locations, snapshot_count=snapshot_count,
include_branding=include_branding,
)
# Dateimetadaten setzen (sichtbar in Explorer/Finder, DMS-Systemen)
@@ -823,13 +929,15 @@ async def generate_docx(
for _ in range(6):
doc.add_paragraph()
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title_para.add_run("AegisSight Monitor")
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
# Firmenname-Zeile nur im gebrandeten Export
if include_branding:
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title_para.add_run("AegisSight Monitor")
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
doc.add_paragraph()
doc.add_paragraph()
type_label = "Hintergrundrecherche" if incident.get("type") == "research" else "Live-Monitoring"
type_para = doc.add_paragraph()
@@ -865,11 +973,23 @@ async def generate_docx(
doc.add_page_break()
# --- Zusammenfassung / Executive Summary ---
# --- Zusammenfassung / Neueste Entwicklungen ---
if "zusammenfassung" in sections:
doc.add_heading(zusammenfassung_title, level=1)
if zusammenfassung_lines:
if zusammenfassung_developments:
# Live-Monitoring: pro Eintrag Datum/Uhrzeit-Zeile + Absatz mit Text, ohne Links
for label, body in zusammenfassung_developments:
date_para = doc.add_paragraph()
date_para.paragraph_format.space_after = Pt(1)
run = date_para.add_run(label)
run.bold = True
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)
body_para = doc.add_paragraph()
body_para.paragraph_format.space_after = Pt(8)
body_para.add_run(re.sub(r'\*\*(.+?)\*\*', r'\1', body))
elif zusammenfassung_lines:
for line in zusammenfassung_lines:
_add_docx_paragraph_with_citations(doc, line, all_sources, style='List Bullet')
else:
@@ -978,7 +1098,11 @@ async def generate_docx(
doc.add_paragraph()
footer = doc.add_paragraph()
footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = footer.add_run(f"Erstellt mit AegisSight Monitor — aegis-sight.de — {now.strftime('%d.%m.%Y')}")
if include_branding:
footer_text = f"Erstellt mit AegisSight Monitor — aegis-sight.de — {now.strftime('%d.%m.%Y')}"
else:
footer_text = f"Stand: {now.strftime('%d.%m.%Y')}"
run = footer.add_run(footer_text)
run.font.size = Pt(8)
run.font.color.rgb = RGBColor(0x0a, 0x18, 0x32)

Datei anzeigen

@@ -47,6 +47,12 @@ body { font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-se
.exec-summary ul { margin: 8px 0 0 18px; }
.exec-summary li { margin-bottom: 6px; line-height: 1.6; }
/* Neueste Entwicklungen (Live-Monitoring) */
.dev-entry { margin-bottom: 12px; }
.dev-entry:last-child { margin-bottom: 0; }
.dev-entry-date { font-size: 9pt; font-weight: 600; color: #0a1832; margin-bottom: 2px; }
.dev-entry-body { font-size: 10.5pt; line-height: 1.5; }
/* Lagebild */
.lagebild-content { line-height: 1.7; }
.lagebild-content p { margin-bottom: 8px; }
@@ -84,7 +90,7 @@ tr:nth-child(even) { background: #f8f9fa; }
<body>
<!-- Deckblatt -->
<div class="cover">
<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">
{% if include_branding %}<img src="data:image/svg+xml;base64,{{ logo_base64 }}" class="cover-logo" alt="AegisSight">{% endif %}
<div class="cover-type">{{ incident_type_label }}</div>
<div class="cover-title">{{ incident.title }}</div>
<div class="cover-meta">
@@ -92,14 +98,14 @@ tr:nth-child(even) { background: #f8f9fa; }
<div>Erstellt von: {{ creator }}</div>
{% if incident.organization_name %}<div>Organisation: {{ incident.organization_name }}</div>{% endif %}
</div>
<div class="cover-brand">AegisSight Monitor</div>
{% if include_branding %}<div class="cover-brand">AegisSight Monitor</div>{% endif %}
</div>
<!-- Inhaltsverzeichnis -->
<div class="toc">
<h2>Inhaltsverzeichnis</h2>
<ul class="toc-list">
{% if 'zusammenfassung' in sections %}<li><a href="#sec-zusammenfassung">Zusammenfassung</a></li>{% endif %}
{% if 'zusammenfassung' in sections %}<li><a href="#sec-zusammenfassung">{{ zusammenfassung_title }}</a></li>{% endif %}
{% if 'bericht' in sections %}<li><a href="#sec-bericht">{% if incident.type == "research" %}Recherchebericht{% else %}Lagebild{% endif %}</a></li>{% endif %}
{% if 'faktencheck' in sections and fact_checks %}<li><a href="#sec-faktencheck">Faktencheck</a></li>{% endif %}
{% if 'quellen' in sections and sources %}<li><a href="#sec-quellen">Quellenverzeichnis</a></li>{% endif %}
@@ -208,7 +214,7 @@ tr:nth-child(even) { background: #f8f9fa; }
{% endif %}
<div class="report-footer">
Erstellt mit AegisSight Monitor &mdash; aegis-sight.de &mdash; {{ report_date }}
{% if include_branding %}Erstellt mit AegisSight Monitor &mdash; aegis-sight.de &mdash; {{ report_date }}{% else %}Stand: {{ report_date }}{% endif %}
</div>
</body>
</html>

Datei anzeigen

@@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"])
INCIDENT_UPDATE_COLUMNS = {
"title", "description", "type", "status", "refresh_mode",
"refresh_interval", "refresh_start_time", "retention_days", "international_sources", "include_telegram", "visibility",
"refresh_interval", "refresh_start_time", "retention_days", "international_sources", "include_telegram", "include_x", "visibility",
}
@@ -89,7 +89,7 @@ async def list_incidents(
query = (
"SELECT id, title, description, type, status, refresh_mode, refresh_interval, "
"refresh_start_time, retention_days, visibility, "
"international_sources, include_telegram, created_by, created_at, updated_at, "
"international_sources, include_telegram, include_x, created_by, created_at, updated_at, "
"CASE WHEN summary IS NOT NULL AND summary != '' THEN 1 ELSE 0 END AS has_summary "
"FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
)
@@ -120,9 +120,9 @@ async def create_incident(
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
cursor = await db.execute(
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
refresh_start_time, retention_days, international_sources, include_telegram, visibility,
refresh_start_time, retention_days, international_sources, include_telegram, include_x, visibility,
tenant_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
data.title,
data.description,
@@ -133,6 +133,7 @@ async def create_incident(
data.retention_days,
1 if data.international_sources else 0,
1 if data.include_telegram else 0,
1 if data.include_x else 0,
data.visibility,
tenant_id,
current_user["id"],
@@ -164,28 +165,25 @@ async def get_refreshing_incidents(
)
rows = await cursor.fetchall()
# Also include queued incidents from orchestrator
# Queued- und laufende Lagen aus dem Orchestrator ergaenzen.
from agents.orchestrator import orchestrator
queued_ids = list(orchestrator._queued_ids) if hasattr(orchestrator, '_queued_ids') else []
current_task = orchestrator._current_task if hasattr(orchestrator, '_current_task') else None
# Session-Start des aktuell laufenden Tasks — stabil ueber Multi-Pass/Retry hinweg.
# Verhindert, dass der Frontend-Timer beim Reload auf den letzten Log-Eintrag
# (pass 2/3 oder retry n) zurueckspringt.
current_started_at = (
orchestrator._current_task_started_at
if hasattr(orchestrator, '_current_task_started_at') else None
)
queued_ids = list(getattr(orchestrator, '_queued_ids', set()))
# incident_id -> stabiler Session-Start (ueber Multi-Pass/Retry hinweg). Ersetzt
# den frueheren Einzelwert, da jetzt mehrere Organisationen parallel laufen.
current_tasks = getattr(orchestrator, '_current_tasks', {}) or {}
details = {}
for row in rows:
iid = row["incident_id"]
started_at = (
current_started_at
if (iid == current_task and current_started_at)
else row["started_at"]
)
session_start = current_tasks.get(iid)
started_at = session_start if session_start else row["started_at"]
details[str(iid)] = {"started_at": started_at}
# Pro Organisation laeuft hoechstens eine Lage gleichzeitig; der Endpoint ist
# ohnehin tenant-gefiltert, daher genuegt der erste laufende Treffer.
running_here = [row["incident_id"] for row in rows if row["incident_id"] in current_tasks]
current_task = running_here[0] if running_here else None
return {
"refreshing": [row["incident_id"] for row in rows],
"queued": queued_ids,
@@ -385,7 +383,7 @@ async def update_incident(
for field, value in data.model_dump(exclude_none=True).items():
if field not in INCIDENT_UPDATE_COLUMNS:
continue
if field in ("international_sources", "include_telegram"):
if field in ("international_sources", "include_telegram", "include_x"):
updates[field] = 1 if value else 0
else:
updates[field] = value
@@ -494,11 +492,14 @@ async def get_articles_sources_summary(
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT source,
"""SELECT a.source,
COUNT(*) AS article_count,
GROUP_CONCAT(DISTINCT COALESCE(language,'de')) AS languages
FROM articles WHERE incident_id = ?
GROUP BY source ORDER BY article_count DESC""",
GROUP_CONCAT(DISTINCT COALESCE(a.language,'de')) AS languages,
COUNT(DISTINCT m.article_id) AS fimi_match_count
FROM articles a
LEFT JOIN article_fimi_matches m ON m.article_id = a.id
WHERE a.incident_id = ?
GROUP BY a.source ORDER BY article_count DESC""",
(incident_id,),
)
sources = []
@@ -506,6 +507,15 @@ async def get_articles_sources_summary(
d = dict(r)
langs = (d.pop("languages") or "de").split(",")
d["languages"] = sorted({(l or "de").strip() for l in langs if l is not None})
d["fimi_match_count"] = d.get("fimi_match_count") or 0
# Quellentyp aus dem source-Praefix ableiten (fuer den Typ-Filter der Quellenuebersicht)
src = d.get("source") or ""
if src.startswith("X: "):
d["source_type"] = "x"
elif src.startswith("Telegram: "):
d["source_type"] = "telegram"
else:
d["source_type"] = "web"
sources.append(d)
# Sprach-Verteilung gesamt
cursor = await db.execute(
@@ -523,6 +533,114 @@ async def get_articles_sources_summary(
return {"total": total, "sources": sources, "language_counts": lang_counts}
@router.get("/{incident_id}/fimi-matches")
async def get_fimi_matches(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""FIMI-Treffer einer Lage, gruppiert nach Artikel (fuer Andockpunkt 1).
Pro Artikel die verlinkten EUvsDisinfo-Falschbehauptungen mit Provenienz:
Claim-Text, Widerlegung, Case-URL, Embedding-Score und das woertliche
Zitat aus dem Artikel. Der Monitor wertet nicht selbst, er verweist.
"""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute(
"""SELECT m.article_id, m.fimi_claim_id, m.score, m.role, m.matched_text,
c.text AS claim_text, c.verdict, c.verdict_summary,
c.source_ref, c.case_url
FROM article_fimi_matches m
JOIN articles a ON a.id = m.article_id
JOIN fimi_claims c ON c.id = m.fimi_claim_id
WHERE a.incident_id = ?
ORDER BY m.score DESC""",
(incident_id,),
)
by_article: dict[str, list] = {}
for r in await cursor.fetchall():
d = dict(r)
aid = str(d["article_id"])
by_article.setdefault(aid, []).append({
"claim_id": d["fimi_claim_id"],
"claim_text": d["claim_text"],
"verdict": d["verdict"],
"verdict_summary": d["verdict_summary"],
"case_url": d["case_url"],
"source_ref": d["source_ref"],
"score": d["score"],
"passage": d["matched_text"],
})
return {"matches_by_article": by_article, "article_count": len(by_article)}
@router.get("/{incident_id}/fimi-summary")
async def get_fimi_summary(
incident_id: int,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Aggregierte FIMI-Kennzahlen fuer die Lagebild-Qualitaetsachse (Andockpunkt 3).
Antwortet auch sinnvoll, wenn noch nichts geprueft wurde."""
tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cur = await db.execute(
"""SELECT COUNT(*) AS total,
SUM(CASE WHEN fimi_checked_at IS NOT NULL THEN 1 ELSE 0 END) AS checked
FROM articles WHERE incident_id = ?""",
(incident_id,),
)
row = await cur.fetchone()
total = row["total"] or 0
checked = row["checked"] or 0
cur = await db.execute(
"""SELECT COUNT(DISTINCT m.article_id) AS matched_articles,
COUNT(DISTINCT m.fimi_claim_id) AS distinct_claims
FROM article_fimi_matches m
JOIN articles a ON a.id = m.article_id
WHERE a.incident_id = ?""",
(incident_id,),
)
row = await cur.fetchone()
matched_articles = row["matched_articles"] or 0
distinct_claims = row["distinct_claims"] or 0
cur = await db.execute(
"""SELECT c.id AS claim_id, c.text AS claim_text, c.case_url,
COUNT(DISTINCT m.article_id) AS article_count
FROM article_fimi_matches m
JOIN articles a ON a.id = m.article_id
JOIN fimi_claims c ON c.id = m.fimi_claim_id
WHERE a.incident_id = ?
GROUP BY c.id ORDER BY article_count DESC LIMIT 10""",
(incident_id,),
)
top_claims = [dict(r) for r in await cur.fetchall()]
cur = await db.execute(
"""SELECT a.source, COUNT(DISTINCT m.article_id) AS match_count
FROM article_fimi_matches m
JOIN articles a ON a.id = m.article_id
WHERE a.incident_id = ?
GROUP BY a.source ORDER BY match_count DESC LIMIT 10""",
(incident_id,),
)
by_source = [dict(r) for r in await cur.fetchall()]
return {
"articles_total": total,
"articles_checked": checked,
"articles_with_match": matched_articles,
"distinct_claims": distinct_claims,
"top_claims": top_claims,
"by_source": by_source,
}
@router.get("/{incident_id}/articles/timeline-buckets")
async def get_articles_timeline_buckets(
incident_id: int,
@@ -1143,6 +1261,8 @@ async def export_incident(
format: str = Query("pdf", pattern="^(pdf|docx)$"),
scope: str = Query("report", pattern="^(summary|report|full)$"),
sections: str = Query(None),
branding: str = Query("on", pattern="^(on|off)$"),
creator: str = Query(None, max_length=120),
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
@@ -1161,10 +1281,13 @@ async def export_incident(
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
incident = dict(row)
# Ersteller-Name
cursor = await db.execute("SELECT email FROM users WHERE id = ?", (incident["created_by"],))
user_row = await cursor.fetchone()
creator = user_row["email"] if user_row else "Unbekannt"
# Ersteller-Name: manuell uebergebener Wert hat Vorrang, sonst E-Mail des Lage-Erstellers
if creator and creator.strip():
creator = creator.strip()
else:
cursor = await db.execute("SELECT email FROM users WHERE id = ?", (incident["created_by"],))
user_row = await cursor.fetchone()
creator = user_row["email"] if user_row else "Unbekannt"
# Organisation (fuer Dateimetadaten)
organization_name = None
@@ -1218,18 +1341,14 @@ async def export_incident(
snapshots = [dict(r) for r in await cursor.fetchall()]
# Zusammenfassung fuer den Export:
# - Bei Adhoc-Lagen primaer "Neueste Entwicklungen" (latest_developments) als Markdown-Bullets,
# weil Live-Monitoring von Aktualitaet lebt.
# - Fallback (oder bei Research): Executive Summary (KI-generiert, gecacht).
# - Live-Monitoring (adhoc) zeigt primaer "Neueste Entwicklungen" (latest_developments).
# Das Rendering (Datum/Uhrzeit als eigene Zeile, ohne Links) uebernimmt der
# Report-Generator direkt aus incident["latest_developments"].
# - Executive Summary (KI, gecacht) dient nur als Fallback (oder bei Research-Lagen).
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:
exec_summary = incident.get("executive_summary")
if not exec_summary and not (is_adhoc and latest_dev):
summary_text = incident.get("summary") or ""
exec_summary = await generate_executive_summary(summary_text)
await db.execute(
@@ -1237,6 +1356,7 @@ async def export_incident(
(exec_summary, incident_id),
)
await db.commit()
exec_summary = exec_summary or ""
date_str = datetime.now(TIMEZONE).strftime("%Y%m%d")
slug = _slugify(incident["title"])
@@ -1259,6 +1379,7 @@ async def export_incident(
organization_name=organization_name,
top_locations=top_locations,
snapshot_count=snapshot_count,
include_branding=(branding == "on"),
)
filename = f"{slug}_{scope_labels_key}_{date_str}.pdf"
return StreamingResponse(
@@ -1273,6 +1394,7 @@ async def export_incident(
organization_name=organization_name,
top_locations=top_locations,
snapshot_count=snapshot_count,
include_branding=(branding == "on"),
)
filename = f"{slug}_{scope_labels_key}_{date_str}.docx"
return StreamingResponse(

Datei anzeigen

@@ -144,6 +144,7 @@ async def get_source_stats(
"rss_feed": {"count": 0, "articles": 0},
"web_source": {"count": 0, "articles": 0},
"telegram_channel": {"count": 0, "articles": 0},
"x_account": {"count": 0, "articles": 0},
"excluded": {"count": 0, "articles": 0},
}
for row in rows:
@@ -637,6 +638,30 @@ async def validate_telegram_channel(
raise HTTPException(status_code=500, detail="Telegram-Validierung fehlgeschlagen")
@router.post("/x/validate")
async def validate_x_account(
data: dict,
current_user: dict = Depends(get_current_user),
):
"""Prueft ob ein X-Account (Twitter) erreichbar ist und gibt Account-Info zurueck."""
handle = data.get("handle", "").strip()
if not handle:
raise HTTPException(status_code=400, detail="handle ist erforderlich")
try:
from feeds.x_parser import XParser
parser = XParser()
result = await parser.validate_account(handle)
if result:
return result
raise HTTPException(status_code=404, detail="X-Account nicht erreichbar oder nicht gefunden")
except HTTPException:
raise
except Exception as e:
logger.error("X-Validierung fehlgeschlagen: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="X-Validierung fehlgeschlagen")
@router.post("/refresh-counts")
async def trigger_refresh_counts(
current_user: dict = Depends(get_current_user),

127
src/services/embeddings.py Normale Datei
Datei anzeigen

@@ -0,0 +1,127 @@
"""Embedding-Service für den Claim-Matcher.
Lädt ein multilinguales SentenceTransformer-Modell als Singleton.
Erzeugt L2-normalisierte 384-dim Vektoren, sodass Kosinus-Ähnlichkeit
einem einfachen Skalarprodukt entspricht.
"""
from __future__ import annotations
import asyncio
import logging
import threading
from typing import Iterable
import numpy as np
logger = logging.getLogger("osint.embeddings")
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
EMBED_DIM = 384
DTYPE = np.float32
# Threshold-Empfehlungen (empirisch aus Sanity-Tests):
# >= 0.85 -> sehr wahrscheinlich identische Behauptung
# >= 0.75 -> ähnliche Behauptung, dem User zur Auswahl vorschlagen
# < 0.60 -> wahrscheinlich verschiedene Behauptungen
DEFAULT_MATCH_THRESHOLD = 0.75 # fuer Duplikat-Warnung beim Anlegen
LIVE_SEARCH_THRESHOLD = 0.55 # fuer Live-Suche im Modal, mehr Recall
_model = None
_model_lock = threading.Lock()
def _get_model():
"""Lädt das Modell einmalig (lazy) und gibt es zurück."""
global _model
if _model is None:
with _model_lock:
if _model is None:
from sentence_transformers import SentenceTransformer
logger.info("Lade Embedding-Modell %s ...", MODEL_NAME)
_model = SentenceTransformer(MODEL_NAME)
logger.info("Embedding-Modell geladen, dim=%d", EMBED_DIM)
return _model
def _encode_sync(texts: list[str]) -> np.ndarray:
"""Synchroner Encode (CPU-bound, sollte im Executor laufen)."""
model = _get_model()
vecs = model.encode(
texts,
normalize_embeddings=True,
convert_to_numpy=True,
show_progress_bar=False,
)
return vecs.astype(DTYPE, copy=False)
async def encode_text(text: str) -> bytes:
"""Encodet einen Text und gibt das Embedding als Bytes (BLOB-tauglich) zurück."""
if not text or not text.strip():
raise ValueError("Leerer Text kann nicht embedded werden")
loop = asyncio.get_running_loop()
vec = await loop.run_in_executor(None, _encode_sync, [text])
return vec[0].tobytes()
async def encode_batch(texts: list[str]) -> list[bytes]:
"""Encodet mehrere Texte in einem Batch (effizienter als einzeln)."""
texts = [t for t in texts if t and t.strip()]
if not texts:
return []
loop = asyncio.get_running_loop()
vecs = await loop.run_in_executor(None, _encode_sync, texts)
return [v.tobytes() for v in vecs]
def decode_embedding(blob: bytes | None) -> np.ndarray | None:
"""Decodet einen BLOB zurück in einen numpy-Vektor."""
if blob is None or len(blob) == 0:
return None
return np.frombuffer(blob, dtype=DTYPE)
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Kosinus-Ähnlichkeit zweier Vektoren.
Da wir L2-normalisiert encoden, reicht das Skalarprodukt.
Defensiv: wenn ein Vektor nicht normalisiert ist, fängt diese Variante das ab.
"""
na = float(np.linalg.norm(a))
nb = float(np.linalg.norm(b))
if na == 0.0 or nb == 0.0:
return 0.0
return float(np.dot(a, b) / (na * nb))
def find_similar(
query: np.ndarray,
candidates: Iterable[tuple[int, np.ndarray]],
top_k: int = 5,
threshold: float = DEFAULT_MATCH_THRESHOLD,
) -> list[tuple[int, float]]:
"""Sucht in einer Kandidaten-Menge die top_k ähnlichsten Embeddings.
Args:
query: L2-normalisierter Query-Vektor.
candidates: Iterable von (id, embedding-Vektor)-Tupeln.
top_k: maximale Anzahl Treffer.
threshold: minimaler Score, alles darunter wird verworfen.
Returns:
Liste von (id, score), absteigend sortiert.
"""
scored: list[tuple[int, float]] = []
for cid, vec in candidates:
if vec is None:
continue
score = cosine_similarity(query, vec)
if score >= threshold:
scored.append((cid, score))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
def warm_up() -> None:
"""Lädt das Modell vor (kann beim App-Start in einem Thread aufgerufen werden)."""
_get_model()

410
src/services/fimi_matcher.py Normale Datei
Datei anzeigen

@@ -0,0 +1,410 @@
"""FIMI-Matcher: gleicht Monitor-Artikel gegen den importierten
Falschbehauptungs-Bestand (fimi_claims, EUvsDisinfo) ab.
Zweistufig, weil Embedding-Aehnlichkeit nur THEMENNAEHE misst, nicht HALTUNG:
ein Artikel, der Russlands Angriff einen "Angriffskrieg" nennt, liegt im
Embedding-Raum dicht an der Falschbehauptung "Russland wurde zum Angriff
gezwungen", sagt aber das Gegenteil. Reine Embeddings wuerden also neutrale
und sogar widerlegende Berichterstattung als Treffer markieren.
Stufe 1 (Embedding-Vorfilter, billig): findet thematisch nahe Kandidaten.
Die Claim-Embeddings liegen als numpy-Matrix im RAM (~30 MB), ein
Match ist eine Matrixmultiplikation (Kosinus == Skalarprodukt, da
L2-normalisiert).
Stufe 2 (LLM-Verifikation, praezise): ein Haiku-Call pro Kandidaten-Artikel
entscheidet, ob der Artikel die Behauptung tatsaechlich VERBREITET
(zustimmend als Tatsache aufstellt) oder nur darueber berichtet /
sie widerlegt. Nur bestaetigte Verbreitungen werden gespeichert.
Provenienz-Leitplanke: gespeichert wird nur eine Verknuepfung Artikel ->
benannter, pruefbarer EUvsDisinfo-Case plus das woertliche Zitat aus dem
Artikel. Der Monitor wertet nie selbst.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import threading
import aiosqlite
import numpy as np
# URLs aus dem Artikeltext entfernen: sonst versucht das Verifizierer-Modell,
# den Link per WebFetch zu oeffnen, was bei --allowedTools "" als
# error_max_turns scheitert.
_URL_RE = re.compile(r"https?://\S+")
from services.embeddings import encode_batch
from agents.claude_client import call_claude, ClaudeCliError
from config import CLAUDE_MODEL_FAST
logger = logging.getLogger("osint.fimi_matcher")
EMBED_DIM = 384
# Stufe 1: Vorfilter
EMBED_FLOOR = 0.55 # untere Grenze, ab der ein Kandidat ueberhaupt entsteht
PREFILTER_THRESHOLD = 0.65 # ab hier geht ein Kandidat in die LLM-Verifikation
TOP_K = 5 # max. Kandidaten-Claims pro Artikel
CONTENT_EXCERPT_CHARS = 1500
# Stufe 2: LLM-Verifikation
VERIFY_ENABLED = os.environ.get("FIMI_VERIFY_ENABLED", "true").lower() != "false"
VERIFY_CONCURRENCY = int(os.environ.get("FIMI_VERIFY_CONCURRENCY", "4"))
VERIFY_CONTENT_CHARS = 2200
VERIFY_TIMEOUT = 90
# Singleton-Matrix der Claim-Embeddings
_ids: np.ndarray | None = None # (N,) int64 -> fimi_claims.id
_matrix: np.ndarray | None = None # (N, 384) float32
_lock = threading.Lock()
# ──────────────────────────────────────────────────────────────────
# Stufe 1: Embedding-Vorfilter
# ──────────────────────────────────────────────────────────────────
async def ensure_matrix(db: aiosqlite.Connection, force: bool = False) -> int:
"""Laedt die Claim-Embeddings einmalig in eine numpy-Matrix. Idempotent."""
global _ids, _matrix
if _matrix is not None and not force:
return int(_matrix.shape[0])
cursor = await db.execute(
"SELECT id, embedding FROM fimi_claims WHERE embedding IS NOT NULL"
)
rows = await cursor.fetchall()
ids: list[int] = []
vecs: list[np.ndarray] = []
for r in rows:
v = np.frombuffer(r["embedding"], dtype=np.float32)
if v.size != EMBED_DIM:
continue
ids.append(r["id"])
vecs.append(v)
with _lock:
if vecs:
_ids = np.asarray(ids, dtype=np.int64)
_matrix = np.vstack(vecs).astype(np.float32, copy=False)
else:
_ids = np.empty((0,), dtype=np.int64)
_matrix = np.empty((0, EMBED_DIM), dtype=np.float32)
logger.info("FIMI-Matcher: %d Claim-Embeddings geladen", len(ids))
return len(ids)
def is_ready() -> bool:
return _matrix is not None and _matrix.shape[0] > 0
def _build_query_text(headline: str | None, content: str | None) -> str:
parts = []
if headline:
parts.append(headline.strip())
if content:
excerpt = content.strip()[:CONTENT_EXCERPT_CHARS]
if excerpt:
parts.append(excerpt)
return " ".join(parts).strip()
async def match_query_texts(
texts: list[str],
threshold: float = EMBED_FLOOR,
top_k: int = TOP_K,
) -> list[list[tuple[int, float]]]:
"""Stufe 1: matcht Query-Texte gegen die Claim-Matrix (Embedding-Kosinus).
Returns: Liste gleicher Laenge wie texts, je eine Liste von
(claim_id, score), absteigend sortiert, nur Treffer >= threshold.
"""
results: list[list[tuple[int, float]]] = [[] for _ in texts]
if _matrix is None or _matrix.shape[0] == 0:
return results
valid_idx = [i for i, t in enumerate(texts) if t and t.strip()]
if not valid_idx:
return results
blobs = await encode_batch([texts[i] for i in valid_idx])
if len(blobs) != len(valid_idx):
logger.warning("FIMI-Matcher: encode_batch-Laenge passt nicht, skip")
return results
qm = np.vstack([np.frombuffer(b, dtype=np.float32) for b in blobs]) # (V, 384)
scores = qm @ _matrix.T # (V, N) — Kosinus, da L2-normalisiert
for row, orig_i in enumerate(valid_idx):
s = scores[row]
if top_k < s.size:
cand = np.argpartition(s, -top_k)[-top_k:]
else:
cand = np.arange(s.size)
cand = cand[np.argsort(s[cand])[::-1]]
hits = [(int(_ids[j]), float(s[j])) for j in cand if s[j] >= threshold]
results[orig_i] = hits
return results
# ──────────────────────────────────────────────────────────────────
# Stufe 2: LLM-Verifikation
# ──────────────────────────────────────────────────────────────────
_VERIFY_PROMPT = """Bewerte ausschließlich den unten stehenden Artikeltext. Du hast KEINEN Internetzugang und darfst KEINE Werkzeuge benutzen (kein WebFetch, keine Suche, kein Öffnen von Links). Falls der Text gekürzt ist, bewerte nur das Vorhandene. Antworte sofort mit JSON.
Du prüfst, ob ein Nachrichtenartikel eine bekannte Falschbehauptung VERBREITET.
VERBREITET (spreads=true): Der Artikel übernimmt die SPEZIFISCHE, irreführende Kernaussage der Behauptung zustimmend, stellt sie als Tatsache hin oder legt sie dem Leser als wahr nahe.
VERBREITET NICHT (spreads=false), wenn eines zutrifft:
- Der Artikel berichtet neutral über das Thema.
- Der Artikel widerlegt die Behauptung, ordnet sie als Desinformation ein oder zitiert sie distanziert/kritisch.
- Der Artikel sagt inhaltlich das Gegenteil.
- Der Artikel erwähnt nur ein thematisch verwandtes Faktum, OHNE die irreführende Kernaussage zu übernehmen.
Entscheidend ist die HALTUNG zur konkreten Kernaussage, nicht die thematische Nähe. Ein gemeinsames Stichwort, Ereignis oder Faktum reicht NICHT.
Beispiele für spreads=false (häufige Verwechslung):
- Behauptung "Russland wurde zum Angriff gezwungen": Artikel nennt den Einmarsch einen "Angriffskrieg" -> false (Gegenteil).
- Behauptung "Die Ukraine ist eine westliche Marionette ohne Souveränität": Artikel berichtet, dass ausländische Ausbilder ukrainische Soldaten trainieren -> false (bloßes Faktum, keine Marionetten-Aussage).
- Behauptung "Russlands Wirtschaft boomt trotz Sanktionen": Artikel berichtet konkrete Öleinnahmen -> false (Einzelfaktum, kein Boom-Narrativ).
- Behauptung "Die Ukraine kann den Krieg nicht gewinnen": Artikel analysiert, dass militärisch keine Seite gewinnen kann -> false (symmetrische Analyse, nicht die einseitige Behauptung).
Im Zweifel spreads=false. Nur die eindeutige Übernahme der irreführenden Kernaussage zählt.
ARTIKEL
Titel: {headline}
Text: {content}
ZU PRÜFENDE BEHAUPTUNGEN
{claims}
Antworte AUSSCHLIESSLICH als JSON:
{{"results": [{{"claim_id": <id>, "spreads": <true|false>, "passage": "<wörtliches Zitat aus dem Artikel, das die Behauptung verbreitet; leer wenn spreads=false>"}}]}}"""
async def _verify_article(
article, candidate_claims: list[tuple[int, float, str]]
) -> list[tuple[int, float, str]]:
"""Ein Haiku-Call: welche Kandidaten-Behauptungen verbreitet der Artikel?
candidate_claims: Liste (claim_id, embed_score, claim_text).
Returns: bestaetigte (claim_id, embed_score, passage) fuer spreads=true.
Wirft bei CLI-/Parse-Fehler, damit der Aufrufer den Artikel nicht als
geprueft markiert (Retry beim naechsten Refresh).
"""
headline = (article["headline_de"] or article["headline"] or "").strip()
content = (
(article["content_de"] if "content_de" in article.keys() else None)
or (article["content_original"] if "content_original" in article.keys() else None)
or ""
)
content = _URL_RE.sub("", content).strip()[:VERIFY_CONTENT_CHARS]
if not content:
# Ohne Fliesstext laesst sich die Haltung nicht serioes bestimmen.
return []
claim_by_id = {cid: text for cid, _, text in candidate_claims}
claims_block = "\n".join(f"[{cid}] {text}" for cid, _, text in candidate_claims)
prompt = _VERIFY_PROMPT.format(headline=headline, content=content, claims=claims_block)
text, _usage = await call_claude(
prompt, tools=None, model=CLAUDE_MODEL_FAST, timeout=VERIFY_TIMEOUT
)
raw = (text or "").strip()
# Defensive: evtl. Markdown-Fences entfernen
if raw.startswith("```"):
raw = raw.strip("`")
nl = raw.find("\n")
if nl != -1:
raw = raw[nl + 1:]
start, end = raw.find("{"), raw.rfind("}")
if start == -1 or end == -1:
raise ValueError(f"Keine JSON-Antwort vom Verifizierer: {raw[:120]!r}")
data = json.loads(raw[start:end + 1])
embed_score = {cid: sc for cid, sc, _ in candidate_claims}
confirmed: list[tuple[int, float, str]] = []
for item in data.get("results", []):
try:
cid = int(item.get("claim_id"))
except (TypeError, ValueError):
continue
if cid not in claim_by_id:
continue
if item.get("spreads") is True:
passage = (item.get("passage") or "").strip()[:500]
confirmed.append((cid, embed_score.get(cid, 0.0), passage))
return confirmed
# ──────────────────────────────────────────────────────────────────
# Orchestrierung: matchen + speichern
# ──────────────────────────────────────────────────────────────────
async def _load_claim_texts(db, claim_ids: set[int]) -> dict[int, str]:
if not claim_ids:
return {}
qs = ",".join("?" for _ in claim_ids)
cursor = await db.execute(
f"SELECT id, text FROM fimi_claims WHERE id IN ({qs})", tuple(claim_ids)
)
return {r["id"]: r["text"] for r in await cursor.fetchall()}
async def match_and_store_articles(
db: aiosqlite.Connection,
articles: list,
prefilter_threshold: float = PREFILTER_THRESHOLD,
top_k: int = TOP_K,
verify: bool | None = None,
mark_checked: bool = True,
) -> dict:
"""Zweistufiger Match + Speicherung fuer eine Liste Artikel-Rows.
articles: Rows mit id, headline, headline_de, content_original, content_de
und (optional) tenant_id.
"""
if verify is None:
verify = VERIFY_ENABLED
await ensure_matrix(db)
if not articles:
return {"articles": 0, "candidates": 0, "articles_with_match": 0, "stored": 0, "errors": 0}
# Stufe 1: Embedding-Vorfilter
texts = [
_build_query_text(
a["headline_de"] or a["headline"],
(a["content_de"] if "content_de" in a.keys() else None)
or (a["content_original"] if "content_original" in a.keys() else None),
)
for a in articles
]
prefiltered = await match_query_texts(texts, threshold=EMBED_FLOOR, top_k=top_k)
# Claim-Texte fuer alle starken Kandidaten laden
strong_per_article: list[list[tuple[int, float]]] = [
[(cid, sc) for cid, sc in cands if sc >= prefilter_threshold]
for cands in prefiltered
]
need_ids: set[int] = {cid for lst in strong_per_article for cid, _ in lst}
claim_texts = await _load_claim_texts(db, need_ids)
# Stufe 2: Verifikation (parallel, begrenzt) — nur Artikel mit starken Kandidaten
sem = asyncio.Semaphore(max(1, VERIFY_CONCURRENCY))
candidates_total = sum(len(lst) for lst in strong_per_article)
async def _process(idx: int):
a = articles[idx]
strong = strong_per_article[idx]
if not strong:
# geprueft, aber kein starker Kandidat -> nichts zu verifizieren
return idx, [], False
cand = [(cid, sc, claim_texts.get(cid, "")) for cid, sc in strong if claim_texts.get(cid)]
if not cand:
return idx, [], False
if not verify:
return idx, [(cid, sc, None) for cid, sc, _ in cand], False
async with sem:
try:
confirmed = await _verify_article(a, cand)
return idx, confirmed, False
except (ClaudeCliError, ValueError, json.JSONDecodeError, TimeoutError) as e:
logger.warning("FIMI-Verifikation article_id=%s fehlgeschlagen: %s",
a["id"], e)
return idx, None, True # error -> nicht als checked markieren
proc = await asyncio.gather(*[_process(i) for i in range(len(articles))])
# Speichern (sequenziell, eine DB-Connection)
stored = 0
with_match = 0
errors = 0
for idx, confirmed, err in proc:
a = articles[idx]
if err:
errors += 1
continue # Artikel NICHT als checked markieren -> Retry
if confirmed:
with_match += 1
tenant_id = a["tenant_id"] if "tenant_id" in a.keys() else None
role = "verified" if verify else "match"
for cid, sc, passage in confirmed:
try:
await db.execute(
"""INSERT INTO article_fimi_matches
(article_id, fimi_claim_id, score, role, matched_text, tenant_id, matched_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
(a["id"], cid, round(sc, 4), role, passage, tenant_id),
)
stored += 1
except aiosqlite.IntegrityError:
await db.execute(
"""UPDATE article_fimi_matches
SET score = MAX(COALESCE(score, 0), ?),
role = ?, matched_text = COALESCE(?, matched_text)
WHERE article_id = ? AND fimi_claim_id = ?""",
(round(sc, 4), role, passage, a["id"], cid),
)
if mark_checked:
await db.execute(
"UPDATE articles SET fimi_checked_at = CURRENT_TIMESTAMP WHERE id = ?",
(a["id"],),
)
await db.commit()
logger.info(
"FIMI-Matcher: %d Artikel, %d Kandidaten, %d verbreiten Falschbehauptungen, "
"%d Links, %d Fehler",
len(articles), candidates_total, with_match, stored, errors,
)
return {
"articles": len(articles),
"candidates": candidates_total,
"articles_with_match": with_match,
"stored": stored,
"errors": errors,
}
async def match_article_ids(
db: aiosqlite.Connection,
article_ids: list[int],
verify: bool | None = None,
) -> dict:
"""Matcht eine konkrete Menge Artikel (per ID). Pipeline-Einstieg fuer die
in einem Refresh neu hinzugekommenen Artikel."""
ids = [int(i) for i in article_ids if i]
if not ids:
return {"articles": 0, "candidates": 0, "articles_with_match": 0, "stored": 0, "errors": 0}
qs = ",".join("?" for _ in ids)
cursor = await db.execute(
f"SELECT id, headline, headline_de, content_original, content_de, tenant_id "
f"FROM articles WHERE id IN ({qs})",
tuple(ids),
)
articles = await cursor.fetchall()
return await match_and_store_articles(db, articles, verify=verify)
async def match_incident_articles(
db: aiosqlite.Connection,
incident_id: int,
only_unchecked: bool = True,
limit: int | None = None,
verify: bool | None = None,
) -> dict:
"""Matcht (standardmaessig noch nicht gepruefte) Artikel einer Lage."""
q = (
"SELECT id, headline, headline_de, content_original, content_de, tenant_id "
"FROM articles WHERE incident_id = ?"
)
params: list = [incident_id]
if only_unchecked:
q += " AND fimi_checked_at IS NULL"
q += " ORDER BY id"
if limit:
q += f" LIMIT {int(limit)}"
cursor = await db.execute(q, params)
articles = await cursor.fetchall()
return await match_and_store_articles(db, articles, verify=verify)

Datei anzeigen

@@ -1,12 +1,17 @@
"""Organization-Settings-Helper.
KV-Store pro Organisation. Aktuell genutzt fuer output_language ('de'|'en').
Spaeter erweiterbar (Default-Modell, Telegram-Toggle, Theme, ...).
KV-Store pro Organisation. Aktuell genutzt fuer:
- output_language ('de'|'en'|...) - Anzeige-/Lagebild-Sprache
- source_language_whitelist (JSON-Liste, z.B. ["ja"]) - schraenkt RSS/Telegram-Quellen ein
- research_language (ISO-Code) - steuert WebSearch-Prompts (default = output_language)
- translator_enabled ('true'|'false') - override fuer das globale TRANSLATOR_ENABLED-Flag
Cache: TTL 60s in-memory pro (tenant_id, key). Wird bei set_org_setting()
invalidiert.
"""
import json
import logging
import os
import time
from typing import Optional
@@ -84,6 +89,15 @@ async def set_org_setting(
LANGUAGE_DISPLAY_NAMES = {
"de": "Deutsch",
"en": "English",
"ja": "Japanese",
"zh": "Chinese",
"ko": "Korean",
"ru": "Russian",
"ar": "Arabic",
"fa": "Persian",
"he": "Hebrew",
"fr": "French",
"es": "Spanish",
}
@@ -91,7 +105,10 @@ async def get_org_language(
db: aiosqlite.Connection,
tenant_id: int,
) -> str:
"""Liefert ISO-2-Sprachcode der Org (default 'de')."""
"""Liefert ISO-2-Sprachcode der Org (default 'de').
Steuert die Lagebild-/Anzeige-Sprache.
"""
value = await get_org_setting(db, tenant_id, "output_language", default="de")
if value not in LANGUAGE_DISPLAY_NAMES:
logger.warning("Unbekannte output_language '%s' fuer Org %s -- fallback 'de'", value, tenant_id)
@@ -99,6 +116,65 @@ async def get_org_language(
return value
async def get_source_language_whitelist(
db: aiosqlite.Connection,
tenant_id: int,
) -> Optional[list[str]]:
"""Liefert Liste erlaubter Quellsprachen oder None (= keine Einschränkung).
Gespeichert als JSON-Array unter dem Key 'source_language_whitelist'.
Beispiel-Wert: '["ja"]' -> nur japanischsprachige Quellen.
"""
raw = await get_org_setting(db, tenant_id, "source_language_whitelist", default=None)
if not raw:
return None
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, TypeError) as e:
logger.warning(
"source_language_whitelist fuer Org %s ist kein JSON ('%s'): %s",
tenant_id, raw, e,
)
return None
if not isinstance(parsed, list):
logger.warning("source_language_whitelist fuer Org %s ist keine Liste: %r", tenant_id, parsed)
return None
cleaned = [str(x).strip().lower() for x in parsed if str(x).strip()]
return cleaned or None
async def get_research_language(
db: aiosqlite.Connection,
tenant_id: int,
) -> str:
"""Liefert die Sprache, in der der WebSearch-Researcher primär sucht.
Default = output_language. Bei jp_demo z.B. 'ja', während output_language='de' bleibt.
"""
value = await get_org_setting(db, tenant_id, "research_language", default=None)
if value and value in LANGUAGE_DISPLAY_NAMES:
return value
return await get_org_language(db, tenant_id)
async def get_translator_enabled(
db: aiosqlite.Connection,
tenant_id: Optional[int],
) -> bool:
"""Liefert true wenn der (volle) Translator-Schritt fuer diese Org laufen soll.
Hierarchie:
1. Org-Setting 'translator_enabled' ('true'/'false') gewinnt, wenn gesetzt.
2. Sonst: globales ENV-Flag TRANSLATOR_ENABLED (Default true im config.py).
"""
if tenant_id is not None:
raw = await get_org_setting(db, tenant_id, "translator_enabled", default=None)
if raw is not None:
return str(raw).strip().lower() in ("true", "1", "yes", "on")
env_value = os.environ.get("TRANSLATOR_ENABLED", "true").strip().lower()
return env_value in ("true", "1", "yes", "on")
def language_display(lang_iso: str) -> str:
"""ISO-Code -> Anzeigename fuer Prompts ('de' -> 'Deutsch')."""
return LANGUAGE_DISPLAY_NAMES.get(lang_iso, lang_iso)

Datei anzeigen

@@ -32,8 +32,12 @@ _PIPELINE_STEPS_DE = [
"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": "public_mood", "label": "Stimmung erfassen", "icon": "message-circle",
"tooltip": "Aus Foren-Quellen (z.B. 5ch, Hatena, Note) wird ein Stimmungsbild der öffentlichen Diskussion extrahiert. Keine Faktenlage, sondern dominante Themen und Bruchlinien."},
{"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": "translate", "label": "Artikel uebersetzen", "icon": "languages",
"tooltip": "Fremdsprachige Meldungen (z.B. japanisch) werden ins Lagebild-Output uebersetzt. Laeuft nur fuer Quellen-Pools mit nicht-deutschen Sprachen und kann bei vielen neuen Artikeln einige Minuten dauern."},
{"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",
@@ -53,8 +57,12 @@ _PIPELINE_STEPS_EN = [
"tooltip": "Locations are extracted from the articles and placed on the map."},
{"key": "factcheck", "label": "Checking facts", "icon": "shield",
"tooltip": "Claims from the articles are cross-checked: Confirmed? Disputed? Still unclear?"},
{"key": "public_mood", "label": "Reading the mood", "icon": "message-circle",
"tooltip": "Forum sources (5ch, Hatena, Note, etc.) are summarised into a public-mood overview. Not factual, but dominant themes and fault lines."},
{"key": "summary", "label": "Writing the briefing", "icon": "file-text",
"tooltip": "All verified articles are combined into a coherent briefing with inline citations."},
{"key": "translate", "label": "Translating articles", "icon": "languages",
"tooltip": "Foreign-language articles (e.g. Japanese) are translated into the briefing output language. Runs only when the source pool contains non-target-language items and can take several minutes for large incoming batches."},
{"key": "qc", "label": "Quality check", "icon": "check-circle",
"tooltip": "A final review: consolidate duplicate facts, verify map locations, before you get notified."},
{"key": "notify", "label": "Notifying", "icon": "bell",

Datei anzeigen

@@ -86,6 +86,9 @@ DOMAIN_CATEGORY_MAP = {
"merkur.de": "regional",
# Telegram
"t.me": "telegram",
# X / Twitter
"x.com": "x",
"twitter.com": "x",
}
# Bekannte Feed-Pfade zum Durchprobieren
@@ -642,14 +645,20 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss
source_type: "rss_feed" (Default) oder "podcast_feed" — trennt RSS- und Podcast-Quellen
in getrennten Pipelines, damit der RSS-Heisspfad unveraendert bleibt.
Wenn die Org eine source_language_whitelist gesetzt hat (z.B. jp_demo: ['ja']),
werden nur Feeds geliefert, deren primary_language darauf passt. Feeds ohne
gesetztes primary_language fallen in dem Fall raus — das ist gewollt, weil
eine Whitelist gerade die strenge Beschraenkung ist.
"""
from database import get_db
from services.org_settings import get_source_language_whitelist
db = await get_db()
try:
if tenant_id:
cursor = await db.execute(
"SELECT name, url, domain, category, notes, primary_language, "
"SELECT name, url, domain, category, notes, primary_language, media_type, "
"COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = ? AND status = 'active' "
"AND (tenant_id IS NULL OR tenant_id = ?)",
@@ -657,12 +666,25 @@ async def get_feeds_with_metadata(tenant_id: int = None, source_type: str = "rss
)
else:
cursor = await db.execute(
"SELECT name, url, domain, category, notes, primary_language, "
"SELECT name, url, domain, category, notes, primary_language, media_type, "
"COALESCE(article_count, 0) AS article_count FROM sources "
"WHERE source_type = ? AND status = 'active'",
(source_type,),
)
return [dict(row) for row in await cursor.fetchall()]
feeds = [dict(row) for row in await cursor.fetchall()]
# Whitelist-Filter (nur wenn die Org eine gesetzt hat)
if tenant_id:
whitelist = await get_source_language_whitelist(db, tenant_id)
if whitelist:
before = len(feeds)
feeds = [f for f in feeds if (f.get("primary_language") or "").lower() in whitelist]
logger.info(
"source_language_whitelist=%s fuer Org %s: %d/%d Feeds passieren",
whitelist, tenant_id, len(feeds), before,
)
return feeds
except Exception as e:
logger.error(f"Fehler beim Laden der Feed-Metadaten ({source_type}): {e}")
return []

Datei anzeigen

@@ -1715,6 +1715,39 @@ a.dev-source-pill:hover {
color: var(--text-primary);
}
.source-type-filter-chips {
display: flex;
flex-wrap: wrap;
gap: var(--sp-xs);
margin: var(--sp-sm) 0 var(--sp-xs);
}
.source-type-filter-chip {
font: inherit;
font-size: 11px;
padding: 3px 10px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.source-type-filter-chip:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.source-type-filter-chip.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.source-type-filter-chip.active strong {
color: #fff;
}
.source-overview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
@@ -6139,3 +6172,122 @@ body.tutorial-active .tutorial-cursor {
.pipeline-block.status-active { box-shadow: var(--glow-accent); }
.pipeline-stage.is-looping .pipeline-loop { animation: none !important; opacity: 1; }
}
/* ──────────────────────────────────────────────────────────────────
FIMI / Counter-Disinformation (Andockpunkte 1-3)
Dezenter, hinweisender Ton (amber = --warning), keine Warnsirene.
Die Provenienz wird ueber Texte + Case-Links getragen, nicht ueber
Farbe. Kein Match -> kein Element, kein visueller Ballast.
────────────────────────────────────────────────────────────────── */
/* Andockpunkt 1: Inline-Hinweis am Artikel (in der Quellen-Detailliste) */
.fimi-hint {
flex-basis: 100%;
display: flex;
align-items: center;
gap: 6px;
margin-top: 5px;
padding: 4px 8px;
font-size: 11.5px;
line-height: 1.35;
background: rgba(245, 158, 11, 0.08);
border-left: 2px solid var(--warning);
border-radius: 3px;
}
.fimi-hint-icon { flex: 0 0 auto; font-size: 12px; color: var(--warning); }
.fimi-hint-text { color: var(--text-secondary); }
.fimi-hint-link {
margin-left: auto;
flex: 0 0 auto;
color: var(--warning);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.fimi-hint-link:hover { text-decoration: underline; }
.source-overview-detail-list li.has-fimi-hint { flex-wrap: wrap; }
/* Andockpunkt 2: empirischer Track-Record-Badge in der Quellen-Box */
.fimi-source-badge {
display: inline-flex;
align-items: center;
margin-left: 6px;
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--warning);
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: 10px;
white-space: nowrap;
}
.source-overview-item.has-fimi { box-shadow: inset 2px 0 0 var(--warning); }
/* Andockpunkt 3: Qualitaetsleiste ueber dem Lagebild */
.fimi-summary-bar {
margin: 0 0 12px 0;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.45;
}
.fimi-summary-bar:empty { display: none; }
.fimi-summary-bar--alert {
color: var(--text-primary);
background: rgba(245, 158, 11, 0.09);
border: 1px solid rgba(245, 158, 11, 0.30);
}
.fimi-summary-bar--clear {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
background: var(--bg-elevated);
border: 1px solid var(--border);
}
.fimi-summary-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.fimi-summary-icon { flex: 0 0 auto; color: var(--warning); font-size: 15px; }
.fimi-summary-bar--clear .fimi-summary-icon { color: var(--success); }
.fimi-summary-lead { flex: 1 1 240px; }
.fimi-summary-lead strong { color: var(--warning); }
.fimi-summary-toggle {
flex: 0 0 auto;
padding: 3px 10px;
font-size: 12px;
font-weight: 600;
color: var(--warning);
background: transparent;
border: 1px solid rgba(245, 158, 11, 0.4);
border-radius: 4px;
cursor: pointer;
}
.fimi-summary-toggle:hover { background: rgba(245, 158, 11, 0.12); }
.fimi-summary-claims {
list-style: none;
margin: 10px 0 0 0;
padding: 10px 0 0 0;
border-top: 1px solid rgba(245, 158, 11, 0.20);
}
.fimi-summary-claims li {
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 0;
font-size: 12.5px;
color: var(--text-secondary);
}
.fimi-claim-count { flex: 0 0 auto; font-weight: 700; color: var(--warning); min-width: 28px; }
.fimi-claim-text { flex: 1 1 auto; }
/* FIMI: Pflicht-Quellenhinweis EUvsDisinfo (dezent, gedaempft) */
.fimi-disclaimer {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(245, 158, 11, 0.18);
font-size: 10.5px;
line-height: 1.4;
color: var(--text-disabled);
}
.fimi-disclaimer a { color: var(--text-secondary); text-decoration: underline; }
.fimi-disclaimer a:hover { color: var(--warning); }

Datei anzeigen

@@ -13,7 +13,7 @@
<link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.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=20260522c">
<style>
/* 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; }
@@ -209,6 +209,7 @@
<button class="tab-btn" data-tab="timeline" data-i18n="tab.timeline">Ereignis-Timeline</button>
<button class="tab-btn" data-tab="karte" data-i18n="tab.map">Geografische Verteilung</button>
<button class="tab-btn" data-tab="faktencheck" data-i18n="tab.factcheck">Faktencheck</button>
<button class="tab-btn" data-tab="stimmung" data-i18n="tab.public_mood" id="tab-btn-stimmung" style="display:none;">Öffentliche Stimmung</button>
<button class="tab-btn" data-tab="pipeline" data-i18n="tab.pipeline">Analysepipeline</button>
<button class="tab-btn" data-tab="quellen" data-i18n="tab.sources_overview">Quellenübersicht</button>
</div>
@@ -233,6 +234,7 @@
<span class="lagebild-timestamp" id="lagebild-timestamp"></span>
</div>
<div id="summary-content">
<div id="fimi-summary-bar"></div>
<div id="summary-text" class="summary-text"></div>
</div>
</div>
@@ -293,6 +295,24 @@
</div>
</div>
<div class="tab-panel" id="panel-stimmung">
<div class="card incident-analysis-stimmung" id="stimmung-card">
<div class="card-header">
<div class="card-title">
<span data-i18n="card.public_mood">Öffentliche Stimmung</span>
<span class="info-icon" data-tooltip="Themen und Bruchlinien aus Foren-Quellen (z.B. 5ch, Hatena, Note).&#10;&#10;KEINE Faktenlage - reines Stimmungsmaterial.&#10;Beitraege sind anonym und koennen Trolling enthalten."><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>
</div>
<span class="stimmung-timestamp" id="stimmung-timestamp"></span>
</div>
<div id="stimmung-content">
<div id="stimmung-text" class="summary-text" style="padding:8px 16px;"></div>
<div style="padding:0 16px 16px; font-size:11px; color:var(--text-disabled); border-top:1px solid var(--border); margin-top:8px; padding-top:8px;">
Hinweis: Forenbeiträge sind anonyme Online-Stimmungen, keine Faktenlage. Sie fließen nicht in den Faktencheck ein.
</div>
</div>
</div>
</div>
<div class="tab-panel" id="panel-pipeline">
<div class="card pipeline-card" id="pipeline-card">
<div class="card-header">
@@ -373,6 +393,13 @@
<span class="toggle-switch"></span>
<span class="toggle-text"><span data-i18n="modal.toggle.telegram">Telegram-Kanäle einbeziehen</span> <span class="info-icon tooltip-below" data-tooltip="Bezieht OSINT-relevante Telegram-Kanäle als zusätzliche Quelle ein. Kann die Aktualität erhöhen, aber auch unbestätigte Informationen liefern."><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>
</div>
<div class="toggle-group" style="margin-top: 8px;">
<label class="toggle-label">
<input type="checkbox" id="inc-x">
<span class="toggle-switch"></span>
<span class="toggle-text"><span data-i18n="modal.toggle.x">X (Twitter) einbeziehen</span> <span class="info-icon tooltip-below" data-tooltip="Bezieht Posts konfigurierter X-Accounts (Twitter) als zusätzliche Quelle ein. Kann die Aktualität erhöhen, aber auch unbestätigte Informationen liefern."><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>
</div> </div>
<div class="form-group">
<label><span data-i18n="modal.new_incident.visibility">Sichtbarkeit</span> <span class="info-icon tooltip-below" data-tooltip="Öffentlich: Alle Nutzer der Organisation sehen diese Lage.&#10;&#10;Privat: Nur für dich sichtbar."><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></label>
@@ -465,6 +492,8 @@
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option>
<option value="telegram_channel">Telegram</option>
<option value="x_account">X (Twitter)</option>
<option value="podcast_feed">Podcast</option>
<option value="excluded">Von mir ausgeschlossen</option>
</select>
<label for="sources-filter-category" class="sr-only" data-i18n="sources_modal.filter.category">Kategorie filtern</label>
@@ -604,6 +633,7 @@
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option>
<option value="telegram_channel">Telegram-Kanal</option>
<option value="x_account">X-Account</option>
</select>
</div>
<div class="form-group" id="src-rss-url-group">
@@ -776,12 +806,12 @@
<script src="/static/vendor/leaflet.js"></script>
<script src="/static/vendor/leaflet.markercluster.js"></script>
<script src="/static/js/i18n.js?v=20260513a"></script>
<script src="/static/js/api.js?v=20260423a"></script>
<script src="/static/js/api.js?v=20260522f"></script>
<script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260514e"></script>
<script src="/static/js/components.js?v=20260522d"></script>
<script src="/static/js/layout.js?v=20260513f"></script>
<script src="/static/js/pipeline.js?v=20260513d"></script>
<script src="/static/js/app.js?v=20260514e"></script>
<script src="/static/js/app.js?v=20260522f"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260514e"></script>
@@ -821,6 +851,16 @@
<label class="export-radio"><input type="radio" name="export-format" value="pdf" checked><span>PDF</span></label>
<label class="export-radio"><input type="radio" name="export-format" value="docx"><span data-i18n="export.format.docx">Word (DOCX)</span></label>
</div>
<div style="margin-bottom:16px;">
<label style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;" data-i18n="export.branding">Branding</label>
<label class="export-radio"><input type="radio" name="export-branding" value="on" checked><span data-i18n="export.branding.on">Mit AegisSight-Branding</span></label>
<label class="export-radio"><input type="radio" name="export-branding" value="off"><span data-i18n="export.branding.off">Ohne Firmen-Branding</span></label>
</div>
<div style="margin-bottom:0;">
<label for="export-ersteller" style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-secondary);display:block;margin-bottom:8px;">Ersteller</label>
<input type="text" id="export-ersteller" maxlength="120" placeholder="Name des Erstellers (optional)" style="width:100%;box-sizing:border-box;">
<div style="font-size:11px;color:var(--text-secondary);margin-top:6px;">Leer lassen, dann wird automatisch der Lage-Ersteller verwendet.</div>
</div>
</div>
<div class="modal-footer" style="padding:12px 20px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--border);">
<button class="btn btn-secondary" onclick="closeModal('modal-export')" data-i18n="common.cancel">Abbrechen</button>

Datei anzeigen

@@ -210,6 +210,9 @@
"export.format": "Format",
"export.format.pdf": "PDF",
"export.format.docx": "Word (DOCX)",
"export.branding": "Branding",
"export.branding.on": "Mit AegisSight-Branding",
"export.branding.off": "Ohne Firmen-Branding",
"export.submit": "Exportieren",
"sources_modal.title": "Quellenverwaltung",
"sources_modal.stats.rss": "RSS-Feeds",

Datei anzeigen

@@ -210,6 +210,9 @@
"export.format": "Format",
"export.format.pdf": "PDF",
"export.format.docx": "Word (DOCX)",
"export.branding": "Branding",
"export.branding.on": "With AegisSight branding",
"export.branding.off": "Without company branding",
"export.submit": "Export",
"sources_modal.title": "Source management",
"sources_modal.stats.rss": "RSS feeds",

Datei anzeigen

@@ -181,6 +181,15 @@ const API = {
return this._request('GET', `/incidents/${incidentId}/factchecks`);
},
// FIMI / Counter-Disinformation
getFimiMatches(incidentId) {
return this._request('GET', `/incidents/${incidentId}/fimi-matches`);
},
getFimiSummary(incidentId) {
return this._request('GET', `/incidents/${incidentId}/fimi-summary`);
},
getPipeline(incidentId) {
return this._request('GET', `/incidents/${incidentId}/pipeline`);
},
@@ -330,7 +339,7 @@ const API = {
resetTutorialState() {
return this._request('DELETE', '/tutorial/state');
},
exportReport(id, format, scope, sections) {
exportReport(id, format, scope, sections, includeBranding, creator) {
const token = localStorage.getItem('osint_token');
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
if (sections && sections.length > 0) {
@@ -338,6 +347,12 @@ const API = {
} else if (scope) {
url += `&scope=${scope}`;
}
if (includeBranding === false) {
url += `&branding=off`;
}
if (creator) {
url += `&creator=${encodeURIComponent(creator)}`;
}
return fetch(url, {
headers: { 'Authorization': `Bearer ${token}` },
});

Datei anzeigen

@@ -884,6 +884,9 @@ const App = {
// Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite)
this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err));
// FIMI: Treffer pro Artikel + Lagebild-Aggregat (Counter-Disinformation)
this._loadFimiData(id).catch(err => console.warn('fimi-data:', err));
// Wenn mehr Artikel existieren als initial geladen: progressiver Hintergrund-Load
if (articlesTotal > articles.length) {
this._loadRemainingArticlesInBackground(id).catch(err => console.warn('bg-articles:', err));
@@ -909,6 +912,64 @@ const App = {
}
},
/** FIMI-Daten der Lage laden: Treffer pro Artikel + Aggregat fuers Lagebild. */
async _loadFimiData(incidentId) {
let matches = {}, summary = null;
try {
const [m, s] = await Promise.all([
API.getFimiMatches(incidentId),
API.getFimiSummary(incidentId),
]);
matches = (m && m.matches_by_article) || {};
summary = s || null;
} catch (err) {
console.warn('fimi-data:', err);
return;
}
if (this.currentIncidentId !== incidentId) return; // User hat gewechselt
this._currentFimiMatches = matches;
this._currentFimiSummary = summary;
this._renderFimiSummaryBar();
},
/** Andockpunkt 3: Qualitaetsleiste ins Lagebild rendern. */
_renderFimiSummaryBar() {
const host = document.getElementById('fimi-summary-bar');
if (!host || typeof UI.renderFimiSummaryBar !== 'function') return;
host.innerHTML = UI.renderFimiSummaryBar(this._currentFimiSummary);
},
/** Narrative-Liste in der FIMI-Qualitaetsleiste auf-/zuklappen. */
toggleFimiDetail(btn) {
const bar = btn.closest('.fimi-summary-bar');
if (!bar) return;
const list = bar.querySelector('.fimi-summary-claims');
if (!list) return;
const open = list.style.display !== 'none';
list.style.display = open ? 'none' : '';
btn.textContent = open ? 'Narrative anzeigen' : 'Narrative verbergen';
},
/** Quellenuebersicht der Lage nach Quellentyp filtern (Web/Telegram/X). */
filterSourceOverview(type, chipEl) {
const content = document.getElementById('source-overview-content');
if (!content) return;
content.querySelectorAll('.source-type-filter-chip').forEach(c => c.classList.remove('active'));
if (chipEl) chipEl.classList.add('active');
// ein offenes Detail-Panel schliessen
const det = content.querySelector('.source-overview-detail');
if (det) det.remove();
content.querySelectorAll('.source-overview-item.active').forEach(it => {
it.classList.remove('active');
it.setAttribute('aria-expanded', 'false');
});
// Quellen-Boxen nach Typ ein-/ausblenden
content.querySelectorAll('.source-overview-item').forEach(it => {
const t = it.dataset.type || 'web';
it.style.display = (!type || t === type) ? '' : 'none';
});
},
/** Klick auf eine Quellen-Box: Liste der Artikel inline aufklappen (mutual-exclusive). */
toggleSourceOverviewDetail(el) {
if (!el) return;
@@ -989,10 +1050,16 @@ const App = {
const inner = a.source_url
? `<a href="${UI.escape(a.source_url)}" target="_blank" rel="noopener">${headline}</a>`
: headline;
return `<li>
// Andockpunkt 1: FIMI-Hinweis, falls dieser Artikel eine widerlegte
// Behauptung verbreitet. Kein Match -> keine Zeile, kein Ballast.
const fimiMatches = (this._currentFimiMatches || {})[String(a.id)];
const fimiHint = (fimiMatches && typeof UI.renderFimiHint === 'function')
? UI.renderFimiHint(fimiMatches) : '';
return `<li${fimiMatches ? ' class="has-fimi-hint"' : ''}>
${numHtml}
<span class="source-overview-detail-date">${UI.escape(dateStr)}</span>
<span class="source-overview-detail-headline">${inner}</span>
${fimiHint}
</li>`;
}).join('');
detail.innerHTML = `<ul class="source-overview-detail-list">${items}</ul>`;
@@ -1131,6 +1198,26 @@ const App = {
: '';
}
// Öffentliche Stimmung (Foren-Kachel): Tab + Inhalt nur einblenden,
// wenn fuer diese Lage tatsaechlich Stimmungs-Text vorhanden ist.
const stimmungTabBtn = document.getElementById('tab-btn-stimmung');
const stimmungText = document.getElementById('stimmung-text');
const stimmungTs = document.getElementById('stimmung-timestamp');
const moodText = (incident.public_mood || '').trim();
if (moodText && stimmungTabBtn) {
stimmungTabBtn.style.display = '';
if (stimmungText) stimmungText.innerHTML = UI.renderPublicMood(moodText);
if (stimmungTs && incident.public_mood_updated_at) {
const mUpd = parseUTC(incident.public_mood_updated_at);
if (mUpd) {
stimmungTs.textContent = `Stand: ${mUpd.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: TIMEZONE })} ${mUpd.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE })} Uhr`;
}
}
} else if (stimmungTabBtn) {
stimmungTabBtn.style.display = 'none';
if (stimmungText) stimmungText.innerHTML = '';
}
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) {
if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
const intervalText = App._formatInterval(incident.refresh_interval);
@@ -1811,6 +1898,7 @@ const App = {
retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
international_sources: document.getElementById('inc-international').checked,
include_telegram: document.getElementById('inc-telegram').checked,
include_x: document.getElementById('inc-x').checked,
visibility: document.getElementById('inc-visibility').checked ? 'public' : 'private',
};
},
@@ -2246,6 +2334,7 @@ async handleRefresh() {
{ const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; }
{ const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; }
{ const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
{ const _e = document.getElementById('inc-x'); if (_e) _e.checked = !!incident.include_x; }
{ const _e = document.getElementById('inc-visibility'); if (_e) _e.checked = incident.visibility !== 'private'; }
updateVisibilityHint();
@@ -2595,6 +2684,9 @@ async handleRefresh() {
return;
}
const format = document.querySelector('input[name="export-format"]:checked').value;
const brandingEl = document.querySelector('input[name="export-branding"]:checked');
const includeBranding = !brandingEl || brandingEl.value === 'on';
const ersteller = (document.getElementById('export-ersteller')?.value || '').trim();
const btn = document.getElementById('export-submit-btn');
const origText = btn.textContent;
@@ -2602,7 +2694,7 @@ async handleRefresh() {
btn.textContent = (typeof T === 'function' ? T('action.creating', 'Wird erstellt...') : 'Wird erstellt...');
try {
const response = await API.exportReport(this.currentIncidentId, format, null, sections);
const response = await API.exportReport(this.currentIncidentId, format, null, sections, includeBranding, ersteller);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler ' + response.status);
@@ -2775,12 +2867,14 @@ async handleRefresh() {
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
const web = stats.by_type.web_source || { count: 0, articles: 0 };
const tg = stats.by_type.telegram_channel || { count: 0, articles: 0 };
const x = stats.by_type.x_account || { count: 0, articles: 0 };
const excluded = this._myExclusions.length;
bar.innerHTML = `
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.rss', 'RSS-Feeds') : 'RSS-Feeds')}</span>
<span class="sources-stat-item"><span class="sources-stat-value">${web.count}</span> ${(typeof T === 'function' ? T('sources_modal.stats.web', 'Web-Quellen') : 'Web-Quellen')}</span>
<span class="sources-stat-item"><span class="sources-stat-value">${tg.count}</span> Telegram</span>
<span class="sources-stat-item"><span class="sources-stat-value">${x.count}</span> X</span>
<span class="sources-stat-item"><span class="sources-stat-value">${excluded}</span> ${(typeof T === 'function' ? T('sources_modal.stats.excluded', 'Ausgeschlossen') : 'Ausgeschlossen')}</span>
<span class="sources-stat-item"><span class="sources-stat-value">${stats.total_articles}</span> Artikel gesamt</span>
`;
@@ -3226,6 +3320,31 @@ async handleRefresh() {
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; }
return;
}
// X (Twitter)-URLs direkt behandeln (kein Discovery noetig)
if (urlVal.match(/^(https?:\/\/)?(x\.com|twitter\.com)\//i)) {
const handle = urlVal
.replace(/^(https?:\/\/)?(x\.com|twitter\.com)\//i, '')
.replace(/\/$/, '')
.split(/[/?]/)[0]
.replace(/^@/, '');
const xUrl = 'x.com/' + handle;
this._discoveredData = {
name: '@' + handle,
domain: xUrl,
source_type: 'x_account',
rss_url: null,
};
document.getElementById('src-name').value = '@' + handle;
document.getElementById('src-type-select').value = 'x_account';
document.getElementById('src-type-display').value = 'X (Twitter)';
document.getElementById('src-domain').value = xUrl;
document.getElementById('src-rss-url-group').style.display = 'none';
document.getElementById('src-discovery-result').style.display = 'block';
const saveBtnX = document.querySelector('#src-discovery-result .sources-discovery-actions .btn-primary');
if (saveBtnX) { saveBtnX.disabled = false; saveBtnX.textContent = 'Speichern'; }
return;
}
const url = urlInput.value.trim();
if (!url) {
UI.showToast('Bitte URL oder Domain eingeben.', 'warning');
@@ -3345,7 +3464,7 @@ async handleRefresh() {
document.getElementById('src-notes').value = source.notes || '';
document.getElementById('src-domain').value = source.domain || '';
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : 'Web-Quelle';
const typeLabel = source.source_type === 'rss_feed' ? 'RSS-Feed' : source.source_type === 'telegram_channel' ? 'Telegram' : source.source_type === 'x_account' ? 'X (Twitter)' : 'Web-Quelle';
const typeSelect = document.getElementById('src-type-select');
if (typeSelect) typeSelect.value = source.source_type || 'web_source';
document.getElementById('src-type-display').value = typeLabel;
@@ -3389,7 +3508,7 @@ async handleRefresh() {
name,
source_type: discovered.source_type || 'web_source',
category: document.getElementById('src-category').value,
url: discovered.rss_url || (discovered.source_type === 'telegram_channel' ? (document.getElementById('src-domain').value || null) : null),
url: discovered.rss_url || ((discovered.source_type === 'telegram_channel' || discovered.source_type === 'x_account') ? (document.getElementById('src-domain').value || null) : null),
domain: document.getElementById('src-domain').value.trim() || discovered.domain || null,
notes: document.getElementById('src-notes').value.trim() || null,
};

Datei anzeigen

@@ -813,6 +813,26 @@ const UI = {
return html;
},
/**
* Rendert die "Öffentliche Stimmung"-Kachel.
* Eingabe ist Markdown mit "- "-Bullets (vom AnalyzerAgent.generate_public_mood).
* Quellen-Pills brauchen wir hier nicht — die Bullet-Texte nennen die Foren-Herkunft
* explizit ("auf 5ch /seiji/ ...", "Hatena-Kommentare betonen ...").
*/
renderPublicMood(text) {
if (!text) return '<span style="color:var(--text-disabled);">Noch kein Stimmungsbild erfasst.</span>';
const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l.startsWith("- "));
if (bulletLines.length === 0) {
// Fliesstext-Fallback: HTML-escapen + Zeilenumbrueche
return this.escape(text).replace(/\n/g, '<br>');
}
const items = bulletLines.map(l => {
const body = l.replace(/^-\s+/, '');
return `<li>${this.escape(body)}</li>`;
}).join('');
return `<ul style="margin:4px 0 4px 18px;line-height:1.7;">${items}</ul>`;
},
/**
* Rendert "Neueste Entwicklungen" für Live-Monitoring (adhoc).
* Erwartet Bullets im Format "- [DD.MM. HH:MM] Text {Quelle1, Quelle2}".
@@ -1014,12 +1034,38 @@ const UI = {
html += `<div class="source-lang-chips">${langChips}</div>`;
html += `</div>`;
// Typ-Filter-Chips: immer zeigen, sobald Quellen vorhanden sind. Die Leiste
// zeigt zugleich auf einen Blick, welche Quellentypen der Fall enthaelt.
const typeCounts = { web: 0, telegram: 0, x: 0 };
data.sources.forEach(s => {
const t = s.source_type || 'web';
typeCounts[t] = (typeCounts[t] || 0) + 1;
});
const typeMeta = [
{ key: '', label: 'Alle', count: data.sources.length },
{ key: 'web', label: 'Web', count: typeCounts.web },
{ key: 'telegram', label: 'Telegram', count: typeCounts.telegram },
{ key: 'x', label: 'X', count: typeCounts.x },
];
const chips = typeMeta
.filter(t => t.key === '' || t.count > 0)
.map(t => `<button type="button" class="source-type-filter-chip${t.key === '' ? ' active' : ''}" data-type="${t.key}" onclick="App.filterSourceOverview('${t.key}', this)">${t.label} <strong>${t.count}</strong></button>`)
.join('');
html += `<div class="source-type-filter-chips">${chips}</div>`;
html += '<div class="source-overview-grid">';
data.sources.forEach(s => {
const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/');
const sourceName = this.escape(s.source || 'Unbekannt');
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);}">
const sType = s.source_type || 'web';
// Andockpunkt 2: empirischer Track-Record. Nur bei Treffern, dezent.
const fimiN = s.fimi_match_count || 0;
const fimiBadge = fimiN > 0
? `<span class="fimi-source-badge" title="${fimiN} ${fimiN === 1 ? 'Artikel dieser Quelle deckt' : 'Artikel dieser Quelle decken'} sich mit einer bei EUvsDisinfo widerlegten Falschbehauptung">${fimiN}&nbsp;FIMI</span>`
: '';
html += `<div class="source-overview-item${fimiN > 0 ? ' has-fimi' : ''}" data-source="${sourceName}" data-type="${sType}" 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">${sourceName}</span>
${fimiBadge}
<span class="source-overview-lang">${langs}</span>
<span class="source-overview-count">${s.article_count}</span>
</div>`;
@@ -1029,6 +1075,79 @@ const UI = {
return html;
},
/**
* Andockpunkt 1: dezenter Inline-Hinweis an einem Artikel, der sich mit
* einer bei EUvsDisinfo widerlegten Falschbehauptung deckt. Provenienz-
* Leitplanke: nennt die Quelle (EUvsDisinfo), verlinkt den Case, wertet
* nicht selbst. matches: Array aus dem fimi-matches-Endpunkt.
*/
renderFimiHint(matches) {
if (!matches || matches.length === 0) return '';
const n = matches.length;
const top = matches[0];
const claimText = this.escape(top.claim_text || '');
const passage = top.passage ? this.escape(top.passage) : '';
let tip = `Bei EUvsDisinfo als widerlegt geführte Behauptung: ${claimText}`;
if (passage) tip += ` | Im Artikel: ${passage}`;
tip += ' | Quelle der Einordnung: EUvsDisinfo (EEAS East StratCom Task Force), keine offizielle EU-Position.';
const label = n === 1
? 'Deckt sich mit einer von EUvsDisinfo widerlegten Falschbehauptung'
: `Deckt sich mit ${n} von EUvsDisinfo widerlegten Falschbehauptungen`;
const link = top.case_url
? `<a href="${this.escape(top.case_url)}" target="_blank" rel="noopener" class="fimi-hint-link" onclick="event.stopPropagation()">Beleg ansehen</a>`
: '';
return `<div class="fimi-hint" title="${tip}">
<span class="fimi-hint-icon" aria-hidden="true">&#9888;</span>
<span class="fimi-hint-text">${label}</span>
${link}
</div>`;
},
/**
* Andockpunkt 3: Qualitaetsachse fuers Lagebild. Verdichtet die
* Einzeltreffer auf Lage-Ebene. Bei 0 Treffern eine ruhige Entwarnung,
* sonst eine zurueckhaltende Hinweisleiste mit aufklappbaren Narrativen.
*/
renderFimiSummaryBar(s) {
if (!s || !s.articles_checked) return '';
const matched = s.articles_with_match || 0;
const checked = s.articles_checked || 0;
const distinct = s.distinct_claims || 0;
if (matched === 0) {
return `<div class="fimi-summary-bar fimi-summary-bar--clear">
<span class="fimi-summary-icon" aria-hidden="true">&#10003;</span>
<span>Keine bekannten Falschbehauptungen unter ${checked} geprüften Artikeln.</span>
</div>`;
}
const topClaims = (s.top_claims || []).slice(0, 6);
const claimList = topClaims.map(c => {
const txt = this.escape(c.claim_text || '');
const link = c.case_url
? `<a href="${this.escape(c.case_url)}" target="_blank" rel="noopener" class="fimi-hint-link">Beleg</a>`
: '';
return `<li><span class="fimi-claim-count">${c.article_count}&times;</span> <span class="fimi-claim-text">${txt}</span> ${link}</li>`;
}).join('');
return `<div class="fimi-summary-bar fimi-summary-bar--alert">
<div class="fimi-summary-head">
<span class="fimi-summary-icon" aria-hidden="true">&#9888;</span>
<span class="fimi-summary-lead"><strong>${matched}</strong> von ${checked} geprüften Artikeln decken sich mit <strong>${distinct}</strong> bei EUvsDisinfo widerlegten Falschbehauptungen.</span>
<button type="button" class="fimi-summary-toggle" onclick="App.toggleFimiDetail(this)">Narrative anzeigen</button>
</div>
<ul class="fimi-summary-claims" style="display:none;">${claimList}</ul>
${this.fimiDisclaimerHtml()}
</div>`;
},
/**
* Pflicht-Quellenhinweis fuer EUvsDisinfo-Einordnungen. Dezent (kleine
* graue Fusszeile), aber praesent: Attribution an EEAS East StratCom
* Task Force + der offizielle Disclaimer, dass es keine offizielle
* EU-Position ist (Wortlaut der EUvsDisinfo-Veroeffentlichungen).
*/
fimiDisclaimerHtml() {
return `<div class="fimi-disclaimer">Einordnungen aus der <a href="https://euvsdisinfo.eu/" target="_blank" rel="noopener">EUvsDisinfo</a>-Datenbank des Europäischen Auswärtigen Dienstes (EEAS East StratCom Task Force). Sie beruhen auf Medienbeobachtung und Analyse der Task Force und stellen keine offizielle Position der EU dar.</div>`;
},
renderSourceOverview(articles) {
if (!articles || articles.length === 0) return '';
@@ -1190,6 +1309,10 @@ const UI = {
/**
* Domain-Gruppe rendern (aufklappbar mit Feeds).
*/
_sourceTypeLabel(type) {
return ({ rss_feed: 'RSS', web_source: 'Web', telegram_channel: 'Telegram', x_account: 'X', podcast_feed: 'Podcast', excluded: 'Ausgeschlossen' })[type] || 'Web';
},
renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
@@ -1224,7 +1347,7 @@ const UI = {
realFeeds.forEach((feed, i) => {
const isLast = i === realFeeds.length - 1;
const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web';
const typeLabel = this._sourceTypeLabel(feed.source_type);
const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
feedRows += `<div class="source-feed-row">
<span class="source-feed-connector">${connector}</span>
@@ -1253,7 +1376,7 @@ const UI = {
|| firstFeed.country_code
|| (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0);
if (hasInfo) {
const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', podcast_feed: 'Podcast' };
const typeMap = { rss_feed: 'RSS-Feed', web_source: 'Web-Quelle', telegram_channel: 'Telegram-Kanal', x_account: 'X (Twitter)', podcast_feed: 'Podcast' };
const lines = [];
lines.push('Typ: ' + (typeMap[firstFeed.source_type] || firstFeed.source_type || 'Unbekannt'));
if (firstFeed.language) lines.push('Sprache: ' + firstFeed.language);
@@ -1294,6 +1417,7 @@ const UI = {
<div class="source-group-info">
<span class="source-group-name">${this.escape(displayName)}</span>${infoButtonHtml}
</div>
${!hasMultiple ? `<span class="source-type-badge type-${feeds[0]?.source_type || ''}">${this._sourceTypeLabel(feeds[0]?.source_type)}</span>` : ''}
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${classificationBadges ? `<span class="source-classification-badges">${classificationBadges}</span>` : ''}
${feedCountBadge}