STAGE 2: Search Provider Abstraction für NSCT

- SearchProvider-Interface mit abstract.base, NormalizedResult-Modell
- DuckDuckGoProvider: HTTP-basierte Suche ohne API-Keys, Fallback-fähig
- MultiProviderSearch: parallele Suche, URL-Dedup, Provider-Config, Fallback
- POST /search-Endpoint mit normalisierten Ergebnissen, Debug-Mode
- 25 unit tests: NormalizedResult, MultiProviderSearch, DuckDuckGoProvider
- rank ist KEIN truth_score - Dokumentation und Validierung durchgängig
This commit is contained in:
NSCT Agent
2026-08-23 12:16:29 +00:00
parent 9280d69ebf
commit a1ef260520
6 changed files with 1042 additions and 0 deletions

View File

@@ -75,6 +75,10 @@ def create_app() -> FastAPI:
from nsct.api.health import router as health_router
app.include_router(health_router, tags=["system"])
# Mount search router
from nsct.api.search import router as search_router
app.include_router(search_router, tags=["search"])
return app

120
src/nsct/api/search.py Normal file
View File

@@ -0,0 +1,120 @@
"""Search endpoint — POST /search returns normalized search results."""
from __future__ import annotations
import logging
import os
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from nsct.providers.abstract import MultiProviderSearch, NormalizedResult, SearchProvider
from nsct.providers.duckduckgo import DuckDuckGoProvider
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Provider factory — creates provider instances from env
# ---------------------------------------------------------------------------
def _build_search_engine() -> MultiProviderSearch:
"""Baue den Search-Engine mit allen konfigurierten Providern auf.
DuckDuckGo ist der Default-Provider. Weitere Provider können in
Zukunft hinzugefügt werden, ohne den API-Code zu ändern.
"""
engine = MultiProviderSearch()
# DuckDuckGo — immer aktiviert als Fallback-Provider
ddgs = DuckDuckGoProvider()
engine.add_provider(ddgs)
logger.info("Search engine initialized with providers: %s", [p.__class__.__name__ for p in engine._providers])
return engine
# Lazy initialization — created on first request
_search_engine: MultiProviderSearch | None = None
def get_search_engine() -> MultiProviderSearch:
"""Lazy-init des Search-Engines (einmal beim ersten Aufruf)."""
global _search_engine
if _search_engine is None:
_search_engine = _build_search_engine()
return _search_engine
# ---------------------------------------------------------------------------
# Request / Response Schemas
# ---------------------------------------------------------------------------
class SearchRequest(BaseModel):
"""Eingabe für den Search-Endpoint."""
query: str = Field(..., min_length=1, max_length=500, description="Suchanfrage.")
language: str = Field(default="de", description="Sprachcode (z.B. 'de', 'en').")
max_results: int = Field(default=10, ge=1, le=50, description="Maximale Ergebnisanzahl.")
providers: list[str] | None = Field(
default=None,
description="Optionale Liste von Provider-Namen. Leer = alle aktiven.",
)
class SearchResponse(BaseModel):
"""Ausgabe des Search-Endpoints."""
results: list[NormalizedResult] = Field(
description="Normalisierte Suchergebnisse. rank ist KEIN truth_score."
)
debug: dict[str, Any] | None = Field(
default=None,
description="Debug-Informationen wenn NSCT_DEBUG=true.",
)
# ---------------------------------------------------------------------------
# Endpoint
# ---------------------------------------------------------------------------
@router.post("/search", response_model=SearchResponse, summary="Search via normalized search providers")
async def search(request: SearchRequest) -> SearchResponse:
"""POST /search — normale Suchanfrage mit normalisierten Ergebnissen.
Kein LLM — nur rohe Suchergebnisse normalisieren.
Bei NSCT_DEBUG=true werden Debug-Informationen mitgeliefert.
"""
engine = get_search_engine()
debug_enabled = os.environ.get("NSCT_DEBUG", "false").lower() == "true"
try:
if debug_enabled:
debug_result = await engine.search_with_debug(
query=request.query,
language=request.language,
max_results=request.max_results,
provider_names=request.providers,
)
return SearchResponse(
results=debug_result["results"],
debug=debug_result["debug"],
)
else:
results = await engine.search(
query=request.query,
language=request.language,
max_results=request.max_results,
provider_names=request.providers,
)
return SearchResponse(results=results)
except Exception as exc:
logger.error("Search failed: %s", exc)
raise HTTPException(status_code=503, detail="Search service unavailable") from exc

