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:
131
src/services/staan_client.py
Normale Datei
131
src/services/staan_client.py
Normale Datei
@@ -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
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren