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:
@@ -75,6 +75,10 @@ def create_app() -> FastAPI:
|
|||||||
from nsct.api.health import router as health_router
|
from nsct.api.health import router as health_router
|
||||||
app.include_router(health_router, tags=["system"])
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
120
src/nsct/api/search.py
Normal file
120
src/nsct/api/search.py
Normal 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
|
||||||
345
src/nsct/providers/abstract.py
Normal file
345
src/nsct/providers/abstract.py
Normal 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),
|
||||||
|
},
|
||||||
|
}
|
||||||
192
src/nsct/providers/duckduckgo.py
Normal file
192
src/nsct/providers/duckduckgo.py
Normal 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
|
||||||
12
src/nsct/providers/multi.py
Normal file
12
src/nsct/providers/multi.py
Normal 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",
|
||||||
|
]
|
||||||
369
tests/test_search.py
Normal file
369
tests/test_search.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
"""Tests for the search provider layer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nsct.providers.abstract import MultiProviderSearch, NormalizedResult, ProviderConfig, SearchProvider
|
||||||
|
from nsct.providers.duckduckgo import DuckDuckGoProvider
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fake provider for testing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSearchProvider(SearchProvider):
|
||||||
|
"""Simpler Test-Provider, der vordefinierte Ergebnisse zurückgibt."""
|
||||||
|
|
||||||
|
_provider_name = "fake"
|
||||||
|
|
||||||
|
def __init__(self, results: list[dict], fail: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
results: Liste von Dicts mit keys: title, url, snippet, rank.
|
||||||
|
fail: Wenn True, wirft search() eine Exception.
|
||||||
|
"""
|
||||||
|
self._results = results
|
||||||
|
self._fail = fail
|
||||||
|
self.call_count = 0
|
||||||
|
self._provider_config = ProviderConfig(name="fake", enabled=True)
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
language: str = "de",
|
||||||
|
max_results: int = 10,
|
||||||
|
) -> list[NormalizedResult]:
|
||||||
|
self.call_count += 1
|
||||||
|
if self._fail:
|
||||||
|
raise RuntimeError("Simulated provider failure")
|
||||||
|
normalized: list[NormalizedResult] = []
|
||||||
|
for idx, item in enumerate(self._results[:max_results], start=1):
|
||||||
|
normalized.append(
|
||||||
|
NormalizedResult.from_raw(
|
||||||
|
provider_name="fake",
|
||||||
|
title=item.get("title", ""),
|
||||||
|
url=item.get("url", ""),
|
||||||
|
snippet=item.get("snippet", ""),
|
||||||
|
rank=idx,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
async def get_metadata(self) -> dict:
|
||||||
|
return {"name": "fake", "version": "0.0.1", "provider": "FakeSearchProvider"}
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return not self._fail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: NormalizedResult
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizedResult:
|
||||||
|
"""Tests für NormalizedResult-Schema."""
|
||||||
|
|
||||||
|
def test_required_fields(self):
|
||||||
|
"""Alle required fields müssen vorhanden sein."""
|
||||||
|
r = NormalizedResult.from_raw(
|
||||||
|
provider_name="test",
|
||||||
|
title="Test Title",
|
||||||
|
url="https://example.com",
|
||||||
|
snippet="Test snippet",
|
||||||
|
rank=1,
|
||||||
|
)
|
||||||
|
assert r.title == "Test Title"
|
||||||
|
assert r.url == "https://example.com"
|
||||||
|
assert r.snippet == "Test snippet"
|
||||||
|
assert r.provider == "test"
|
||||||
|
assert r.rank == 1
|
||||||
|
assert isinstance(r.retrieved_at, datetime)
|
||||||
|
|
||||||
|
def test_rank_is_not_truth(self):
|
||||||
|
"""rank ist ein Ranking-Indikator, kein truth_score."""
|
||||||
|
r = NormalizedResult.from_raw(
|
||||||
|
provider_name="test",
|
||||||
|
title="Rank 5",
|
||||||
|
url="https://example.com/5",
|
||||||
|
rank=5,
|
||||||
|
)
|
||||||
|
assert r.rank == 5
|
||||||
|
assert r.rank != r.rank + 1
|
||||||
|
assert r.title != "" # Titel ist die Quelle der Wahrheit, nicht rank
|
||||||
|
|
||||||
|
def test_url_validation(self):
|
||||||
|
"""URLs müssen mit http:// oder https:// beginnen."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
NormalizedResult.from_raw(
|
||||||
|
provider_name="test",
|
||||||
|
title="Bad URL",
|
||||||
|
url="not-a-url",
|
||||||
|
rank=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_title_raises(self):
|
||||||
|
"""Leere titles sind ungültig."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
NormalizedResult.from_raw(
|
||||||
|
provider_name="test",
|
||||||
|
title="",
|
||||||
|
url="https://example.com",
|
||||||
|
rank=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_extra_fields(self):
|
||||||
|
"""Zusätzliche Metadaten können gespeichert werden."""
|
||||||
|
r = NormalizedResult.from_raw(
|
||||||
|
provider_name="test",
|
||||||
|
title="With extra",
|
||||||
|
url="https://example.com",
|
||||||
|
rank=1,
|
||||||
|
extra={"domain": "example.com", "category": "news"},
|
||||||
|
)
|
||||||
|
assert r.extra["domain"] == "example.com"
|
||||||
|
assert r.extra["category"] == "news"
|
||||||
|
|
||||||
|
def test_timestamp_default_utc(self):
|
||||||
|
"""Standard-Zeitstempel muss UTC sein."""
|
||||||
|
r = NormalizedResult.from_raw(
|
||||||
|
provider_name="test",
|
||||||
|
title="Timestamp test",
|
||||||
|
url="https://example.com",
|
||||||
|
rank=1,
|
||||||
|
)
|
||||||
|
assert r.retrieved_at.tzinfo is not None
|
||||||
|
|
||||||
|
def test_from_raw_factory(self):
|
||||||
|
"""Factory-Methode erzeugt korrekte NormalizedResult-Instanzen."""
|
||||||
|
r = NormalizedResult.from_raw(
|
||||||
|
provider_name="duckduckgo",
|
||||||
|
title="Test",
|
||||||
|
url="https://example.com",
|
||||||
|
snippet="Snippet text",
|
||||||
|
rank=1,
|
||||||
|
extra={"source": "web"},
|
||||||
|
)
|
||||||
|
assert r.provider == "duckduckgo"
|
||||||
|
assert r.extra == {"source": "web"}
|
||||||
|
|
||||||
|
def test_custom_retrieved_at(self):
|
||||||
|
"""Benutzerdefinierter retrieved_at wird übernommen."""
|
||||||
|
custom = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
r = NormalizedResult.from_raw(
|
||||||
|
provider_name="test",
|
||||||
|
title="Custom time",
|
||||||
|
url="https://example.com",
|
||||||
|
rank=1,
|
||||||
|
retrieved_at=custom,
|
||||||
|
)
|
||||||
|
assert r.retrieved_at == custom
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: MultiProviderSearch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiProviderSearch:
|
||||||
|
"""Tests für MultiProviderSearch-Orchestrator."""
|
||||||
|
|
||||||
|
def test_add_and_run_provider(self):
|
||||||
|
"""Provider hinzufügen und Suche ausführen."""
|
||||||
|
fake = FakeSearchProvider([
|
||||||
|
{"title": "Result 1", "url": "https://example.com/1", "snippet": "First", "rank": 1},
|
||||||
|
{"title": "Result 2", "url": "https://example.com/2", "snippet": "Second", "rank": 2},
|
||||||
|
])
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
engine.add_provider(fake)
|
||||||
|
|
||||||
|
results = asyncio.run(engine.search("test query"))
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0].title == "Result 1"
|
||||||
|
assert results[1].url == "https://example.com/2"
|
||||||
|
|
||||||
|
def test_parallel_multiple_providers(self):
|
||||||
|
"""Multiple Provider parallel abfragen."""
|
||||||
|
fake_a = FakeSearchProvider([
|
||||||
|
{"title": "A-1", "url": "https://a.example/1", "snippet": "A first", "rank": 1},
|
||||||
|
{"title": "A-2", "url": "https://a.example/2", "snippet": "A second", "rank": 2},
|
||||||
|
])
|
||||||
|
fake_b = FakeSearchProvider([
|
||||||
|
{"title": "B-1", "url": "https://b.example/1", "snippet": "B first", "rank": 1},
|
||||||
|
])
|
||||||
|
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
engine.add_provider(fake_a)
|
||||||
|
engine.add_provider(fake_b)
|
||||||
|
|
||||||
|
results = asyncio.run(engine.search("test query"))
|
||||||
|
# Alle 3 Ergebnisse sollten vorhanden sein
|
||||||
|
assert len(results) == 3
|
||||||
|
urls = {r.url for r in results}
|
||||||
|
assert "https://a.example/1" in urls
|
||||||
|
assert "https://a.example/2" in urls
|
||||||
|
assert "https://b.example/1" in urls
|
||||||
|
|
||||||
|
def test_url_deduplication(self):
|
||||||
|
"""Doppelte URLs nach URL werden dedupliziert."""
|
||||||
|
fake_a = FakeSearchProvider([
|
||||||
|
{"title": "Same URL A", "url": "https://example.com/same", "snippet": "From A", "rank": 1},
|
||||||
|
])
|
||||||
|
fake_b = FakeSearchProvider([
|
||||||
|
{"title": "Same URL B", "url": "https://example.com/same", "snippet": "From B", "rank": 1},
|
||||||
|
])
|
||||||
|
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
engine.add_provider(fake_a)
|
||||||
|
engine.add_provider(fake_b)
|
||||||
|
|
||||||
|
results = asyncio.run(engine.search("test query"))
|
||||||
|
# Nur eine Instanz der URL sollte vorkommen
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].url == "https://example.com/same"
|
||||||
|
|
||||||
|
def test_fallback_when_provider_fails(self):
|
||||||
|
"""Wenn ein Provider ausfällt, funktionieren andere weiter."""
|
||||||
|
failing = FakeSearchProvider([], fail=True)
|
||||||
|
working = FakeSearchProvider([
|
||||||
|
{"title": "Still works", "url": "https://works.com", "snippet": "I work", "rank": 1},
|
||||||
|
])
|
||||||
|
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
engine.add_provider(failing)
|
||||||
|
engine.add_provider(working)
|
||||||
|
|
||||||
|
results = asyncio.run(engine.search("test query"))
|
||||||
|
# Nur das Ergebnis des working-Providers sollte da sein
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].url == "https://works.com"
|
||||||
|
|
||||||
|
def test_empty_provider_list(self):
|
||||||
|
"""Keine Provider → leere Ergebnisse."""
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
results = asyncio.run(engine.search("test query"))
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
def test_max_results_limit(self):
|
||||||
|
"""max_results limitiert die Rückgabeanzahl."""
|
||||||
|
big_provider = FakeSearchProvider([
|
||||||
|
{"title": f"Item {i}", "url": f"https://example.com/{i}", "snippet": f"Snip {i}", "rank": i}
|
||||||
|
for i in range(1, 11) # 10 results
|
||||||
|
])
|
||||||
|
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
engine.add_provider(big_provider)
|
||||||
|
|
||||||
|
results = asyncio.run(engine.search("test query", max_results=3))
|
||||||
|
assert len(results) <= 3
|
||||||
|
|
||||||
|
def test_get_provider_by_name(self):
|
||||||
|
"""Provider nach Namen lookup."""
|
||||||
|
fake = FakeSearchProvider([])
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
engine.add_provider(fake)
|
||||||
|
|
||||||
|
retrieved = engine.get_provider("fake")
|
||||||
|
assert retrieved is fake
|
||||||
|
|
||||||
|
def test_enabled_providers(self):
|
||||||
|
"""Nur enabled Provider werden zurückgegeben."""
|
||||||
|
fake = FakeSearchProvider([])
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
engine.add_provider(fake)
|
||||||
|
|
||||||
|
enabled = engine.enabled_providers()
|
||||||
|
assert len(enabled) == 1
|
||||||
|
assert enabled[0][0] == "fake"
|
||||||
|
|
||||||
|
def test_disabled_provider_not_searched(self):
|
||||||
|
"""Deaktivierte Provider werden nicht abgefragt."""
|
||||||
|
fake = FakeSearchProvider(
|
||||||
|
[{"title": "Disabled", "url": "https://disabled.com", "snippet": "nope", "rank": 1}]
|
||||||
|
)
|
||||||
|
fake._provider_config = ProviderConfig(name="fake", enabled=False)
|
||||||
|
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
engine.add_provider(fake)
|
||||||
|
|
||||||
|
results = asyncio.run(engine.search("test query"))
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
def test_provider_names_filter(self):
|
||||||
|
"""Nur explizit angegebene Provider werden abgefragt."""
|
||||||
|
fake_a = FakeSearchProvider([{"title": "A", "url": "https://a.com", "snippet": "a", "rank": 1}])
|
||||||
|
fake_b = FakeSearchProvider([{"title": "B", "url": "https://b.com", "snippet": "b", "rank": 1}])
|
||||||
|
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
engine.add_provider(fake_a)
|
||||||
|
engine.add_provider(fake_b)
|
||||||
|
|
||||||
|
# query mit provider_names
|
||||||
|
results = asyncio.run(engine.search("test query", provider_names=["fake"]))
|
||||||
|
assert len(results) == 1 # Beide haben den Namen "fake", nur eine Instanz
|
||||||
|
|
||||||
|
def test_search_with_debug_empty(self):
|
||||||
|
"""Debug-Mode mit keiner Provider gibt leere Results."""
|
||||||
|
engine = MultiProviderSearch()
|
||||||
|
result = asyncio.run(engine.search_with_debug("test"))
|
||||||
|
assert result["debug"]["total_results"] == 0
|
||||||
|
assert len(result["results"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: DuckDuckGo Provider
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuckDuckGoProvider:
|
||||||
|
"""Tests für DuckDuckGoProvider."""
|
||||||
|
|
||||||
|
def test_metadata(self):
|
||||||
|
"""Provider gibt korrekte Metadata zurück."""
|
||||||
|
provider = DuckDuckGoProvider()
|
||||||
|
meta = asyncio.run(provider.get_metadata())
|
||||||
|
assert meta["name"] == "duckduckgo"
|
||||||
|
assert meta["requires_api_key"] is False
|
||||||
|
assert meta["capabilities"] == ["web_search", "html_scraping"]
|
||||||
|
|
||||||
|
def test_metadata_structure(self):
|
||||||
|
"""Metadata enthält alle erwarteten Felder."""
|
||||||
|
provider = DuckDuckGoProvider()
|
||||||
|
meta = asyncio.run(provider.get_metadata())
|
||||||
|
assert "name" in meta
|
||||||
|
assert "version" in meta
|
||||||
|
assert "provider" in meta
|
||||||
|
assert "capabilities" in meta
|
||||||
|
assert "note" in meta
|
||||||
|
assert "Search ranking is NOT a trust indicator." in meta["note"]
|
||||||
|
|
||||||
|
def test_is_search_provider(self):
|
||||||
|
"""DuckDuckGoProvider implementiert SearchProvider."""
|
||||||
|
provider = DuckDuckGoProvider()
|
||||||
|
assert isinstance(provider, SearchProvider)
|
||||||
|
|
||||||
|
def test_no_api_key_required(self):
|
||||||
|
"""DuckDuckGo benötigt keinen API-Key."""
|
||||||
|
provider = DuckDuckGoProvider()
|
||||||
|
meta = asyncio.run(provider.get_metadata())
|
||||||
|
assert meta["requires_api_key"] is False
|
||||||
|
|
||||||
|
def test_url_validation_for_duckduckgo(self):
|
||||||
|
"""Ergebnisse von DuckDuckGo durchlaufen URL-Validierung."""
|
||||||
|
provider = DuckDuckGoProvider()
|
||||||
|
# Der Provider selbst ist ein SearchProvider, das ist der Test.
|
||||||
|
assert provider._provider_name == "duckduckgo"
|
||||||
|
|
||||||
|
def test_health_check_interface(self):
|
||||||
|
"""health_check gibt bool zurück."""
|
||||||
|
provider = DuckDuckGoProvider()
|
||||||
|
# Wir können nicht die tatsächliche Erreichbarkeit testen ohne Netzwerk,
|
||||||
|
# aber wir prüfen, dass die Methode existiert und bool zurückgibt.
|
||||||
|
result = asyncio.run(provider.health_check())
|
||||||
|
assert isinstance(result, bool)
|
||||||
Reference in New Issue
Block a user