feat(eu-umbau): staan-Recherche-Schleife und Doppelspur (Phase 2)

Im EU-Modus laeuft die Recherche jetzt europaeisch: agents/eu_researcher.py
steuert eine Schleife aus Bedrock-Planung (Opus, EU-Profile) und staan-Suchen
(services/staan_client.py, Suche + Volltexte via full_content=markdown,
Pflicht-Domain-Ausschlussliste je Anfrage). Bildet die 4-Phasen-Tiefenrecherche
nach und liefert exakt das JSON der bisherigen CLI-WebSearch, Parsing und
Filter in researcher.search bleiben unveraendert. Anti-Halluzination: nur URLs
aus echten staan-Treffern werden akzeptiert; published_at nur wenn aus Inhalt
oder URL ableitbar (staan liefert keine Daten).

Doppelspur im Orchestrator: Lane-Schluessel ist jetzt (Organisation, Backend),
Aufloesung zentral in _resolve_ai_backend (Lage > Org-Setting > ENV). CLI- und
EU-Lauf derselben Organisation laufen gleichzeitig, gleiche Backends seriell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-30 20:12:31 +00:00
Ursprung e0edac2a18
Commit a7f1aa29ab
6 geänderte Dateien mit 589 neuen und 20 gelöschten Zeilen

131
src/services/staan_client.py Normale Datei
Datei anzeigen

@@ -0,0 +1,131 @@
"""staan.ai Such-Client. Europäische Websuche für den EU-Modellweg (Phase 2).
Ein Baustein für Suche UND Volltextabruf. Der Parameter full_content="markdown"
liefert die kompletten Seiteninhalte der Treffer gleich mit, damit braucht die
EU-Recherche-Schleife keinen eigenen Abruf einzelner Seiten. Die Verarbeitung
bleibt vollständig beim europäischen Anbieter (European Search Perspective,
gehostet bei OVHcloud).
Befund aus Phase 0. Die API liefert praktisch nie ein Veröffentlichungsdatum
und kennt keinen Zeitraum-Filter. Die Aktualität sichern deshalb die
Query-Formulierung (macht die Schleife) und die Datumsextraktion aus den
Inhalten (macht das Modell). Der Domain-Ausschlussfilter ist Pflichtbestandteil
jeder Anfrage, ohne ihn bestehen die Trefferlisten spürbar aus YouTube,
Wikipedia und Social-Media-Seiten.
"""
import asyncio
import logging
import httpx
from config import (
STAAN_API_KEY,
STAAN_BASE_URL,
STAAN_EXCLUDE_DOMAINS,
)
logger = logging.getLogger("osint.staan_client")
# Von staan offiziell unterstützte Märkte. Andere Werte fallen auf en-us zurück,
# der Index liefert auch dann fremdsprachige Treffer (in Phase 0 belegt für
# Russisch, Ukrainisch, Arabisch, Farsi, Hebräisch und Japanisch).
ALLOWED_MARKETS = {"de-de", "en-us", "fr-fr"}
# staan akzeptiert maximal 10 Ausschluss-Domains je Anfrage. Die Pflichtliste
# aus config belegt die ersten Plätze, Nutzer-Ausschlüsse füllen bis 10 auf.
_MAX_EXCLUDE_DOMAINS = 10
class StaanError(RuntimeError):
"""Fehler der staan-API (Status, Auth, Netz)."""
def market_for_language(lang_iso: str | None) -> str:
"""ISO-Sprachcode auf den passendsten staan-Markt abbilden."""
mapping = {"de": "de-de", "en": "en-us", "fr": "fr-fr"}
return mapping.get((lang_iso or "").lower().strip(), "en-us")
async def staan_search(
q: str,
market: str = "en-us",
extra_snippets: bool = True,
max_snippets: int = 5,
full_content: bool = False,
extra_exclude: list[str] | None = None,
timeout: float = 60.0,
) -> list[dict]:
"""Eine Suchanfrage gegen staan. Gibt normalisierte Treffer zurück.
Rückgabe je Treffer: title, url, hostname, snippet, extra_snippets (Liste
von Text-Chunks), full_text (Markdown, gekappt), published_date (fast
immer None, siehe Modul-Docstring).
"""
if not STAAN_API_KEY:
raise StaanError("STAAN_API_KEY ist nicht gesetzt, EU-Suche nicht nutzbar")
if market not in ALLOWED_MARKETS:
market = "en-us"
exclude = list(STAAN_EXCLUDE_DOMAINS)
for d in extra_exclude or []:
d = (d or "").strip().lower()
if not d or d in exclude:
continue
if len(exclude) >= _MAX_EXCLUDE_DOMAINS:
break
exclude.append(d)
body: dict = {"q": (q or "").strip()[:400], "market": market, "exclude_domains": exclude}
if extra_snippets:
body["extra_snippets"] = True
body["max_snippets"] = max(1, min(int(max_snippets), 10))
if full_content:
body["full_content"] = "markdown"
headers = {"Authorization": f"Bearer {STAAN_API_KEY}", "Content-Type": "application/json"}
data = None
async with httpx.AsyncClient(timeout=timeout) as client:
for attempt in (1, 2):
try:
resp = await client.post(f"{STAAN_BASE_URL}/search/web", json=body, headers=headers)
except httpx.TimeoutException as e:
raise StaanError(f"staan Timeout nach {timeout}s ({e})") from e
except httpx.HTTPError as e:
raise StaanError(f"staan Netzwerkfehler ({type(e).__name__}: {e})") from e
if resp.status_code == 429 and attempt == 1:
# Rate-Limit (20 req/s), kurz warten und einmal wiederholen
await asyncio.sleep(1.5)
continue
if resp.status_code not in (200, 201):
# GET antwortet mit 200, POST mit 201 (Created)
raise StaanError(f"staan HTTP {resp.status_code}: {resp.text[:200]}")
try:
data = resp.json()
except ValueError as e:
raise StaanError(f"staan lieferte kein JSON ({e})") from e
break
results: list[dict] = []
for r in (data.get("web") or {}).get("results", []):
fc = r.get("full_content") or {}
results.append({
"title": (r.get("title") or "").strip(),
"url": (r.get("url") or "").strip(),
"hostname": (r.get("hostname") or "").strip(),
"snippet": (r.get("snippet") or "").strip(),
"extra_snippets": [
(c.get("chunk") or "").strip()
for c in (r.get("extra_snippets") or [])
if (c.get("chunk") or "").strip()
],
"full_text": ((fc.get("text") or "").strip())[:6000],
"published_date": r.get("published_date"),
})
logger.info(
"staan [%s] %r -> %d Treffer%s",
market, body["q"][:60], len(results), " mit Volltexten" if full_content else "",
)
return results