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>
2402 Zeilen
121 KiB
Python
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()
|