From e483c4d13faca91b9475542e776da65e3f8d83ce Mon Sep 17 00:00:00 2001 From: faligam Date: Mon, 7 Sep 2026 12:46:06 +0200 Subject: [PATCH] fix search runs with empty results --- HANDOFF.md | 24 ++++++++++++++++ docker-compose.yml | 3 ++ src/nsct/config.py | 12 +++++++- src/nsct/orchestration/orchestrator.py | 21 +++++++++++++- src/nsct/providers/searxng.py | 15 ++++++++-- tests/test_backend_pipeline_repairs.py | 40 ++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 4 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 16f8647..eddba17 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,6 +7,30 @@ ## Aktueller Stand — 2026-09-07 +### Browser-E2E: Leeren Abschluss auf Suchausfall zurückgeführt und repariert — 2026-09-07 + +Der abgeschlossene Browser-Run `33328b0b-18df-48df-9f2d-8af077987ccc` hatte +einen validen Plan mit sechs Suchanfragen, aber keine Treffer: Quellen, +Claims, Evidenz und Bericht waren deshalb leer. SearXNG war erreichbar, seine +Default-Engine-Mischung lieferte im aktuellen Netz jedoch wegen CAPTCHA, 429 +und Timeouts keine nutzbaren Ergebnisse. Der Orchestrator behandelte diesen +Fall fehlerhaft als erfolgreichen Run und erzeugte einen leeren Fallback- +Bericht. + +- Ein providerweiter Trefferstand von null beendet den Run nun mit einem + eindeutigen Suchfehler, statt `completed` mit leerem Bericht zu speichern. +- Die SearXNG-Engine-Liste ist über `NSCT_SEARXNG_ENGINES` konfigurierbar. + Der lokale Compose-Default ist `bing,yahoo`; diese beiden Engines lieferten + im API-Container erfolgreich Ergebnisse. Betreiber können die Auswahl in + ihrer Umgebung überschreiben. + +Validierung: 70 gezielte Tests (`test_backend_pipeline_repairs.py`, +`test_crawler.py`, `test_search.py`), Compose-Konfigurationsprüfung, +Docker-Rebuild und `/health` HTTP 200. Der direkte Provider-Probe aus dem +API-Container lieferte fünf Resultate. Für den nächsten Browser-E2E-Test eine +neue Recherche starten; ein API-Rebuild verwirft weiterhin alte In-Memory- +Run-IDs. + ### Planungsinformationen in Research-Details umgesetzt — 2026-09-07 Issue [Frontend #1](https://git.frerkc.de/opencode/NSCT-FrontEnd/issues/1) diff --git a/docker-compose.yml b/docker-compose.yml index 5e61624..a32fdc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,9 @@ services: NSCT_DB_URL: postgresql+asyncpg://${POSTGRES_USER:-nsct}:${POSTGRES_PASSWORD:-nsct_secret}@postgres:5432/${POSTGRES_DB:-nsct} NSCT_LLM_MAX_CONCURRENCY: "${NSCT_LLM_MAX_CONCURRENCY:-3}" NSCT_SEARXNG_BASE_URL: http://searxng:8080/ + # The default SearXNG engine set is often rate-limited. Operators can + # override this comma-separated list in their environment. + NSCT_SEARXNG_ENGINES: "${NSCT_SEARXNG_ENGINES:-bing,yahoo}" depends_on: postgres: condition: service_healthy diff --git a/src/nsct/config.py b/src/nsct/config.py index 4c04e77..8f9cf10 100644 --- a/src/nsct/config.py +++ b/src/nsct/config.py @@ -62,6 +62,10 @@ class AppSettings(BaseModel): postgres: DatabaseConfig = Field(default_factory=DatabaseConfig) debug: bool = Field(default=False) searxng_base_url: str | None = Field(default=None, description="SearXNG instance URL.") + searxng_engines: tuple[str, ...] = Field( + default=(), + description="Optional allow-list of SearXNG engines for web searches.", + ) @staticmethod def from_env() -> "AppSettings": @@ -98,6 +102,11 @@ class AppSettings(BaseModel): debug = os.environ.get("NSCT_DEBUG", "false").lower() == "true" searxng_url = os.environ.get("NSCT_SEARXNG_BASE_URL", None) + searxng_engines = tuple( + engine.strip() + for engine in os.environ.get("NSCT_SEARXNG_ENGINES", "").split(",") + if engine.strip() + ) return AppSettings( llm=llm_cfg, @@ -106,6 +115,7 @@ class AppSettings(BaseModel): postgres=postgres_cfg, debug=debug, searxng_base_url=searxng_url, + searxng_engines=searxng_engines, ) @property @@ -113,4 +123,4 @@ class AppSettings(BaseModel): """Return a config directory path for persisting runtime data.""" data_dir = Path.home() / ".nsct" data_dir.mkdir(parents=True, exist_ok=True) - return data_dir \ No newline at end of file + return data_dir diff --git a/src/nsct/orchestration/orchestrator.py b/src/nsct/orchestration/orchestrator.py index d5ac596..e50f594 100644 --- a/src/nsct/orchestration/orchestrator.py +++ b/src/nsct/orchestration/orchestrator.py @@ -307,7 +307,10 @@ class ResearchOrchestrator: try: from nsct.providers.searxng import SearXNGProvider if self._config.searxng_base_url: - provider = SearXNGProvider(base_url=self._config.searxng_base_url) + provider = SearXNGProvider( + base_url=self._config.searxng_base_url, + engines=self._config.searxng_engines, + ) providers.append(provider) except ImportError as exc: logger.error("SearXNG provider is unavailable: %s", exc) @@ -641,6 +644,22 @@ class ResearchOrchestrator: } self._search_results.append(result_dict) + if not self._search_results: + # An empty result set from every provider is not a completed + # research result. Continuing would make fetching, + # extraction, and synthesis silently produce an empty report + # and incorrectly mark the run as completed. A genuine + # "no evidence found" report needs an explicit, auditable + # outcome; provider-wide zero results are an operational + # failure until that outcome exists. + logger.error("Search returned no usable URLs from configured providers") + return { + "success": False, + "error": "Search returned no usable URLs from configured providers", + "data": {}, + "url_count": 0, + } + logger.info("Search complete: %d unique URLs collected", len(self._search_results)) metrics.increment(C_SEARCH_QUERIES_TOTAL, len(plan_queries)) return {"success": True, "data": {"urls": self._search_results}, "url_count": len(self._search_results)} diff --git a/src/nsct/providers/searxng.py b/src/nsct/providers/searxng.py index 422a6e6..9610a61 100644 --- a/src/nsct/providers/searxng.py +++ b/src/nsct/providers/searxng.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Any import httpx @@ -14,11 +15,17 @@ class SearXNGProvider(SearchProvider): _provider_name = "searxng" - def __init__(self, base_url: str, timeout_seconds: float = 15.0) -> None: + def __init__( + self, + base_url: str, + timeout_seconds: float = 15.0, + engines: Sequence[str] | None = None, + ) -> None: if not base_url.strip(): raise ValueError("SearXNG base URL must not be empty") self._base_url = base_url.rstrip("/") self._timeout_seconds = timeout_seconds + self._engines = tuple(engine.strip() for engine in (engines or ()) if engine.strip()) self._client: httpx.AsyncClient | None = None @property @@ -30,9 +37,13 @@ class SearXNGProvider(SearchProvider): async def search( self, query: str, language: str = "de", max_results: int = 10 ) -> list[NormalizedResult]: + params: dict[str, str] = {"q": query, "format": "json", "language": language} + if self._engines: + params["engines"] = ",".join(self._engines) + response = await self._http_client.get( f"{self._base_url}/search", - params={"q": query, "format": "json", "language": language}, + params=params, # The bundled SearXNG instance enables bot detection and rejects # requests without a client IP header. This is the loopback IP of # the API-to-SearXNG hop, not an end-user supplied value. diff --git a/tests/test_backend_pipeline_repairs.py b/tests/test_backend_pipeline_repairs.py index 47dcca6..3adc8cb 100644 --- a/tests/test_backend_pipeline_repairs.py +++ b/tests/test_backend_pipeline_repairs.py @@ -69,6 +69,27 @@ def test_searxng_provider_normalizes_json_results() -> None: asyncio.run(run()) +def test_searxng_provider_sends_configured_engine_allow_list() -> None: + async def run() -> None: + seen_request: httpx.Request | None = None + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal seen_request + seen_request = request + return httpx.Response(200, json={"results": []}) + + provider = SearXNGProvider("http://searxng:8080", engines=("bing", "yahoo")) + provider._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + try: + await provider.search("test") + assert seen_request is not None + assert seen_request.url.params["engines"] == "bing,yahoo" + finally: + await provider._client.aclose() + + asyncio.run(run()) + + def test_orchestrator_registers_searxng_provider() -> None: orchestrator = ResearchOrchestrator(_config(), uuid4(), "test") provider = orchestrator._get_multi_search().get_provider("searxng") @@ -104,6 +125,25 @@ def test_searching_accepts_normalized_result_with_empty_snippet() -> None: asyncio.run(run()) +def test_searching_with_no_usable_results_fails_instead_of_completing_empty() -> None: + """Provider-wide empty results must not become an empty completed report.""" + async def run() -> None: + orchestrator = ResearchOrchestrator(_config(), uuid4(), "test") + multi_search = Mock() + multi_search.enabled_providers.return_value = ["searxng"] + multi_search.search = AsyncMock(return_value=[]) + orchestrator._multi_search = multi_search + + response = await orchestrator._step_searching() + + assert response["success"] is False + assert response["url_count"] == 0 + assert "no usable URLs" in response["error"] + assert orchestrator._search_results == [] + + asyncio.run(run()) + + def test_fetching_uses_actual_bytes_and_respects_remaining_source_budget() -> None: async def run() -> None: budget = HardBudgetConfig(max_sources=2, max_total_download_bytes=1_000)