View File

@@ -0,0 +1,345 @@
"""Search provider interface — neutral ranking, no trust implication."""
from __future__ import annotations
import abc
import os
import time
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, Field, field_validator
# ---------------------------------------------------------------------------
# NormalizedResult
# ---------------------------------------------------------------------------
class NormalizedResult(BaseModel):
"""Normalisiertes Suchergebnis — provider-unabhängig.
WICHTIG: rank ist KEIN truth_score.
Suchergebnis auf Position 1 ist nicht automatisch glaubwürdiger als Position 8.
"""
title: str = Field(..., min_length=1, description="Titel des Suchergebnisses.")
url: str = Field(..., description="Original-URL der Quelle.")
snippet: str = Field(default="", description="Auszug/Snippet aus dem Suchergebnis.")
provider: str = Field(..., description="Name des Providers, der dieses Ergebnis geliefert hat.")
rank: int = Field(
...,
ge=1,
description="Original-Ranking des Providers. KEIN Vertrauens- oder Wahrheitsindikator.",
)
retrieved_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
description="Zeitpunkt der Abrufung (UTC).",
)
extra: dict[str, Any] = Field(
default_factory=dict,
description="Zusätzliche provider-spezifische Metadaten.",
)
@field_validator("url")
@classmethod
def _validate_url(cls, v: str) -> str:
"""Stelle sicher, dass URL einen validen Scheme hat."""
if not v.startswith(("http://", "https://")):
raise ValueError("URL muss mit http:// oder https:// beginnen")
return v
@field_validator("title")
@classmethod
def _validate_title(cls, v: str) -> str:
if not v.strip():
raise ValueError("title darf nicht leer sein")
return v
@classmethod
def from_raw(
cls,
provider_name: str,
title: str,
url: str,
snippet: str = "",
rank: int = 1,
extra: dict[str, Any] | None = None,
retrieved_at: datetime | None = None,
) -> "NormalizedResult":
"""Factory: Erzeuge ein NormalizedResult aus rohen Provider-Daten."""
if retrieved_at is None:
retrieved_at = datetime.now(timezone.utc)
return cls(
title=title.strip() if title else "",
url=url.strip(),
snippet=snippet.strip() if snippet else "",
provider=provider_name,
rank=rank,
retrieved_at=retrieved_at,
extra=extra or {},
)
# ---------------------------------------------------------------------------
# ProviderConfig
# ---------------------------------------------------------------------------
class ProviderConfig(BaseModel):
"""Konfiguration für einen einzelnen SearchProvider."""
name: str = Field(..., description="Eindeutiger Provider-Name.")
enabled: bool = Field(default=True, description="Ob der Provider aktiviert ist.")
timeout_seconds: float = Field(
default=10.0,
description="Timeout pro Provider-Anfrage in Sekunden.",
gt=0,
)
max_results: int = Field(
default=10,
ge=1,
description="Max Ergebnisse pro Provider.",
)
# ---------------------------------------------------------------------------
# SearchProvider Interface
# ---------------------------------------------------------------------------
class SearchProvider(abc.ABC):
"""Schnittstelle für Suchmaschinen.
Kein Provider darf als Evidenz-Ranking verwendet werden.
Das Ergebnis-Ranking einer Suchmaschine ist KEIN Vertrauensindikator.
"""
_provider_name: str = ""
_provider_config: ProviderConfig | None = None
@abc.abstractmethod
async def search(
self,
query: str,
language: str = "de",
max_results: int = 10,
) -> list[NormalizedResult]:
"""Suche durchführen.
Parameters
----------
query: Suchanfrage-Text.
language: Sprachcode (z.B. 'de', 'en').
max_results: Maximale Anzahl zurückzugebender Ergebnisse.
Returns
-------
list[NormalizedResult] — ohne Ranking-Implikation.
"""
...
@abc.abstractmethod
async def get_metadata(self) -> dict[str, Any]:
"""Provider-Metadata (Name, Version, capabilities)."""
...
async def health_check(self) -> bool:
"""Prüfe, ob der Provider erreichbar ist."""
try:
meta = await self.get_metadata()
return bool(meta.get("name"))
except Exception:
return False
# ---------------------------------------------------------------------------
# Multi-Provider-Orchestrator
# ---------------------------------------------------------------------------
class MultiProviderSearch:
"""Orchestrator für mehrere SearchProvider parallel.
- Sammelt Ergebnisse aller aktiven Provider.
- Dedupliziert nach URL.
- Behält provider-Information für jedes Ergebnis.
- Fällt stillschweigend auf aus, wenn ein Provider ausfällt.
"""
def __init__(self, providers: list[SearchProvider] | None = None) -> None:
"""Initialisiere mit einer Liste von SearchProvider-Instanzen."""
self._providers: list[SearchProvider] = providers or []
self._name_index: dict[str, SearchProvider] = {}
for p in self._providers:
name = getattr(p, "_provider_name", p.__class__.__name__)
self._name_index[name] = p
def add_provider(self, provider: SearchProvider, config: ProviderConfig | None = None) -> None:
"""Füge einen Provider hinzu."""
self._providers.append(provider)
name = getattr(provider, "_provider_name", provider.__class__.__name__)
self._name_index[name] = provider
def get_provider(self, name: str) -> SearchProvider | None:
"""Gib einen Provider nach Namen zurück."""
return self._name_index.get(name)
def enabled_providers(self) -> list[tuple[str, SearchProvider, ProviderConfig]]:
"""Gib alle aktivierten (Name, Provider, Config)-Tupel zurück."""
results: list[tuple[str, SearchProvider, ProviderConfig]] = []
for p in self._providers:
name = getattr(p, "_provider_name", p.__class__.__name__)
cfg = getattr(p, "_provider_config", None)
if cfg is None:
cfg = ProviderConfig(name=name, enabled=True)
if not isinstance(cfg, ProviderConfig):
cfg = ProviderConfig(name=name, enabled=True)
if cfg.enabled:
results.append((name, p, cfg))
return results
async def search(
self,
query: str,
language: str = "de",
max_results: int = 10,
provider_names: list[str] | None = None,
timeout_seconds: float | None = None,
) -> list[NormalizedResult]:
"""Starte parallele Suche über alle (oder ausgewählte) Provider.
Returns
-------
Deduplizierte, normalisierte Ergebnisse.
"""
import asyncio
candidates: list[tuple[str, SearchProvider, ProviderConfig]] = []
if provider_names:
for pname in provider_names:
p = self.get_provider(pname)
if p is None:
continue
cfg = getattr(p, "_provider_config", None)
if cfg is None:
cfg = ProviderConfig(name=pname, enabled=True)
elif not isinstance(cfg, ProviderConfig):
cfg = ProviderConfig(name=pname, enabled=True)
candidates.append((pname, p, cfg))
else:
candidates = self.enabled_providers()
if not candidates:
return []
async def _run(p: SearchProvider, cfg: ProviderConfig) -> list[NormalizedResult]:
to = timeout_seconds if timeout_seconds else cfg.timeout_seconds
try:
return await asyncio.wait_for(
p.search(query, language=language, max_results=cfg.max_results),
timeout=to,
)
except asyncio.TimeoutError:
return []
except Exception:
return []
tasks = [_run(p, cfg) for _, p, cfg in candidates]
raw_results: list[list[NormalizedResult]] = await asyncio.gather(*tasks)
# Zusammenführen und deduplizieren nach URL
seen_urls: set[str] = set()
deduped: list[NormalizedResult] = []
for batch in raw_results:
for r in batch:
url_key = r.url.lower().rstrip("/")
if url_key not in seen_urls:
seen_urls.add(url_key)
deduped.append(r)
if len(deduped) >= max_results:
break
if len(deduped) >= max_results:
break
return deduped[:max_results]
async def search_with_debug(
self,
query: str,
language: str = "de",
max_results: int = 10,
provider_names: list[str] | None = None,
timeout_seconds: float | None = None,
) -> dict[str, Any]:
"""Parallele Suche mit Debug-Informationen (Timing, Provider-Status)."""
import asyncio
start = time.monotonic()
candidates: list[tuple[str, SearchProvider, ProviderConfig]] = []
if provider_names:
for pname in provider_names:
p = self.get_provider(pname)
if p is None:
continue
cfg = getattr(p, "_provider_config", None)
if cfg is None:
cfg = ProviderConfig(name=pname, enabled=True)
elif not isinstance(cfg, ProviderConfig):
cfg = ProviderConfig(name=pname, enabled=True)
candidates.append((pname, p, cfg))
else:
candidates = self.enabled_providers()
timings: dict[str, float] = {}
results_by_provider: dict[str, list[NormalizedResult]] = {}
errors: dict[str, str] = {}
async def _run_debug(p: SearchProvider, cfg: ProviderConfig) -> None:
to = timeout_seconds if timeout_seconds else cfg.timeout_seconds
t0 = time.monotonic()
try:
res = await asyncio.wait_for(
p.search(query, language=language, max_results=cfg.max_results),
timeout=to,
)
timings[p.__class__.__name__] = time.monotonic() - t0
results_by_provider[p.__class__.__name__] = res
except asyncio.TimeoutError:
timings[p.__class__.__name__] = time.monotonic() - t0
errors[p.__class__.__name__] = "timeout"
except Exception as exc:
timings[p.__class__.__name__] = time.monotonic() - t0
errors[p.__class__.__name__] = str(exc)
tasks = [_run_debug(p, cfg) for _, p, cfg in candidates]
await asyncio.gather(*tasks)
# Dedupliziere
seen: set[str] = set()
deduped: list[NormalizedResult] = []
for batch in results_by_provider.values():
for r in batch:
u = r.url.lower().rstrip("/")
if u not in seen:
seen.add(u)
deduped.append(r)
deduped = deduped[:max_results]
elapsed = time.monotonic() - start
return {
"results": deduped,
"debug": {
"total_time_seconds": round(elapsed, 3),
"providers": {
name: {
"timing_seconds": round(timings.get(name, 0), 3),
"result_count": len(results_by_provider.get(name, [])),
"error": errors.get(name),
}
for name, _, _ in candidates
},
"total_results": len(deduped),
},
}

