fix search runs with empty results

This commit is contained in:
faligam
2026-09-07 12:46:06 +02:00
parent f921e978a0
commit e483c4d13f
6 changed files with 111 additions and 4 deletions

View File

@@ -7,6 +7,30 @@
## Aktueller Stand — 2026-09-07 ## 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 ### Planungsinformationen in Research-Details umgesetzt — 2026-09-07
Issue [Frontend #1](https://git.frerkc.de/opencode/NSCT-FrontEnd/issues/1) Issue [Frontend #1](https://git.frerkc.de/opencode/NSCT-FrontEnd/issues/1)

View File

@@ -81,6 +81,9 @@ services:
NSCT_DB_URL: postgresql+asyncpg://${POSTGRES_USER:-nsct}:${POSTGRES_PASSWORD:-nsct_secret}@postgres:5432/${POSTGRES_DB:-nsct} 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_LLM_MAX_CONCURRENCY: "${NSCT_LLM_MAX_CONCURRENCY:-3}"
NSCT_SEARXNG_BASE_URL: http://searxng:8080/ 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@@ -62,6 +62,10 @@ class AppSettings(BaseModel):
postgres: DatabaseConfig = Field(default_factory=DatabaseConfig) postgres: DatabaseConfig = Field(default_factory=DatabaseConfig)
debug: bool = Field(default=False) debug: bool = Field(default=False)
searxng_base_url: str | None = Field(default=None, description="SearXNG instance URL.") 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 @staticmethod
def from_env() -> "AppSettings": def from_env() -> "AppSettings":
@@ -98,6 +102,11 @@ class AppSettings(BaseModel):
debug = os.environ.get("NSCT_DEBUG", "false").lower() == "true" debug = os.environ.get("NSCT_DEBUG", "false").lower() == "true"
searxng_url = os.environ.get("NSCT_SEARXNG_BASE_URL", None) 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( return AppSettings(
llm=llm_cfg, llm=llm_cfg,
@@ -106,6 +115,7 @@ class AppSettings(BaseModel):
postgres=postgres_cfg, postgres=postgres_cfg,
debug=debug, debug=debug,
searxng_base_url=searxng_url, searxng_base_url=searxng_url,
searxng_engines=searxng_engines,
) )
@property @property
@@ -113,4 +123,4 @@ class AppSettings(BaseModel):
"""Return a config directory path for persisting runtime data.""" """Return a config directory path for persisting runtime data."""
data_dir = Path.home() / ".nsct" data_dir = Path.home() / ".nsct"
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
return data_dir return data_dir

View File

@@ -307,7 +307,10 @@ class ResearchOrchestrator:
try: try:
from nsct.providers.searxng import SearXNGProvider from nsct.providers.searxng import SearXNGProvider
if self._config.searxng_base_url: 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) providers.append(provider)
except ImportError as exc: except ImportError as exc:
logger.error("SearXNG provider is unavailable: %s", exc) logger.error("SearXNG provider is unavailable: %s", exc)
@@ -641,6 +644,22 @@ class ResearchOrchestrator:
} }
self._search_results.append(result_dict) 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)) logger.info("Search complete: %d unique URLs collected", len(self._search_results))
metrics.increment(C_SEARCH_QUERIES_TOTAL, len(plan_queries)) metrics.increment(C_SEARCH_QUERIES_TOTAL, len(plan_queries))
return {"success": True, "data": {"urls": self._search_results}, "url_count": len(self._search_results)} return {"success": True, "data": {"urls": self._search_results}, "url_count": len(self._search_results)}

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from typing import Any from typing import Any
import httpx import httpx
@@ -14,11 +15,17 @@ class SearXNGProvider(SearchProvider):
_provider_name = "searxng" _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(): if not base_url.strip():
raise ValueError("SearXNG base URL must not be empty") raise ValueError("SearXNG base URL must not be empty")
self._base_url = base_url.rstrip("/") self._base_url = base_url.rstrip("/")
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
self._engines = tuple(engine.strip() for engine in (engines or ()) if engine.strip())
self._client: httpx.AsyncClient | None = None self._client: httpx.AsyncClient | None = None
@property @property
@@ -30,9 +37,13 @@ class SearXNGProvider(SearchProvider):
async def search( async def search(
self, query: str, language: str = "de", max_results: int = 10 self, query: str, language: str = "de", max_results: int = 10
) -> list[NormalizedResult]: ) -> 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( response = await self._http_client.get(
f"{self._base_url}/search", f"{self._base_url}/search",
params={"q": query, "format": "json", "language": language}, params=params,
# The bundled SearXNG instance enables bot detection and rejects # The bundled SearXNG instance enables bot detection and rejects
# requests without a client IP header. This is the loopback IP of # requests without a client IP header. This is the loopback IP of
# the API-to-SearXNG hop, not an end-user supplied value. # the API-to-SearXNG hop, not an end-user supplied value.

View File

@@ -69,6 +69,27 @@ def test_searxng_provider_normalizes_json_results() -> None:
asyncio.run(run()) 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: def test_orchestrator_registers_searxng_provider() -> None:
orchestrator = ResearchOrchestrator(_config(), uuid4(), "test") orchestrator = ResearchOrchestrator(_config(), uuid4(), "test")
provider = orchestrator._get_multi_search().get_provider("searxng") 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()) 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: def test_fetching_uses_actual_bytes_and_respects_remaining_source_budget() -> None:
async def run() -> None: async def run() -> None:
budget = HardBudgetConfig(max_sources=2, max_total_download_bytes=1_000) budget = HardBudgetConfig(max_sources=2, max_total_download_bytes=1_000)