Dateien
AegisSight-Monitor/src/agents/orchestrator.py
claude-dev 0154c0f575 fix(eu): Kapazitaetsengpaesse bei Bedrock aussitzen statt Schritte verlieren
Am 01.08.2026 lieferte Bedrock waehrend eines Laufs ServiceUnavailableException.
Botocore versuchte es dreimal in fuenf Sekunden und gab auf, die
Web-Source-Selektion fiel ersatzlos aus. Im Bericht war davon nichts zu sehen,
der Lauf galt als vollstaendig. Gemessen sind es zwei betroffene Aufrufe bei
rund 340, also 0,6 Prozent, aber jeder Vorfall kostet einen ganzen
Pipeline-Schritt.

Der Fehler ist auch keiner unserer Quote. ServiceUnavailableException ist ein
503, AWS hat also gerade keine Kapazitaet fuer das Modell. Unsere Meldung
sagte trotzdem "Rate-Limit", weil der Code beide Faelle in einen Topf warf.

Aendert sich damit:

1. Geduldiges Wiederholen. Kapazitaets- (503), Kontingent- (429) und
   Verbindungsfehler werden nach 5, 15 und 30 Sekunden erneut versucht. Diese
   Dellen dauern typischerweise unter einer Minute, unsere bisherigen fuenf
   Sekunden lagen genau im ungeguenstigsten Fenster. Stufen ueber
   BEDROCK_RETRY_WAITS einstellbar.

2. Zwei Bremsen gegen lange Laeufe. Gewartet wird nur, wenn die Wartezeit ins
   Zeitbudget des Aufrufs passt (die Wartezeit zaehlt gegen dasselbe
   asyncio-Budget, und die Haiku-Planungsaufrufe haben nur 120 Sekunden), und
   nur solange das Wartebudget des Refreshs reicht (BEDROCK_RETRY_BUDGET_S,
   Vorgabe 120 Sekunden). Ein Lauf kann sich dadurch um hoechstens zwei
   Minuten verlaengern.

3. Ursachen werden getrennt benannt. Im Log steht jetzt "hat keine freie
   Kapazitaet" oder "meldet ausgeschoepftes Kontingent" statt pauschal
   "Rate-Limit". Nach aussen bleibt beides die Kategorie rate_limit, damit die
   Retry-Steuerung des Orchestrators unveraendert greift.

4. Stoerungen werden sichtbar. Waehrend einer Wartezeit meldet die Oberflaeche
   "KI-Dienst hat keine freie Kapazitaet, neuer Versuch in 15 Sekunden", sonst
   sieht die stehende Anzeige wie ein Haenger aus. Nach dem Lauf steht eine
   Zusammenfassung im Refresh-Protokoll, auch wenn der Lauf sonst glatt
   durchlief.

5. botocore laeuft im Modus 'adaptive' statt 'standard'. Es bremst sich bei
   Drosselung selbst ein und deckt die Sekunden ab, unsere Schleife die
   Zehnersekunden.

Zeitliche Wirkung, gemessen am EU-Lauf der Ceuta-Lage (252 Sekunden, 15
Bedrock-Aufrufe): im stoerungsfreien Fall null, im betroffenen Lauf rund eine
Minute mehr. Bei 0,6 Prozent Fehlerrate je Aufruf trifft das etwa jeden
elften Lauf, im Mittel ueber alle Laeufe rund zwei Prozent.

Gegenprobe mit einem echten Bedrock-Aufruf ueber den geaenderten Pfad: Antwort,
Verbrauch und Kosten unveraendert, keine Stoerung protokolliert.

Neu sind 23 Pruefungen, insgesamt laufen 179 ohne Netzzugriff und ohne Kosten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:58:50 +02:00

2402 Zeilen
121 KiB
Python