View File

@@ -0,0 +1,192 @@
"""DuckDuckGo search provider — lightweight HTTP-based search.
Uses DuckDuckGo's HTML search page (no API key required).
This is a fallback provider — if DuckDuckGo is unavailable, the
system continues functioning normally.
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
from html.parser import HTMLParser
from typing import Any
import httpx
from nsct.providers.abstract import NormalizedResult, SearchProvider
# Environment-Variable für das DuckDuckGo Gateway
# DDGS_BASE_URL — Base URL (default: DuckDuckGo HTML)
# DDGS_USER_AGENT — Custom User-Agent string
_DDGS_BASE_URL: str = os.environ.get("DDGS_BASE_URL", "https://html.duckduckgo.com")
_DDGS_USER_AGENT: str = os.environ.get(
"DDGS_USER_AGENT",
"NSCT/1.0 Neutral Search Crawler Tool (research@localhost)",
)
class DuckDuckGoProvider(SearchProvider):
"""DuckDuckGo Search Provider.
Nutzt die HTML-Version von DuckDuckGo über HTTP keine API-Keys,
keine externen Services. Fallback-fähig: wenn DuckDuckGo nicht
erreichbar ist, gibt der Provider leere Ergebnisse zurück und das
Gesamtsystem funktioniert weiter.
"""
_provider_name: str = "duckduckgo"
def __init__(self, base_url: str | None = None, user_agent: str | None = None) -> None:
"""Initialisiere den DuckDuckGo-Provider.
Parameters
----------
base_url: Optionale URL-Override (env DDGS_BASE_URL takes priority).
user_agent: Optionale User-Agent-Override.
"""
self._base_url: str = base_url or _DDGS_BASE_URL
self._user_agent: str = user_agent or _DDGS_USER_AGENT
self._client: httpx.AsyncClient | None = None
@property
def _http_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(15.0, connect=5.0),
headers={
"User-Agent": self._user_agent,
"Accept": "text/html,application/xhtml+xml",
},
follow_redirects=True,
)
return self._client
async def _search_html(self, query: str, language: str = "de") -> str:
"""Führe die DuckDuckGo-Suche durch und liefere den HTML-Body."""
lang_map: dict[str, str] = {
"de": "de_de",
"en": "en_us",
"fr": "fr_fr",
"es": "es_es",
"it": "it_it",
"pt": "pt_br",
"nl": "nl_nl",
"ru": "ru_ru",
"ja": "ja_jp",
"zh": "zh_cn",
}
lang_param = lang_map.get(language, "en_us")
params: dict[str, str] = {
"q": query,
"lang": language,
}
query_str = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{self._base_url}/html/?{query_str}"
resp = await self._http_client.get(url)
resp.raise_for_status()
return resp.text
def _parse_html(self, html: str, query: str) -> list[dict[str, str]]:
"""Parst die DuckDuckGo-HTML-Seite und extrahiert Web-Ergebnisse."""
results: list[dict[str, str]] = []
# DuckDuckGo's HTML structure uses <a class="result__a"> for result links
# and <span class="result__snippet"> for snippets
import re
# Find result links
result_links = re.finditer(
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)</a>',
html,
re.DOTALL,
)
# Find snippets
snippets = re.finditer(
r'<span[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</span>',
html,
re.DOTALL,
)
link_urls = list(re.finditer(r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"', html))
snippet_texts = list(re.finditer(
r'<span[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</span>',
html,
re.DOTALL,
))
link_titles = list(re.finditer(
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*>(.*?)</a>',
html,
re.DOTALL,
))
for idx, link in enumerate(link_urls):
url = link.group(1)
title = link_titles[idx].group(1).strip() if idx < len(link_titles) else ""
snippet = snippet_texts[idx].group(1).strip() if idx < len(snippet_texts) else ""
# Strip HTML tags from title and snippet
title = re.sub(r"<[^>]*>", "", title).strip()
snippet = re.sub(r"<[^>]*>", "", snippet).strip()
if url and url.startswith(("http://", "https://")):
results.append({
"title": title or query,
"url": url,
"snippet": snippet[:500],
})
return results
async def search(
self,
query: str,
language: str = "de",
max_results: int = 10,
) -> list[NormalizedResult]:
"""Suche über DuckDuckGo HTML und normalisiere Ergebnisse."""
results: list[NormalizedResult] = []
try:
html = await self._search_html(query, language)
parsed = self._parse_html(html, query)
for idx, item in enumerate(parsed[:max_results], start=1):
results.append(
NormalizedResult.from_raw(
provider_name="duckduckgo",
title=item.get("title", query),
url=item.get("url", ""),
snippet=item.get("snippet", ""),
rank=idx,
)
)
except Exception:
# Fallback: kein Fehler werfen leere Liste zurückgeben.
# Das System funktioniert trotzdem weiter.
pass
return results
async def get_metadata(self) -> dict[str, Any]:
"""Gib Provider-Metadata zurück."""
return {
"name": "duckduckgo",
"version": "0.1.0",
"provider": self.__class__.__name__,
"capabilities": ["web_search", "html_scraping"],
"requires_api_key": False,
"base_url": self._base_url,
"language_support": ["de", "en", "fr", "es", "it", "pt", "nl", "ru", "ja", "zh"],
"note": "Search ranking is NOT a trust indicator.",
}
async def health_check(self) -> bool:
"""Prüfe die Erreichbarkeit von DuckDuckGo."""
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=2.0)) as client:
resp = await client.get(f"{self._base_url}/", follow_redirects=True)
return resp.status_code == 200
except Exception:
return False

View File

@@ -0,0 +1,12 @@
"""Multi-provider orchestrator — re-exported from abstract for convenience."""
from __future__ import annotations
from nsct.providers.abstract import MultiProviderSearch, NormalizedResult, ProviderConfig, SearchProvider
__all__ = [
"MultiProviderSearch",
"NormalizedResult",
"ProviderConfig",
"SearchProvider",
]