"""Agenten-Orchestrierung: Queue und Steuerung der Claude-Agenten."""
import asyncio
import json
import logging
import os
import re
from datetime import datetime
from config import TIMEZONE
from typing import Optional
from urllib.parse import urlparse, urlunparse, quote_plus
import httpx
from agents.claude_client import UsageAccumulator, _cancel_event_var, _ai_backend_var
from agents import bedrock_client
from agents.factchecker import find_matching_claim, deduplicate_new_facts, TWOPHASE_MIN_FACTS
from source_rules import (
_detect_category,
_extract_domain,
discover_source,
domain_to_display_name,
)
logger = logging.getLogger("osint.orchestrator")
# Reputations-Score nach Quellenkategorie (fuer Relevanz-Scoring).
# Keys muessen mit den tatsaechlichen DB-Werten in sources.category uebereinstimmen
# (siehe DOMAIN_CATEGORY_MAP in source_rules.py).
CATEGORY_REPUTATION = {
"nachrichtenagentur": 1.0, # Reuters, AP, dpa, AFP — Primärquellen
"behoerde": 1.0, # BMI, BSI, Europol — offizielle Quellen
"oeffentlich-rechtlich": 0.95, # tagesschau, ZDF, ARD, BBC, ORF
"qualitaetszeitung": 0.85, # Spiegel, Zeit, FAZ, NZZ, Süddeutsche
"think-tank": 0.85, # SWP, IISS, Brookings, Chatham House
"fachmedien": 0.8, # heise, golem, netzpolitik, Handelsblatt
"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.
}
# Research-Modus: Automatisch 3 Durchläufe für optimale Ergebnisse
RESEARCH_MULTI_PASS_COUNT = 3
RESEARCH_PASS_LABELS = {1: "Breite Erfassung", 2: "Vertiefung", 3: "Konsolidierung"}
def _normalize_url(url: str) -> str:
"""URL normalisieren für Duplikat-Erkennung."""
if not url:
return ""
url = url.strip()
try:
parsed = urlparse(url)
# Scheme normalisieren
scheme = parsed.scheme.lower() or "https"
# Host normalisieren (www entfernen, lowercase)
netloc = parsed.netloc.lower()
if netloc.startswith("www."):
netloc = netloc[4:]
# Pfad normalisieren (trailing slash entfernen)
path = parsed.path.rstrip("/")
# Query-Parameter und Fragment entfernen (Tracking-Params etc.)
return urlunparse((scheme, netloc, path, "", "", ""))
except Exception:
return url.lower().strip().rstrip("/")
def _normalize_headline(headline: str) -> str:
"""Überschrift normalisieren für Ähnlichkeitsvergleich."""
if not headline:
return ""
h = headline.lower().strip()
# Umlaute normalisieren
h = h.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
# Sonderzeichen entfernen
h = re.sub(r"[^\w\s]", "", h)
h = re.sub(r"\s+", " ", h).strip()
return h
def _is_duplicate(article: dict, seen_urls: set, seen_headlines: set) -> bool:
"""Prüft ob ein Artikel ein Duplikat ist (URL oder Headline)."""
url = article.get("source_url", "")
headline = article.get("headline", "")
# URL-Duplikat
if url:
norm_url = _normalize_url(url)
if norm_url in seen_urls:
return True
seen_urls.add(norm_url)
# Headline-Duplikat (nur wenn Überschrift lang genug)
if headline and len(headline) > 20:
norm_headline = _normalize_headline(headline)
if norm_headline and norm_headline in seen_headlines:
return True
if norm_headline:
seen_headlines.add(norm_headline)
return False
def _score_relevance(article: dict, search_words: list[str] = None) -> float:
"""Berechnet einen Relevanz-Score (0.0-1.0) für einen Artikel.
Gewichtung:
- 40% Keyword-Dichte (wie gut passt der Artikel zum Suchbegriff)
- 30% Quellen-Reputation (basierend auf Kategorie)
- 20% Inhaltstiefe (hat der Artikel substantiellen Inhalt)
- 10% RSS-Score (falls vorhanden, vom RSS-Parser)
"""
score = 0.0
# 1. Keyword-Dichte (40%)
rss_score = article.get("relevance_score", 0.0)
if rss_score > 0:
score += 0.4 * rss_score
elif search_words:
text = f"{article.get('headline', '')} {article.get('content_original', '')}".lower()
match_count = sum(1 for w in search_words if w in text)
score += 0.4 * (match_count / len(search_words)) if search_words else 0.0
# 2. Quellen-Reputation (30%)
source_url = article.get("source_url", "")
if source_url:
domain = _extract_domain(source_url)
category = _detect_category(domain)
score += 0.3 * CATEGORY_REPUTATION.get(category, 0.4)
else:
score += 0.3 * 0.4 # Unbekannte Quelle
# 3. Inhaltstiefe (20%)
content = article.get("content_original") or article.get("content_de") or ""
if len(content) > 500:
score += 0.2
elif len(content) > 200:
score += 0.1
elif len(content) > 50:
score += 0.05
# 4. RSS-Score Bonus (10%)
score += 0.1 * rss_score
return min(1.0, score)
async def _verify_article_urls(
articles: list[dict],
concurrency: int = 10,
timeout: float = 8.0,
) -> list[dict]:
"""Prueft WebSearch-URLs auf Erreichbarkeit. Ersetzt unerreichbare URLs durch Suchlinks."""
if not articles:
return []
sem = asyncio.Semaphore(concurrency)
results: list[dict | None] = [None] * len(articles)
async def _check(idx: int, article: dict, client: httpx.AsyncClient):
url = article.get("source_url", "").strip()
if not url:
results[idx] = article # Kein URL -> behalten (wird eh nicht verlinkt)
return
async with sem:
try:
resp = await client.head(url)
if resp.status_code == 405:
# Manche Server unterstuetzen kein HEAD
resp = await client.get(url, headers={"Range": "bytes=0-0"})
if 200 <= resp.status_code < 400:
results[idx] = article
return
# 404 oder anderer Fehler -> Fallback-Suchlink
logger.info(f"URL-Verifizierung: {resp.status_code} fuer {url}")
except Exception as e:
logger.debug(f"URL-Verifizierung fehlgeschlagen fuer {url}: {e}")
# Fallback: Google-Suchlink aus Headline + Source-Domain
headline = article.get("headline", "")
source = article.get("source", "")
domain = ""
try:
from urllib.parse import urlparse as _urlparse
domain = _urlparse(url).netloc
except Exception:
pass
if headline:
search_query = f"site:{domain} {headline}" if domain else f"{source} {headline}"
fallback_url = f"https://www.google.com/search?q={quote_plus(search_query)}"
article_copy = dict(article)
article_copy["source_url"] = fallback_url
article_copy["_url_repaired"] = True
results[idx] = article_copy
logger.info(f"URL-Fallback: {url} -> Google-Suche fuer \"{headline[:60]}...\"")
else:
results[idx] = article # Kein Headline -> Original behalten
async with httpx.AsyncClient(
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 (compatible; AegisSight-Monitor/1.0)"},
) as client:
await asyncio.gather(*[_check(i, a, client) for i, a in enumerate(articles)])
verified = [r for r in results if r is not None]
repaired = sum(1 for r in verified if r.get("_url_repaired"))
ok = len(verified) - repaired
if repaired > 0:
logger.warning(
f"URL-Verifizierung: {ok} OK, {repaired} durch Suchlinks ersetzt "
f"(von {len(articles)} WebSearch-Artikeln)"
)
else:
logger.info(f"URL-Verifizierung: Alle {len(articles)} WebSearch-URLs erreichbar")
return verified
async def _background_discover_sources(articles: list[dict]):
"""Background-Task: Registriert seriöse, unbekannte Quellen aus Recherche-Ergebnissen."""
from database import get_db
db = await get_db()
try:
# 1. Unique Domains extrahieren
seen_domains: set[str] = set()
domains_to_check: list[tuple[str, str, str]] = []
for article in articles:
url = article.get("source_url")
if not url:
continue
domain = _extract_domain(url)
if not domain or domain in seen_domains:
continue
seen_domains.add(domain)
# 2. Nur seriöse Domains (in DOMAIN_CATEGORY_MAP, nicht "sonstige")
category = _detect_category(domain)
if category == "sonstige":
continue
domains_to_check.append((domain, url, category))
if not domains_to_check:
return
# 3. Gegen DB prüfen — welche Domains existieren schon?
new_count = 0
for domain, url, category in domains_to_check:
cursor = await db.execute(
"SELECT id FROM sources WHERE LOWER(domain) = ?",
(domain.lower(),),
)
if await cursor.fetchone():
continue # Domain schon bekannt
# 4. RSS-Feed-Erkennung
try:
result = await discover_source(url)
name = domain_to_display_name(domain)
source_type = result["source_type"] # "rss_feed" oder "web_source"
feed_url = result.get("rss_url")
await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by)
VALUES (?, ?, ?, ?, ?, 'active', 'Auto-entdeckt via Recherche', 'system')""",
(name, feed_url or f"https://{domain}", domain, source_type, category),
)
new_count += 1
logger.info(f"Neue Quelle registriert: {name} ({domain}) als {source_type}")
except Exception as e:
logger.debug(f"Discovery fehlgeschlagen für {domain}: {e}")
if new_count > 0:
await db.commit()
logger.info(f"Background-Discovery: {new_count} neue Quellen registriert")
except Exception as e:
logger.warning(f"Background-Discovery Fehler: {e}")
finally:
await db.close()
async def _create_notifications_for_incident(
db, incident_id: int, visibility: str, created_by: int, tenant_id: int, notifications: list[dict]
):
"""Erzeugt DB-Notifications fuer alle betroffenen Nutzer der Organisation.
- Oeffentliche Lagen -> alle Nutzer der Org
- Private Lagen -> nur Ersteller
"""
if not notifications:
return
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
if visibility == "public" and tenant_id:
cursor = await db.execute(
"SELECT id FROM users WHERE organization_id = ? AND is_active = 1 AND last_login_at IS NOT NULL",
(tenant_id,),
)
user_ids = [row["id"] for row in await cursor.fetchall()]
else:
user_ids = [created_by]
for user_id in user_ids:
for notif in notifications:
await db.execute(
"""INSERT INTO notifications (user_id, incident_id, type, title, text, icon, tenant_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
user_id,
incident_id,
notif.get("type", "refresh_summary"),
notif["title"],
notif["text"],
notif.get("icon", "info"),
tenant_id,
now,
),
)
await db.commit()
logger.info(f"Notifications erstellt: {len(notifications)} x {len(user_ids)} Nutzer fuer Lage {incident_id}")
async def _send_email_notifications_for_incident(
db, incident_id: int, incident_title: str, visibility: str,
created_by: int, tenant_id: int, notifications: list[dict],
incident_type: str = "adhoc",
):
"""Sendet E-Mail-Benachrichtigungen basierend auf individuellen Nutzer-Abos.
Jeder Nutzer hat eigene E-Mail-Praeferenzen pro Lage (incident_subscriptions).
Nur Nutzer die aktiv sind und sich mindestens einmal eingeloggt haben
(last_login_at IS NOT NULL) werden beruecksichtigt.
"""
if not notifications:
return
from email_utils.sender import send_email
from email_utils.templates import incident_notification_email
from config import MAGIC_LINK_BASE_URL
from services.org_settings import get_org_language
# Sprache der Org bestimmen (die Lage gehoert genau einer Org)
org_lang_iso = await get_org_language(db, tenant_id) if tenant_id else "de"
# Alle Nutzer mit aktiven Abos fuer diese Lage laden
cursor = await db.execute(
"""SELECT s.notify_email_summary, s.notify_email_new_articles,
s.notify_email_status_change, u.id, u.email, u.username
FROM incident_subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.incident_id = ?
AND u.is_active = 1
AND u.last_login_at IS NOT NULL
AND (s.notify_email_summary = 1
OR s.notify_email_new_articles = 1
OR s.notify_email_status_change = 1)""",
(incident_id,),
)
subscribers = await cursor.fetchall()
if not subscribers:
return
dashboard_url = f"{MAGIC_LINK_BASE_URL}/dashboard"
for sub in subscribers:
prefs = dict(sub)
# Relevante Notifications basierend auf Nutzer-Praeferenzen filtern
filtered_notifications = []
for n in notifications:
ntype = n.get("type", "refresh_summary")
if ntype == "refresh_summary" and prefs.get("notify_email_summary"):
filtered_notifications.append(n)
elif ntype == "new_articles" and prefs.get("notify_email_new_articles"):
filtered_notifications.append(n)
elif ntype == "status_change" and prefs.get("notify_email_status_change"):
filtered_notifications.append(n)
if not filtered_notifications:
continue
subject, html = incident_notification_email(
username=prefs["email"].split("@")[0],
incident_title=incident_title,
notifications=filtered_notifications,
dashboard_url=dashboard_url,
incident_type=incident_type,
lang=org_lang_iso,
)
try:
await send_email(prefs["email"], subject, html)
logger.info(f"E-Mail-Benachrichtigung gesendet an {prefs['email']} fuer Lage {incident_id} ({len(filtered_notifications)} Items)")
except Exception as e:
logger.error(f"E-Mail-Benachrichtigung fehlgeschlagen fuer {prefs['email']}: {e}")
class AgentOrchestrator:
"""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._running = False
self._ws_manager = None
# 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()
# 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):
"""Orchestrator aktivieren. Worker-Lanes werden pro Organisation bei der
ersten Anfrage angelegt (lazy)."""
self._running = True
logger.info("Agenten-Orchestrator gestartet")
async def stop(self):
"""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, collect_only: bool = False) -> bool:
"""Refresh-Auftrag in die Lane der Organisation stellen. Gibt False zurueck
wenn die Lage dort bereits wartet oder gerade laeuft.
collect_only=True (Studio-Baustein "Sammeln"): sammelt nur Artikel, ueberspringt
Analyse und Faktencheck.
"""
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)
# Doppelspur (EU-Umbau Phase 2). Eine Lane je Organisation UND KI-Weg.
# Ein CLI-Lauf und ein EU-Lauf derselben Organisation laufen dadurch
# wirklich gleichzeitig (getrennte Ressourcen, Abo gegen AWS), während
# Läufe über denselben Weg seriell bleiben (Rate-Limit-Schutz).
ai_backend = await self._resolve_ai_backend(incident_id)
lane_key = (tenant_id if tenant_id else self.PUBLIC_LANE, ai_backend)
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, collect_only))
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({
"type": "status_update",
"incident_id": incident_id,
"data": {"status": "queued", "queue_position": queue_size},
}, visibility, created_by, tenant_id)
return True
async def cancel_refresh(self, incident_id: int) -> bool:
"""Fordert Abbruch eines laufenden oder wartenden Refreshes an."""
# Laeuft die Lage gerade?
if incident_id in self._current_tasks:
self._cancel_requested.add(incident_id)
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:
vis, cb, tid = await self._get_incident_visibility(incident_id)
except Exception:
vis, cb, tid = "public", None, None
await self._ws_manager.broadcast_for_incident({
"type": "status_update",
"incident_id": incident_id,
"data": {"status": "cancelling", "detail": "Wird abgebrochen..."},
}, vis, cb, tid)
return True
# Wartet die Lage noch in ihrer Lane?
if incident_id in self._queued_ids:
self._queued_ids.discard(incident_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
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})")
# refresh_log-Eintrag schreiben, damit Auto-Refresh nicht im naechsten Tick erneut einreiht
await self._log_queued_cancellation(incident_id)
# Send cancelled event
if self._ws_manager:
try:
vis, cb, tid = await self._get_incident_visibility(incident_id)
except Exception:
vis, cb, tid = "public", None, None
await self._ws_manager.broadcast_for_incident({
"type": "refresh_cancelled",
"incident_id": incident_id,
"data": {"status": "cancelled"},
}, vis, cb, tid)
return True
return False
def _check_cancelled(self, incident_id: int):
"""Prüft ob Abbruch angefordert wurde und wirft CancelledError."""
if incident_id in self._cancel_requested:
self._cancel_requested.discard(incident_id)
raise asyncio.CancelledError("Vom Nutzer abgebrochen")
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(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) == 4:
incident_id, trigger_type, user_id, collect_only = item
elif len(item) == 3:
incident_id, trigger_type, user_id = item
collect_only = False
else:
incident_id, trigger_type = item
user_id = None
collect_only = False
self._queued_ids.discard(incident_id)
# Session-Start EINMAL setzen — bleibt ueber Multi-Pass/Retry hinweg stabil
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)
from agents.claude_client import ClaudeCliError
last_error = None
def _is_transient_cli(err: Exception) -> bool:
return isinstance(err, ClaudeCliError) and err.error_type in ("rate_limit", "timeout")
def _is_db_locked(err: Exception) -> bool:
# Seit der Doppelspur (Phase 2) koennen sich parallele Lanes beim
# Schreiben auf die SQLite-DB kurz blockieren. Das ist transient
# und einen Retry wert, kein permanenter Fehler.
import sqlite3 as _sqlite3
return isinstance(err, _sqlite3.OperationalError) and "locked" in str(err).lower()
# 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)
use_multi_pass = incident_type == "research" and not has_summary and not collect_only
for attempt in range(3):
try:
if use_multi_pass:
await self._run_research_multi_pass(incident_id, trigger_type=trigger_type, user_id=user_id)
else:
await self._run_refresh(incident_id, trigger_type=trigger_type, retry_count=attempt, user_id=user_id, collect_only=collect_only)
last_error = None
break # Erfolg
except asyncio.CancelledError:
logger.info(f"Refresh fuer Lage {incident_id} abgebrochen")
await self._mark_refresh_cancelled(incident_id)
try:
_vis, _cb, _tid = await self._get_incident_visibility(incident_id)
except Exception:
_vis, _cb, _tid = "public", None, None
if self._ws_manager:
await self._ws_manager.broadcast_for_incident({
"type": "refresh_cancelled",
"incident_id": incident_id,
"data": {"status": "cancelled"},
}, _vis, _cb, _tid)
last_error = None
break
except Exception as e:
# Auth/CLI-Fehler: sofort abbrechen, kein Retry sinnvoll
if isinstance(e, ClaudeCliError) and e.error_type in ("auth_error", "cli_error"):
last_error = e
logger.error(f"Permanenter Claude-Fehler [{e.error_type}] bei Lage {incident_id}: {e}")
await self._mark_refresh_failed(incident_id, str(e))
break
# Transiente Fehler: Retry bis 3x
if isinstance(e, TRANSIENT_ERRORS) or _is_transient_cli(e) or _is_db_locked(e):
last_error = e
kind = e.error_type if isinstance(e, ClaudeCliError) else type(e).__name__
logger.warning(f"Transienter Fehler [{kind}] bei Lage {incident_id} (Versuch {attempt + 1}/3): {e}")
if attempt < 2:
await self._mark_refresh_failed(incident_id, str(e))
delay = RETRY_DELAYS[attempt + 1]
logger.info(f"Retry in {delay}s für Lage {incident_id}")
if self._ws_manager:
try:
_vis, _cb, _tid = await self._get_incident_visibility(incident_id)
except Exception:
_vis, _cb, _tid = "public", None, None
await self._ws_manager.broadcast_for_incident({
"type": "status_update",
"incident_id": incident_id,
"data": {"status": "retrying", "attempt": attempt + 1, "delay": delay},
}, _vis, _cb, _tid)
await asyncio.sleep(delay)
continue
else:
await self._mark_refresh_failed(incident_id, f"Endgültig fehlgeschlagen nach 3 Versuchen: {e}")
break
# Alles andere: permanent
last_error = e
logger.error(f"Permanenter Fehler bei Refresh für Lage {incident_id}: {e}")
await self._mark_refresh_failed(incident_id, str(e))
break
if last_error and self._ws_manager:
try:
_vis, _cb, _tid = await self._get_incident_visibility(incident_id)
except Exception:
_vis, _cb, _tid = "public", None, None
await self._ws_manager.broadcast_for_incident({
"type": "refresh_error",
"incident_id": incident_id,
"data": {"error": str(last_error)},
}, _vis, _cb, _tid)
finally:
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)
_ai_backend_var.set(None)
queue.task_done()
async def _mark_refresh_cancelled(self, incident_id: int):
"""Markiert den laufenden Refresh-Log-Eintrag als cancelled und schliesst
alle noch aktiven Pipeline-Schritte. Ohne den zweiten Schritt blieb der
zuletzt aktive Step-Eintrag verwaist und das Frontend zeigte dauerhaft
'Schritt X laeuft', weil /api/incidents/<id>/pipeline aus
refresh_pipeline_steps liest."""
from database import get_db
from services.pipeline_tracker import cancel_active_steps
db = await get_db()
try:
now_str = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
cur = await db.execute(
"SELECT id FROM refresh_log WHERE incident_id = ? AND status = 'running'",
(incident_id,),
)
row = await cur.fetchone()
refresh_log_id = row["id"] if row else None
await db.execute(
"""UPDATE refresh_log SET status = 'cancelled', error_message = 'Vom Nutzer abgebrochen',
completed_at = ? WHERE incident_id = ? AND status = 'running'""",
(now_str, incident_id),
)
await db.commit()
if refresh_log_id is not None:
await cancel_active_steps(db, refresh_log_id=refresh_log_id)
except Exception as e:
logger.warning(f"Konnte Refresh-Log nicht als abgebrochen markieren: {e}")
finally:
await db.close()
async def _log_queued_cancellation(self, incident_id: int):
"""Schreibt einen cancelled-Eintrag fuer einen Queue-Abbruch (Lage war noch nicht laufend).
Verhindert, dass der Auto-Refresh-Scheduler im naechsten Tick sofort wieder einreiht."""
from database import get_db
db = await get_db()
try:
cur = await db.execute("SELECT tenant_id FROM incidents WHERE id = ?", (incident_id,))
row = await cur.fetchone()
tid = row["tenant_id"] if row else None
now_str = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
await db.execute(
"""INSERT INTO refresh_log (incident_id, started_at, completed_at, status,
trigger_type, error_message, tenant_id)
VALUES (?, ?, ?, 'cancelled', 'manual', 'Aus Warteschlange entfernt', ?)""",
(incident_id, now_str, now_str, tid),
)
await db.commit()
except Exception as e:
logger.warning(f"Konnte Queue-Cancel nicht in refresh_log loggen: {e}")
finally:
await db.close()
async def _mark_refresh_failed(self, incident_id: int, error: str):
"""Markiert den laufenden Refresh-Log-Eintrag als error."""
from database import get_db
db = await get_db()
try:
await db.execute(
"""UPDATE refresh_log SET status = 'error', error_message = ?,
completed_at = ? WHERE incident_id = ? AND status = 'running'""",
(error[:500], datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), incident_id),
)
await db.commit()
except Exception as e:
logger.warning(f"Konnte Refresh-Log nicht als fehlgeschlagen markieren: {e}")
finally:
await db.close()
async def _get_incident_visibility(self, incident_id: int) -> tuple[str, Optional[int], Optional[int]]:
"""Incident-Visibility, created_by und tenant_id laden."""
from database import get_db
visibility = "public"
created_by = None
tenant_id = None
db = await get_db()
try:
cursor = await db.execute(
"SELECT visibility, created_by, tenant_id FROM incidents WHERE id = ?", (incident_id,)
)
row = await cursor.fetchone()
if row:
visibility = row["visibility"] or "public"
created_by = row["created_by"]
tenant_id = row["tenant_id"]
finally:
await db.close()
return visibility, created_by, tenant_id
async def _resolve_ai_backend(self, incident_id: int) -> str:
"""Löst das KI-Backend einer Lage auf (EU-Umbau).
Lage (incidents.ai_backend) gewinnt vor dem Org-Setting 'ai_backend',
das vor dem globalen Default AI_BACKEND aus config. Wird beim Einreihen
(Lane-Wahl) und beim Refresh-Start (ContextVar) identisch genutzt."""
from config import AI_BACKEND
from database import get_db
from services.org_settings import get_org_setting
incident_backend = None
tenant_id = None
db = await get_db()
try:
cursor = await db.execute(
"SELECT ai_backend, tenant_id FROM incidents WHERE id = ?", (incident_id,)
)
row = await cursor.fetchone()
if row:
incident_backend = row["ai_backend"] if "ai_backend" in row.keys() else None
tenant_id = row["tenant_id"] if "tenant_id" in row.keys() else None
org_backend = await get_org_setting(db, tenant_id, "ai_backend", default=None) if tenant_id else None
finally:
await db.close()
ai_backend = (incident_backend or org_backend or AI_BACKEND or "cli").strip().lower()
if ai_backend not in ("cli", "bedrock"):
logger.warning("Unbekanntes ai_backend '%s' für Lage %s, fallback 'cli'", ai_backend, incident_id)
ai_backend = "cli"
return ai_backend
async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0, user_id: int = None, _suppress_complete: bool = False, _pass_info: dict = None, collect_only: bool = False):
"""Führt einen kompletten Refresh-Zyklus durch."""
import aiosqlite
from database import get_db
from agents.researcher import ResearcherAgent
from agents.analyzer import AnalyzerAgent
from agents.factchecker import FactCheckerAgent
from feeds.rss_parser import RSSParser
from services import pipeline_tracker as _pipe
db = await get_db()
try:
# Lage laden
cursor = await db.execute("SELECT * FROM incidents WHERE id = ?", (incident_id,))
incident = await cursor.fetchone()
if not incident:
logger.warning(f"Lage {incident_id} nicht gefunden")
return
title = incident["title"]
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, get_research_language,
get_source_language_whitelist, get_translator_enabled,
)
# KI-Backend für diesen Refresh auflösen und per ContextVar setzen
# (EU-Umbau, gemeinsame Auflösung mit der Lane-Wahl beim Einreihen,
# siehe _resolve_ai_backend).
ai_backend = await self._resolve_ai_backend(incident_id)
_ai_backend_var.set(ai_backend)
if ai_backend != "cli":
logger.info("Lage %s läuft über KI-Backend '%s'", incident_id, ai_backend)
# Wartebudget des EU-Wegs zuruecksetzen und die Oberflaeche an die
# Wartemeldungen anschliessen. Ohne diesen Hinweis sieht eine
# Wiederholung nach einem Kapazitaetsengpass wie ein Haenger aus,
# weil die Anzeige waehrend der Wartezeit stillsteht.
bedrock_client.refresh_kontext_starten()
if self._ws_manager:
def _warte_melden(art: str, sekunden: float, _iid=incident_id,
_vis=visibility, _cb=created_by, _tid=tenant_id) -> None:
text = bedrock_client._ART_TEXT.get(art, art)
asyncio.create_task(self._ws_manager.broadcast_for_incident({
"type": "status_update",
"incident_id": _iid,
"data": {
"status": "running",
"detail": f"KI-Dienst {text}, neuer Versuch in {sekunden:.0f} Sekunden...",
},
}, _vis, _cb, _tid))
bedrock_client.wartemelder_setzen(_warte_melden)
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
# Bei Retry: vorherigen running-Eintrag als error markieren
if retry_count > 0:
await db.execute(
"""UPDATE refresh_log SET status = 'error', error_message = 'Retry gestartet',
completed_at = ? WHERE incident_id = ? AND status = 'running'""",
(datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), incident_id),
)
await db.commit()
# Refresh-Log starten
now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
now_utc = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
cursor = await db.execute(
"INSERT INTO refresh_log (incident_id, started_at, status, trigger_type, retry_count, tenant_id) VALUES (?, ?, 'running', ?, ?, ?)",
(incident_id, now, trigger_type, retry_count, tenant_id),
)
await db.commit()
log_id = cursor.lastrowid
usage_acc = UsageAccumulator()
# --- Pipeline-Tracking (Analysepipeline-Visualisierung) ---
_pass_nr = (_pass_info or {}).get("nr", 1)
_step_ids: dict[str, Optional[int]] = {}
async def _pipe_start(step_key: str):
try:
sid = await _pipe.start_step(
db, self._ws_manager,
refresh_log_id=log_id, incident_id=incident_id, step_key=step_key,
pass_number=_pass_nr, tenant_id=tenant_id,
visibility=visibility, created_by=created_by,
)
_step_ids[step_key] = sid
return sid
except Exception as _e:
logger.debug(f"_pipe_start({step_key}) ignoriert: {_e}")
return None
async def _pipe_done(step_key: str, count_value=None, count_secondary=None):
try:
sid = _step_ids.pop(step_key, None)
await _pipe.complete_step(
db, self._ws_manager, step_id=sid,
refresh_log_id=log_id, incident_id=incident_id, step_key=step_key,
pass_number=_pass_nr, count_value=count_value, count_secondary=count_secondary,
tenant_id=tenant_id, visibility=visibility, created_by=created_by,
)
except Exception as _e:
logger.debug(f"_pipe_done({step_key}) ignoriert: {_e}")
async def _pipe_skip(step_key: str):
try:
await _pipe.skip_step(
db, self._ws_manager,
refresh_log_id=log_id, incident_id=incident_id, step_key=step_key,
pass_number=_pass_nr, tenant_id=tenant_id,
visibility=visibility, created_by=created_by,
)
except Exception as _e:
logger.debug(f"_pipe_skip({step_key}) ignoriert: {_e}")
research_status = "deep_researching" if incident_type == "research" else "researching"
research_detail = "Hintergrundrecherche im Web läuft..." if incident_type == "research" else "RSS-Feeds und Web werden durchsucht..."
# Multi-Pass: Detail-Text mit Durchlauf-Info versehen
_ws_extra = {}
if _pass_info:
_pnr, _ptotal, _plabel = _pass_info["nr"], _pass_info["total"], _pass_info["label"]
research_detail = f"Recherche {_pnr}/{_ptotal}: {_plabel}..."
_ws_extra = {"research_pass": _pnr, "research_total_passes": _ptotal}
if self._ws_manager:
await self._ws_manager.broadcast_for_incident({
"type": "status_update",
"incident_id": incident_id,
"data": {"status": research_status, "detail": research_detail, "started_at": now_utc, **_ws_extra},
}, visibility, created_by, tenant_id)
# Bestehende Artikel vorladen (für Dedup UND Kontext)
cursor = await db.execute(
"SELECT id, source_url, headline, source FROM articles WHERE incident_id = ?",
(incident_id,),
)
existing_db_articles_full = await cursor.fetchall()
# Pipeline-Schritt 1: Quellen sichten (vorbereitet)
await _pipe_start("sources_review")
try:
if incident_type == "adhoc":
_src_cursor = await db.execute(
"SELECT COUNT(*) AS cnt FROM sources "
"WHERE status = 'active' "
"AND (tenant_id IS NULL OR tenant_id = ?)",
(tenant_id,),
)
_src_row = await _src_cursor.fetchone()
_src_total = _src_row["cnt"] if _src_row else 0
else:
_src_total = None
except Exception:
_src_total = None
# secondary wird später mit der Anzahl tatsächlich liefernder Quellen ergänzt
await _pipe_done("sources_review", count_value=_src_total, count_secondary=None)
# Schritt 1+2: RSS-Feeds und Claude-Recherche parallel ausführen
async def _rss_pipeline():
"""RSS-Feed-Suche (Feed-Selektion + dynamische Keywords + Parsing)."""
if incident_type != "adhoc":
logger.info("Recherche-Modus: RSS-Feeds übersprungen")
return [], None
rss_researcher = ResearcherAgent()
rss_parser = RSSParser()
from source_rules import get_feeds_with_metadata
all_feeds = await get_feeds_with_metadata(tenant_id=tenant_id)
# Dynamische Keywords aus den letzten Headlines extrahieren
cursor_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,),
)
recent_headlines = [row["hl"] for row in await cursor_hl.fetchall() if row["hl"]]
dynamic_keywords, kw_usage = await rss_researcher.extract_dynamic_keywords(title, recent_headlines)
if kw_usage:
usage_acc.add(kw_usage)
feed_usage = None
keywords = dynamic_keywords # Dynamische Keywords bevorzugen
if len(all_feeds) > 20:
selected_feeds, feed_sel_keywords, feed_usage = await rss_researcher.select_relevant_feeds(
title, description, international, all_feeds
)
logger.info(f"Feed-Selektion: {len(selected_feeds)} von {len(all_feeds)} Feeds ausgewählt")
# Feed-Selektion-Keywords nur als Fallback wenn dynamische fehlen
if not keywords:
keywords = feed_sel_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)
logger.info(f"RSS: {len(articles)} relevante Artikel gefunden (international={international})")
return articles, feed_usage
async def _web_search_pipeline():
"""Claude WebSearch-Recherche mit Vorselektion eingetragener Web-Quellen."""
researcher = ResearcherAgent()
# Bestehende Artikel als Kontext mitgeben (Research + Adhoc)
existing_for_context = None
if existing_db_articles_full:
existing_for_context = [
{"source": row["source"] if "source" in row.keys() else "",
"headline": row["headline"],
"source_url": row["source_url"]}
for row in existing_db_articles_full
]
# Web-Quellen vorselektieren (Haiku) — nur thematisch passende werden Claude im Prompt empfohlen
preferred_sources = []
try:
from source_rules import get_feeds_with_metadata
web_sources = await get_feeds_with_metadata(tenant_id=tenant_id, source_type="web_source")
if web_sources:
preferred_sources, web_sel_usage = await researcher.select_relevant_web_sources(
title, description, web_sources,
)
if web_sel_usage:
usage_acc.add(web_sel_usage)
except Exception as e:
logger.warning(f"Web-Source-Vorselektion fehlgeschlagen (Pipeline laeuft weiter): {e}")
preferred_sources = []
results, usage, parse_failed = await researcher.search(
title, description, incident_type,
international=international, user_id=user_id,
existing_articles=existing_for_context,
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"
+ (f" (mit {len(preferred_sources)} Web-Quellen-Hinweis)" if preferred_sources else "")
+ (" (Parser fehlgeschlagen)" if parse_failed else "")
)
return results, usage, parse_failed
async def _podcast_pipeline():
"""Podcast-Episoden-Suche (nur adhoc-Lagen, nur mit vorhandenen Transkripten)."""
if incident_type != "adhoc":
logger.info("Recherche-Modus: Podcasts uebersprungen")
return [], None
from source_rules import get_feeds_with_metadata
podcast_feeds = await get_feeds_with_metadata(tenant_id=tenant_id, source_type="podcast_feed")
if not podcast_feeds:
return [], None
from feeds.podcast_parser import PodcastFeedParser
pd_parser = PodcastFeedParser()
pd_researcher = ResearcherAgent()
# Dynamische Keywords (eigener Haiku-Call, parallel zu RSS —
# billig und hält Pipelines unabhaengig)
cursor_pd_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,),
)
pd_headlines = [row["hl"] for row in await cursor_pd_hl.fetchall() if row["hl"]]
pd_keywords, pd_kw_usage = await pd_researcher.extract_dynamic_keywords(title, pd_headlines)
if pd_kw_usage:
usage_acc.add(pd_kw_usage)
# Podcast-Parser erwartet (noch) eine flache Liste – Podcasts sind
# primaer deutschsprachig, daher reicht das gemeinsame Flatten.
from agents.researcher import flatten_keywords
pd_keywords_flat = flatten_keywords(pd_keywords)
articles = await pd_parser.search_feeds_selective(title, podcast_feeds, keywords=pd_keywords_flat or None)
logger.info(f"Podcast-Pipeline: {len(articles)} Episoden gefunden")
return articles, None
async def _telegram_pipeline():
"""Telegram-Kanal-Suche mit KI-basierter Kanal-Selektion."""
from feeds.telegram_parser import TelegramParser
tg_parser = TelegramParser()
# Alle Telegram-Kanaele laden
all_channels = await tg_parser._get_telegram_channels(tenant_id=tenant_id)
if not all_channels:
logger.info("Keine Telegram-Kanaele konfiguriert")
return [], None
# KI waehlt relevante Kanaele aus
tg_researcher = ResearcherAgent()
selected_channels, tg_sel_usage = await tg_researcher.select_relevant_telegram_channels(
title, description, all_channels
)
if tg_sel_usage:
usage_acc.add(tg_sel_usage)
selected_ids = [ch["id"] for ch in selected_channels]
logger.info(f"Telegram-Selektion: {len(selected_ids)} von {len(all_channels)} Kanaelen")
# Dynamische Keywords fuer Telegram (eigener Aufruf, da parallel zu RSS)
cursor_tg_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,),
)
tg_headlines = [row["hl"] for row in await cursor_tg_hl.fetchall() if row["hl"]]
tg_keywords, tg_kw_usage = await tg_researcher.extract_dynamic_keywords(title, tg_headlines)
if tg_kw_usage:
usage_acc.add(tg_kw_usage)
if isinstance(tg_keywords, dict):
logger.info(f"Telegram-Keywords (Sprachen): { {k: len(v) for k, v in tg_keywords.items()} }")
else:
logger.info(f"Telegram-Keywords: {tg_keywords}")
articles = await tg_parser.search_channels(title, tenant_id=tenant_id, keywords=tg_keywords, channel_ids=selected_ids)
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/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[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:
rss_articles = (rss_articles or []) + podcast_articles
# URL-Verifizierung nur fuer WebSearch-Ergebnisse (RSS-URLs sind bereits verifiziert)
if search_results:
search_results = await _verify_article_urls(search_results)
if rss_feed_usage:
usage_acc.add(rss_feed_usage)
if search_usage:
usage_acc.add(search_usage)
# Checkpoint 1: Cancel prüfen nach RSS/WebSearch
self._check_cancelled(incident_id)
# Alle Ergebnisse zusammenführen
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")})
except Exception:
_delivering_sources = None
await _pipe_done("collect", count_value=len(all_results), count_secondary=_delivering_sources)
# Pipeline-Schritt 3: Doppeltes filtern (Start)
await _pipe_start("dedup")
# Duplikate entfernen (normalisierte URL + Headline-Ähnlichkeit)
seen_urls = set()
seen_headlines = set()
unique_results = []
for article in all_results:
if not _is_duplicate(article, seen_urls, seen_headlines):
unique_results.append(article)
dupes_removed = len(all_results) - len(unique_results)
if dupes_removed > 0:
logger.info(f"Deduplizierung: {dupes_removed} Duplikate entfernt, {len(unique_results)} verbleibend")
await _pipe_done("dedup", count_value=dupes_removed, count_secondary=len(unique_results))
# Relevanz-Scoring und Sortierung
for article in unique_results:
if "relevance_score" not in article or article["relevance_score"] == 0:
article["relevance_score"] = _score_relevance(article)
unique_results.sort(key=lambda a: a.get("relevance_score", 0), reverse=True)
source_count = len(set(a.get("source", "") for a in unique_results))
_analyze_detail = f"Analysiert {len(unique_results)} Meldungen aus {source_count} Quellen..."
if _pass_info:
_analyze_detail = f"[{_pass_info['nr']}/{_pass_info['total']}] {_analyze_detail}"
if self._ws_manager:
await self._ws_manager.broadcast_for_incident({
"type": "status_update",
"incident_id": incident_id,
"data": {
"status": "analyzing",
"detail": _analyze_detail,
"started_at": now,
**_ws_extra,
},
}, visibility, created_by, tenant_id)
# --- Set-basierte DB-Deduplizierung (statt N×M Queries) ---
# existing_db_articles_full wurde bereits oben geladen
existing_urls = set()
existing_headlines = set()
for row in existing_db_articles_full:
if row["source_url"]:
existing_urls.add(_normalize_url(row["source_url"]))
if row["headline"] and len(row["headline"]) > 20:
norm_h = _normalize_headline(row["headline"])
if norm_h:
existing_headlines.add(norm_h)
logger.info(f"DB-Dedup: {len(existing_urls)} URLs, {len(existing_headlines)} Headlines im Bestand")
# --- Dedup gegen Bestand: nur neue (noch nicht gespeicherte) Kandidaten behalten ---
new_candidates = []
for article in unique_results:
if article.get("source_url"):
norm_url = _normalize_url(article["source_url"])
if norm_url in existing_urls:
continue
existing_urls.add(norm_url)
headline = article.get("headline", "")
if headline and len(headline) > 20:
norm_h = _normalize_headline(headline)
if norm_h and norm_h in existing_headlines:
continue
if norm_h:
existing_headlines.add(norm_h)
new_candidates.append(article)
# Pipeline-Schritt 4: Relevanz bewerten (Start)
await _pipe_start("relevance")
_candidates_before_topic = len(new_candidates)
# --- Pre-Topic-Übersetzung: fremdsprachige Headlines ins Englische ---
# Damit der nachgelagerte Topic-Filter (Haiku) auch CJK/Arabisch/
# Hebräisch/Kyrillisch-Headlines fair beurteilen kann statt sie aus
# Sicherheit zu verwerfen.
if new_candidates:
try:
from agents.translator import translate_headlines_for_topic_filter
_pt_count, _pt_usage = await translate_headlines_for_topic_filter(new_candidates)
if _pt_usage:
usage_acc.add(_pt_usage)
if _pt_count:
logger.info(
f"Pre-Topic-Translate: {_pt_count} fremdsprachige Headlines übersetzt"
)
except Exception as e:
logger.warning(
f"Pre-Topic-Translate fehlgeschlagen (Pipeline laeuft weiter): {e}"
)
# --- Semantischer Topic-Filter (Haiku) ---
# Wirft Artikel raus, die zwar Keyword-Treffer hatten, aber das Kernthema
# der Lage nicht inhaltlich behandeln. Bei Fehler Fallback auf alle Kandidaten.
if new_candidates:
_tf_agent = AnalyzerAgent()
new_candidates, _tf_usage = await _tf_agent.filter_relevant_articles(
title, description, new_candidates,
)
if _tf_usage:
usage_acc.add(_tf_usage)
await _pipe_done("relevance", count_value=len(new_candidates), count_secondary=_candidates_before_topic)
# --- Neue (thematisch gefilterte) Artikel speichern und für Analyse tracken ---
new_count = 0
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, 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,
),
)
new_count += 1
article_with_id = dict(article)
article_with_id["id"] = cursor.lastrowid
new_articles_for_analysis.append(article_with_id)
await db.commit()
# Geoparsing: Orte aus neuen Artikeln extrahieren und speichern
# 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(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:
await db.execute(
"""INSERT INTO article_locations
(article_id, incident_id, location_name, location_name_normalized,
country_code, latitude, longitude, confidence, source_text, tenant_id, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(art_id, incident_id, loc["location_name"], loc["location_name_normalized"],
loc["country_code"], loc["lat"], loc["lon"], loc["confidence"],
loc.get("source_text", ""), tenant_id, loc.get("category", "mentioned")),
)
geo_count += 1
if geo_count > 0:
await db.commit()
logger.info(f"Geoparsing: {geo_count} Orte aus {len(geo_results)} Artikeln gespeichert")
# Category-Labels in Incident speichern (nur wenn neu generiert)
if category_labels:
import json as _json
await db.execute(
"UPDATE incidents SET category_labels = ? WHERE id = ? AND category_labels IS NULL",
(_json.dumps(category_labels, ensure_ascii=False), incident_id),
)
await db.commit()
logger.info(f"Category-Labels gespeichert fuer Incident {incident_id}: {category_labels}")
await _pipe_done("geoparsing", count_value=geo_count, count_secondary=len(geo_results) if geo_results else 0)
except Exception as e:
logger.warning(f"Geoparsing fehlgeschlagen (Pipeline laeuft weiter): {e}")
await _pipe_done("geoparsing", count_value=0, count_secondary=0)
else:
await _pipe_skip("geoparsing")
# Quellen-Statistiken aktualisieren
if new_count > 0:
try:
from database import refresh_source_counts
await refresh_source_counts(db)
except Exception as e:
logger.warning(f"Quellen-Statistiken konnten nicht aktualisiert werden: {e}")
# Schritt 3+4: Analyse und Faktencheck PARALLEL
# collect_only (Studio-Baustein "Sammeln"): ueberspringt Analyse/Faktencheck.
if (new_count > 0 or not previous_summary) and not collect_only:
is_first_summary = not previous_summary
# Snapshot des alten Lagebilds sichern BEVOR parallele Verarbeitung startet
if previous_summary:
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM articles WHERE incident_id = ?",
(incident_id,),
)
snap_articles = (await cursor.fetchone())["cnt"]
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM fact_checks WHERE incident_id = ?",
(incident_id,),
)
snap_fcs = (await cursor.fetchone())["cnt"]
await db.execute(
"""INSERT INTO incident_snapshots
(incident_id, summary, sources_json,
article_count, fact_check_count, refresh_log_id, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(incident_id, previous_summary, previous_sources_json,
snap_articles, snap_fcs, log_id, now, tenant_id),
)
await db.commit()
# Bestehende Fakten und alle Artikel vorladen (für parallele Tasks)
cursor = await db.execute(
"SELECT id, claim, status, sources_count, evidence FROM fact_checks WHERE incident_id = ?",
(incident_id,),
)
existing_facts = [dict(row) for row in await cursor.fetchall()]
# Alle Artikel vorladen für Erstanalyse/Erstcheck
all_articles_preloaded = None
if not previous_summary or new_count == 0 or not existing_facts:
cursor = await db.execute(
# 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()]
_parallel_detail = "Analyse und Faktencheck laufen parallel..."
if _pass_info:
_parallel_detail = f"[{_pass_info['nr']}/{_pass_info['total']}] {_parallel_detail}"
if self._ws_manager:
await self._ws_manager.broadcast_for_incident({
"type": "status_update",
"incident_id": incident_id,
"data": {"status": "analyzing", "detail": _parallel_detail, "started_at": now_utc, **_ws_extra},
}, visibility, created_by, tenant_id)
# Quelleneinordnung (Bias) an Artikel anhaengen
try:
cursor = await db.execute(
"SELECT name, domain, bias FROM sources WHERE bias IS NOT NULL"
)
_bias_rows = await cursor.fetchall()
_bias_by_domain = {}
_bias_by_name = {}
for br in _bias_rows:
brd = dict(br)
if brd.get("domain"):
_bias_by_domain[brd["domain"].lower()] = brd["bias"]
if brd.get("name"):
_bias_by_name[brd["name"].lower()] = brd["bias"]
def _enrich_bias(articles_list):
if not articles_list:
return
for art in articles_list:
if art.get("source_bias"):
continue
src = (art.get("source") or "").lower()
url = (art.get("source_url") or "").lower()
# Match by name
bias = _bias_by_name.get(src)
if not bias:
# Match by domain in URL
for dom, b in _bias_by_domain.items():
if dom and dom in url:
bias = b
break
if bias:
art["source_bias"] = bias
_enrich_bias(new_articles_for_analysis)
_enrich_bias(all_articles_preloaded)
except Exception as e:
logger.warning("Bias-Anreicherung fehlgeschlagen (Pipeline laeuft weiter): %s", e)
# --- Analyse-Task (wird nach _do_factcheck mit fact_context_block aufgerufen) ---
async def _do_analysis(fact_context_block: str = ""):
analyzer = AnalyzerAgent()
if previous_summary and new_count > 0:
logger.info(f"Inkrementelle Analyse: {new_count} neue Artikel zum bestehenden Lagebild")
return await analyzer.analyze_incremental(
title, description, new_articles_for_analysis,
previous_summary, previous_sources_json, incident_type,
fact_context_block=fact_context_block,
output_language=output_language,
)
else:
logger.info("Erstanalyse: Alle Artikel werden analysiert")
return await analyzer.analyze(
title, description, all_articles_preloaded, incident_type,
fact_context_block=fact_context_block,
output_language=output_language,
)
# --- Faktencheck-Task ---
async def _do_factcheck():
factchecker = FactCheckerAgent()
if existing_facts and new_count > 0:
if len(existing_facts) >= TWOPHASE_MIN_FACTS:
logger.info(
f"Zwei-Phasen-Faktencheck: {new_count} neue Artikel, "
f"{len(existing_facts)} bestehende Fakten"
)
return await factchecker.check_incremental_twophase(
title, new_articles_for_analysis, existing_facts, incident_type,
output_language=output_language,
)
else:
logger.info(
f"Inkrementeller Faktencheck: {new_count} neue Artikel, "
f"{len(existing_facts)} bestehende Fakten"
)
return await factchecker.check_incremental(
title, new_articles_for_analysis, existing_facts, incident_type,
output_language=output_language,
)
else:
# Alle Artikel laden falls nicht vorab geladen (Henne-Ei-Problem:
# Summary existiert aber noch keine Factchecks)
articles_for_check = all_articles_preloaded
if not articles_for_check:
cursor = await db.execute(
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
(incident_id,),
)
articles_for_check = [dict(row) for row in await cursor.fetchall()]
return await factchecker.check(title, articles_for_check, incident_type, output_language=output_language)
# Pipeline-Schritt 6: Faktencheck zuerst (sequenziell). Liefert den
# Faktenkontext fuer das Lagebild, damit dieses auf geprueftem Stand
# schreibt und Unklarheiten explizit benennt. Variante 1: bei
# Faktencheck-Fehler faellt das Lagebild auf den alten Pfad ohne
# Faktenkontext zurueck (Refresh bricht NICHT ab).
await _pipe_start("factcheck")
factcheck_result: tuple = ([], None)
fact_context_block = ""
factcheck_failed_reason: str | None = None
try:
factcheck_result = await _do_factcheck()
except Exception as fc_err:
factcheck_failed_reason = str(fc_err)
logger.warning(
"Faktencheck fehlgeschlagen, Lagebild laeuft ohne Faktenkontext: %s",
fc_err, exc_info=True,
)
fact_checks, fc_usage = factcheck_result if factcheck_result else ([], None)
# Pipeline-Schritt 6 done direkt nach dem Aufruf — die finale
# DB-Persistierung passiert weiter unten, aber fuer die UI ist
# der Faktencheck-Aufruf hier abgeschlossen. Der count_value
# ist eine Schaetzung (echte Zahl steht spaeter in der DB).
_fc_estimated_new = max(0, len(fact_checks or []) - len(existing_facts or []))
await _pipe_done(
"factcheck",
count_value=_fc_estimated_new,
count_secondary=len(fact_checks) if fact_checks else 0,
)
# Faktenkontext fuer das Lagebild bauen.
try:
from agents.analyzer import build_fact_context_block as _build_fc_ctx
fact_context_block = _build_fc_ctx(
existing_facts or [], fact_checks or [], incident_type,
)
if fact_context_block:
logger.info(
"Faktenkontext fuer Lagebild: %d Zeichen, basierend auf %d alten + %d neuen Fakten",
len(fact_context_block), len(existing_facts or []), len(fact_checks or []),
)
except Exception as ctx_err:
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(
"Starte Lagebild (sequenziell nach Faktencheck%s)",
" — OHNE Faktenkontext (Fallback)" if factcheck_failed_reason else "",
)
analysis_result = await _do_analysis(fact_context_block)
analysis, analysis_usage = analysis_result
await _pipe_done("summary", count_value=None, count_secondary=None)
# --- Analyse-Ergebnisse verarbeiten ---
if analysis_usage:
usage_acc.add(analysis_usage)
if analysis:
sources = analysis.get("sources", [])
new_summary = analysis.get("summary", "") or previous_summary
# Validierung: Fehlende Quellennummern im Summary erkennen und reparieren
if sources and new_summary:
import re as _re
# Auch alphanumerische Refs wie [389a] erkennen
referenced_raw = set(_re.findall(r'\[(\d+[a-z]?)\]', new_summary))
referenced_nrs = set()
for r in referenced_raw:
try:
referenced_nrs.add(int(r))
except ValueError:
referenced_nrs.add(r) # Keep alphanumeric as string
defined_nrs = set()
for s in sources:
nr = s.get("nr", 0)
if isinstance(nr, int):
defined_nrs.add(nr)
elif isinstance(nr, str):
try:
defined_nrs.add(int(nr))
except ValueError:
defined_nrs.add(nr) # Keep alphanumeric like '389a'
missing_nrs = sorted(referenced_nrs - defined_nrs)
if missing_nrs:
truly_missing = []
for nr in missing_nrs:
# Buchstaben-Suffix (z.B. "22b") -> Basisnummer (22) aufloesen
if isinstance(nr, str) and _re.match(r"^\d+[a-z]$", nr):
base_nr = int(nr[:-1])
if base_nr in defined_nrs:
new_summary = new_summary.replace(f"[{nr}]", f"[{base_nr}]")
logger.info(
"Incident %d: Suffix-Ref [%s] auf Basisquelle [%d] aufgeloest",
incident_id, nr, base_nr
)
continue
truly_missing.append(nr)
if truly_missing:
logger.warning(
"Incident %d: %d Quellennummern im Summary ohne Eintrag in sources: %s",
incident_id, len(truly_missing), truly_missing[:20]
)
for nr in truly_missing:
sources.append({"nr": nr, "name": "Quelle", "url": ""})
logger.info("Platzhalter fuer fehlende Quelle [%s] eingefuegt", nr)
sources.sort(key=lambda s: int(s.get("nr", 0)) if isinstance(s.get("nr"), int) or (isinstance(s.get("nr"), str) and str(s.get("nr", "")).isdigit()) else 9999)
# Sicherstellen dass alle nr-Werte Integer sind (Claude liefert manchmal Strings)
if sources:
for s in sources:
nr = s.get("nr")
if isinstance(nr, str):
try:
s["nr"] = int(nr)
except ValueError:
pass
sources_json = json.dumps(sources, ensure_ascii=False) if sources else previous_sources_json
# summary_at haelt fest, WANN das Lagebild entstand (Studio-Freshness).
# JETZT stempeln, nicht 'now' vom Lauf-Beginn: sonst saehen die in diesem
# Lauf gesammelten Artikel neuer aus als der Bericht, der sie schon enthaelt.
summary_now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
await db.execute(
"UPDATE incidents SET summary = ?, sources_json = ?, executive_summary = NULL, "
"updated_at = ?, summary_at = ? WHERE id = ?",
(new_summary, sources_json, summary_now, summary_now, incident_id),
)
# Beim ersten Refresh: Snapshot des neuen Lagebilds erstellen
if is_first_summary and new_summary:
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM articles WHERE incident_id = ?",
(incident_id,),
)
snap_articles = (await cursor.fetchone())["cnt"]
cursor = await db.execute(
"SELECT COUNT(*) as cnt FROM fact_checks WHERE incident_id = ?",
(incident_id,),
)
snap_fcs = (await cursor.fetchone())["cnt"]
await db.execute(
"""INSERT INTO incident_snapshots
(incident_id, summary, sources_json,
article_count, fact_check_count, refresh_log_id, created_at, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(incident_id, new_summary, sources_json,
snap_articles, snap_fcs, log_id, now, tenant_id),
)
# Translations werden vom dedizierten Translator-Agent unten
# erzeugt (frueher inline im Analyzer-Output, das war token-
# instabil und schaetzte regelmaessig content_de aus).
await db.commit()
# Cancel-Check nach paralleler Verarbeitung
self._check_cancelled(incident_id)
# --- Translator (Haiku) fuer fremdsprachige Artikel ohne DE-Texte ---
# Idempotent: nur Artikel ohne headline_de/content_de werden geholt.
# Lauft nach der Analyse (Lagebild ist schon committed) und vor QC
# (damit normalize_umlaut_articles auch die frischen DE-Texte fasst).
_translate_step_started = False
try:
tr_cursor = await db.execute(
"""SELECT id, headline, content_original, language
FROM articles
WHERE incident_id = ?
AND language IS NOT NULL AND LOWER(language) != 'de'
AND (headline_de IS NULL OR headline_de = ''
OR content_de IS NULL OR content_de = '')""",
(incident_id,),
)
pending_translations = [dict(r) for r in await tr_cursor.fetchall()]
if pending_translations 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),
)
from agents.translator import translate_articles
from services.post_refresh_qc import normalize_german_umlauts as _norm_de2
translations = await translate_articles(
pending_translations,
output_lang=output_language_iso,
usage_accumulator=usage_acc,
enabled=translator_enabled,
)
for t in translations:
hd = t.get("headline_de")
cd = t.get("content_de")
if hd:
hd, _ = _norm_de2(hd)
if cd:
cd, _ = _norm_de2(cd)
if hd or cd:
await db.execute(
"UPDATE articles SET headline_de = COALESCE(?, headline_de), "
"content_de = COALESCE(?, content_de) WHERE id = ? AND incident_id = ?",
(hd, cd, t["id"], incident_id),
)
await db.commit()
logger.info(
"Translator fuer Incident %d: %d/%d Artikel uebersetzt",
incident_id, len(translations), len(pending_translations),
)
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
# --- 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.
dev_summary_source = (locals().get("new_summary") or previous_summary or "").strip()
if incident_type == "adhoc" and dev_summary_source:
try:
# Top-60 neueste Artikel mit Publikationsdatum als Beleg-Pool.
dev_cursor = await db.execute(
"""SELECT id, headline, headline_de, source, source_url, published_at
FROM articles
WHERE incident_id = ? AND published_at IS NOT NULL
ORDER BY published_at DESC LIMIT 60""",
(incident_id,),
)
dev_articles = [dict(row) for row in await dev_cursor.fetchall()]
dev_analyzer = AnalyzerAgent()
dev_text, dev_usage = await dev_analyzer.generate_latest_developments(
title, description, dev_summary_source, dev_articles, previous_developments,
output_language=output_language,
)
if dev_usage:
usage_acc.add(dev_usage)
if dev_text is not None:
await db.execute(
"UPDATE incidents SET latest_developments = ? WHERE id = ?",
(dev_text, incident_id),
)
await db.commit()
previous_developments = dev_text
except Exception as e:
logger.warning(f"Latest-Developments-Generator fehlgeschlagen: {e}")
# Cancel-Check nach Analyse+Faktencheck
self._check_cancelled(incident_id)
# --- Faktencheck-Ergebnisse verarbeiten ---
# Pre-Dedup: Duplikate aus LLM-Antwort entfernen
fact_checks = deduplicate_new_facts(fact_checks)
if fc_usage:
usage_acc.add(fc_usage)
# Prüfen ob dies der erste Refresh ist (keine vorherigen Faktenchecks)
is_first_refresh = len(existing_facts) == 0
# Notification-Summary sammeln
confirmed_count = 0
contradicted_count = 0
status_changes = []
# --- Schutz gegen Massen-Downgrades ---
# Wenn >50% der established/confirmed Fakten auf unverified/unconfirmed
# herabgestuft wuerden, verwerfe die FC-Ergebnisse komplett.
established_ids = {ef["id"] for ef in existing_facts if ef.get("status") in ("established", "confirmed")}
if established_ids and fact_checks:
_downgrade_count = 0
_remaining_tmp = list(existing_facts)
for _fc in fact_checks:
_matched = find_matching_claim(_fc.get("claim", ""), _remaining_tmp)
if _matched and _matched["id"] in established_ids:
_new_st = _fc.get("status", "developing")
if _new_st in ("unverified", "unconfirmed", "developing"):
_downgrade_count += 1
_remaining_tmp = [ef for ef in _remaining_tmp if ef["id"] != _matched["id"]]
_downgrade_ratio = _downgrade_count / len(established_ids) if established_ids else 0
if _downgrade_ratio > 0.5:
logger.warning(
f"Faktencheck-Ergebnisse verworfen: {_downgrade_count}/{len(established_ids)} "
f"established Fakten wuerden herabgestuft ({_downgrade_ratio:.0%}). "
f"Bestehende Fakten bleiben unveraendert."
)
fact_checks = []
# Mutable Kopie für Fuzzy-Matching
remaining_existing = list(existing_facts)
for fc in fact_checks:
new_claim = fc.get("claim", "")
new_status = fc.get("status", "developing")
# Fuzzy-Matching gegen bestehende Claims
matched = find_matching_claim(new_claim, remaining_existing)
if matched:
old_status = matched.get("status")
# status_history aktualisieren bei Statusaenderung
history_update = ""
if old_status and old_status != new_status:
import json as _json
cursor_hist = await db.execute(
"SELECT status_history FROM fact_checks WHERE id = ?",
(matched["id"],),
)
hist_row = await cursor_hist.fetchone()
try:
history = _json.loads(hist_row[0] or "[]") if hist_row else []
except (ValueError, TypeError):
history = []
history.append({"status": new_status, "at": now})
history_update = _json.dumps(history)
# Evidence: Alte URLs beibehalten wenn neue keine hat
new_evidence = fc.get("evidence") or ""
import re as _re
if not _re.search(r"https?://", new_evidence) and matched.get("evidence"):
old_evidence = matched["evidence"] or ""
if _re.search(r"https?://", old_evidence):
bracket_match = _re.search(r"\[(?:Quellen|Weitere Quellen|Ursprungsquellen):.*?\]", old_evidence)
if bracket_match:
new_evidence = new_evidence.rstrip(". ") + ". " + bracket_match.group()
else:
new_evidence = old_evidence
await db.execute(
"UPDATE fact_checks SET claim = ?, status = ?, sources_count = ?, evidence = ?, is_notification = ?, checked_at = ?"
+ (", status_history = ?" if history_update else "")
+ " WHERE id = ?",
(new_claim, new_status, fc.get("sources_count", 0), new_evidence, fc.get("is_notification", 0), now)
+ ((history_update,) if history_update else ())
+ (matched["id"],),
)
# Aus der Liste entfernen damit nicht doppelt gematcht wird
remaining_existing = [ef for ef in remaining_existing if ef["id"] != matched["id"]]
# Status-Änderung tracken
if not is_first_refresh and old_status and old_status != new_status:
status_changes.append({
"claim": new_claim,
"old_status": old_status,
"new_status": new_status,
})
else:
import json as _json
initial_history = _json.dumps([{"status": new_status, "at": now}])
await db.execute(
"""INSERT INTO fact_checks (incident_id, claim, status, sources_count, evidence, is_notification, tenant_id, status_history)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(incident_id, new_claim, new_status, fc.get("sources_count", 0), fc.get("evidence"), fc.get("is_notification", 0), tenant_id, initial_history),
)
# Status-Statistik sammeln
if new_status in ("confirmed", "established"):
confirmed_count += 1
elif new_status in ("contradicted", "disputed", "false"):
contradicted_count += 1
await db.commit()
# Pipeline-Schritt 7 (Fakten pruefen) wurde bereits frueher als done
# markiert (siehe weiter oben — direkt nach dem _do_factcheck-Aufruf,
# bevor das Lagebild generiert wurde). Hier nur noch die DB-
# Persistierung der Fakten, ohne den Step erneut zu schliessen.
# Pipeline-Schritt 8: Qualitätscheck (Start, ohne Zahlen)
await _pipe_start("qc")
# Post-Refresh Quality Check: Duplikate und Karten-Kategorien pruefen
try:
from services.post_refresh_qc import run_post_refresh_qc
qc_result = await run_post_refresh_qc(db, incident_id)
if qc_result.get("facts_removed", 0) > 0 or qc_result.get("locations_fixed", 0) > 0:
logger.info(
f"QC: {qc_result['facts_removed']} Duplikate, "
f"{qc_result['locations_fixed']} Location-Fixes"
)
except Exception as qc_err:
logger.warning(f"Post-Refresh QC fehlgeschlagen: {qc_err}")
await _pipe_done("qc", count_value=None, count_secondary=None)
# Pipeline-Schritt 9: Benachrichtigen (Start)
await _pipe_start("notify")
_notify_count = 0
# Gebündelte Notification senden (nicht beim ersten Refresh)
if not is_first_refresh:
if self._ws_manager:
await self._ws_manager.broadcast_for_incident({
"type": "refresh_summary",
"incident_id": incident_id,
"data": {
"new_articles": new_count,
"confirmed_count": confirmed_count,
"contradicted_count": contradicted_count,
"status_changes": status_changes,
"is_first_refresh": False,
"incident_title": title,
},
}, visibility, created_by, tenant_id)
# DB-Notifications erzeugen (Texte org-sprach-relativ)
is_en = output_language_iso == "en"
parts = []
if is_en:
if new_count > 0:
parts.append(f"{new_count} new article{'s' if new_count != 1 else ''}")
if confirmed_count > 0:
parts.append(f"{confirmed_count} confirmed")
if contradicted_count > 0:
parts.append(f"{contradicted_count} contradicted")
summary_text = ", ".join(parts) if parts else "No new developments"
research_prefix = "Research"
new_articles_msg = f"{new_count} new article{'s' if new_count != 1 else ''} found"
else:
if new_count > 0:
parts.append(f"{new_count} neue Meldung{'en' if new_count != 1 else ''}")
if confirmed_count > 0:
parts.append(f"{confirmed_count} bestätigt")
if contradicted_count > 0:
parts.append(f"{contradicted_count} widersprochen")
summary_text = ", ".join(parts) if parts else "Keine neuen Entwicklungen"
research_prefix = "Recherche"
new_articles_msg = f"{new_count} neue Meldung{'en' if new_count != 1 else ''} gefunden"
db_notifications = [{
"type": "refresh_summary",
"title": title,
"text": f"{research_prefix}: {summary_text}",
"icon": "warning" if contradicted_count > 0 else "success",
}]
if new_count > 0:
db_notifications.append({
"type": "new_articles",
"title": title,
"text": new_articles_msg,
"icon": "info",
})
for sc in status_changes:
db_notifications.append({
"type": "status_change",
"title": title,
"text": f"{sc['claim']}: {sc['old_status']} \u2192 {sc['new_status']}",
"icon": "error" if sc["new_status"] in ("contradicted", "disputed", "false") else "success",
})
if created_by:
await _create_notifications_for_incident(
db, incident_id, visibility, created_by, tenant_id, db_notifications
)
# E-Mail-Benachrichtigungen versenden
await _send_email_notifications_for_incident(
db, incident_id, title, visibility, created_by, tenant_id, db_notifications,
incident_type=incident_type,
)
_notify_count = len(db_notifications)
# Pipeline-Schritt 9: Benachrichtigen (fertig)
await _pipe_done("notify", count_value=_notify_count, count_secondary=None)
# Falls Analyse-Block übersprungen wurde (kein neuer Artikel und Summary existiert),
# die noch offenen Pipeline-Schritte als übersprungen markieren.
for _skipped_key in ("summary", "factcheck", "qc", "notify"):
if _skipped_key in _step_ids or _skipped_key not in {"summary", "factcheck", "qc", "notify"}:
pass
# Saubere Variante: alle noch offenen Steps am Ende skippen
for _open_key in list(_step_ids.keys()):
await _pipe_skip(_open_key)
# Auch Steps die nie gestartet wurden (bei übersprungenem Outer-If)
_started_keys = set()
try:
_check_cursor = await db.execute(
"SELECT step_key FROM refresh_pipeline_steps WHERE refresh_log_id = ? AND pass_number = ?",
(log_id, _pass_nr),
)
_started_keys = {row[0] for row in await _check_cursor.fetchall()}
except Exception:
pass
for _missing_key in ("summary", "factcheck", "qc", "notify"):
if _missing_key not in _started_keys:
await _pipe_skip(_missing_key)
# Refresh-Log abschließen (mit Token-Statistiken). Störungen des
# KI-Dienstes werden auch bei einem erfolgreichen Lauf vermerkt,
# sonst sieht ein Durchlauf mit Aussetzern aus wie ein glatter.
stoerungshinweis = bedrock_client.stoerungen_zusammenfassen() or None
if stoerungshinweis:
logger.info("Refresh-Protokoll Lage %s: %s", incident_id, stoerungshinweis)
await db.execute(
"""UPDATE refresh_log SET
completed_at = ?, articles_found = ?, status = 'completed',
input_tokens = ?, output_tokens = ?,
cache_creation_tokens = ?, cache_read_tokens = ?,
total_cost_usd = ?, api_calls = ?, error_message = ?
WHERE id = ?""",
(datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S'), new_count,
usage_acc.input_tokens, usage_acc.output_tokens,
usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens,
round(usage_acc.total_cost_usd, 7), usage_acc.call_count,
stoerungshinweis, log_id),
)
await db.commit()
logger.info(
f"Token: {usage_acc.input_tokens} in / {usage_acc.output_tokens} out / "
f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)"
)
# Guthaben belasten + monatliche Statistik fortschreiben. Der Lagentyp
# entscheidet ueber den Tarif, ein Research-Durchlauf kostet mehr als
# ein Live-Refresh. Nicht mehr an total_cost_usd > 0 gebunden, weil im
# Pauschalmodus die Aktion zaehlt und lokale Modelle 0 kosten.
if tenant_id:
from services.license_service import charge_usage_to_tenant
await charge_usage_to_tenant(
db, tenant_id, usage_acc, source="monitor", incident_type=incident_type
)
await db.commit()
# Quellen-Discovery im Background starten
if unique_results:
asyncio.create_task(_background_discover_sources(unique_results))
if not _suppress_complete and self._ws_manager:
await self._ws_manager.broadcast_for_incident({
"type": "refresh_complete",
"incident_id": incident_id,
"data": {"new_articles": new_count, "status": "idle"},
}, visibility, created_by, tenant_id)
# updated_at IMMER aktualisieren wenn Refresh lief (auch bei fehlgeschlagener Analyse)
await db.execute(
"UPDATE incidents SET updated_at = ? WHERE id = ?",
(now, incident_id),
)
await db.commit()
logger.info(f"Refresh für Lage {incident_id} abgeschlossen: {new_count} neue Artikel")
# Multi-Pass-Diagnose: Pass-Ergebnis zurueck an Multi-Pass-Caller geben
if _pass_info is not None:
_pass_info["new_count"] = new_count
_pass_info["parse_failed"] = search_parse_failed
# Executive Summary im Hintergrund vorab generieren (fuer schnelleren Export)
if new_count > 0:
async def _pregenerate_exec_summary():
try:
from report_generator import generate_executive_summary
from database import get_db
_db = await get_db()
try:
cursor = await _db.execute(
"SELECT summary, executive_summary FROM incidents WHERE id = ?",
(incident_id,),
)
_row = await cursor.fetchone()
if _row and _row["summary"] and not _row["executive_summary"]:
es = await generate_executive_summary(_row["summary"])
await _db.execute(
"UPDATE incidents SET executive_summary = ? WHERE id = ?",
(es, incident_id),
)
await _db.commit()
logger.info(f"Executive Summary fuer Lage {incident_id} vorberechnet")
finally:
await _db.close()
except Exception as e:
logger.warning(f"Executive Summary Vorberechnung fehlgeschlagen: {e}")
asyncio.create_task(_pregenerate_exec_summary())
finally:
await db.close()
async def _get_incident_info(self, incident_id: int) -> tuple[str, bool]:
"""Incident-Typ und Summary-Status laden."""
from database import get_db
db = await get_db()
try:
cursor = await db.execute(
"SELECT type, summary FROM incidents WHERE id = ?", (incident_id,)
)
row = await cursor.fetchone()
if not row:
return "adhoc", False
return row["type"] or "adhoc", bool(row["summary"])
finally:
await db.close()
async def _run_research_multi_pass(self, incident_id: int, trigger_type: str, user_id: int = None):
"""Führt automatisch 3 Recherche-Durchläufe für Research-Lagen durch.
Durchlauf 1: Breite Erfassung (initiale 4-Phasen-Recherche)
Durchlauf 2: Vertiefung (andere Quellen, inkrementelle Analyse)
Durchlauf 3: Konsolidierung (letzte Lücken, Fakten-Upgrade)
"""
total = RESEARCH_MULTI_PASS_COUNT
pass_results = []
for pass_nr in range(1, total + 1):
# Cancel zwischen Durchläufen prüfen
self._check_cancelled(incident_id)
is_last = (pass_nr == total)
pass_info = {
"nr": pass_nr,
"total": total,
"label": RESEARCH_PASS_LABELS.get(pass_nr, f"Durchlauf {pass_nr}"),
}
logger.info(
f"Research Multi-Pass {pass_nr}/{total} für Lage {incident_id}: "
f"{pass_info['label']}"
)
try:
await self._run_refresh(
incident_id,
trigger_type=trigger_type,
retry_count=0,
user_id=user_id,
_suppress_complete=not is_last,
_pass_info=pass_info,
)
except asyncio.CancelledError:
logger.info(
f"Research Multi-Pass abgebrochen in Durchlauf {pass_nr}/{total} "
f"für Lage {incident_id}"
)
raise
except Exception as e:
logger.error(
f"Research Multi-Pass {pass_nr}/{total} fehlgeschlagen "
f"für Lage {incident_id}: {e}"
)
if is_last:
raise
# Nicht-letzter Durchlauf: weiter mit nächstem, bisherige Ergebnisse bleiben
finally:
pass_results.append(pass_info)
logger.info(
f"Research Multi-Pass abgeschlossen für Lage {incident_id}: "
f"{total} Durchläufe"
)
# Diagnose: Wenn ALLE Passes 0 neue Artikel hatten UND mindestens einer
# an einem Parser-Fehler scheiterte, ist die Recherche faktisch fehlgeschlagen —
# Claude lieferte zwar Antworten, aber kein verwertbares JSON. Sonst bliebe
# die Lage ohne sichtbare Fehlermeldung leer (siehe staging Lage "Friedrich Merz").
total_new = sum(p.get("new_count", 0) for p in pass_results)
any_parse_failed = any(p.get("parse_failed") for p in pass_results)
if total_new == 0 and any_parse_failed:
raise RuntimeError(
"Recherche fehlgeschlagen: Claude lieferte keine verwertbaren Quellen "
"(JSON-Parsing schlug bei mindestens einem Durchlauf fehl). "
"Bitte Logs prüfen und Refresh erneut starten."
)
# Singleton-Instanz
orchestrator = AgentOrchestrator()