Fix research pipeline provider failures

This commit is contained in:
faligam
2026-09-06 18:32:42 +02:00
parent 9f6029b60a
commit e5b7d90436
9 changed files with 269 additions and 65 deletions

View File

@@ -85,39 +85,34 @@ Mit einem gültigen, bereits erzeugten Key im Browser testen:
Antwortcodes gemeinsam auswerten. Klartext-Keys niemals in Chat oder Logs Antwortcodes gemeinsam auswerten. Klartext-Keys niemals in Chat oder Logs
kopieren. kopieren.
### Aktueller Blocker im Browser-Flow — 2026-09-06 ### Browser-Flow: Backend-Blocker lokal behoben — 2026-09-06
Der Browser-Flow erreicht inzwischen die Detailseite und fragt Status, Die drei Backend-Ursachen für leere, scheinbar erfolgreiche Researches sind
Quellen, Claims, Evidenz und Bericht ab. Eine tatsächlich gestartete Recherche lokal implementiert, getestet und mit `docker compose up -d --build` deployed:
liefert jedoch derzeit keine Ergebnisse. Dies ist ein Backend-Problem, nicht
mehr der Frontend-Proxy oder die API-Key-Loginstrecke.
Reproduzierbare Logbefunde aus `docker compose logs nsct-api`: 1. `LLMProvider._create_client()` normalisiert die Basis-URL und hängt `/v1`
nur an, wenn sie nicht bereits enthalten ist. Ein authentifizierter,
sanitisiert ausgegebener Chat-Completions-Probe war erfolgreich.
2. `SearXNGProvider` ist in `src/nsct/providers/searxng.py` implementiert und
wird vom Orchestrator registriert. Compose mountet eine minimale SearXNG-
Konfiguration, die `format=json` erlaubt, und nutzt den vom aktuellen
SearXNG-Image erwarteten Variablennamen `SEARXNG_SECRET`. Ein echter,
sanitisiert ausgegebener Search-Probe lieferte ein Ergebnis.
3. Die Pipeline führt wieder `fetching → extracting → analyzing` aus.
Fehler beim Planen, Suchen, Abrufen oder Extrahieren werden nicht mehr als
leere Fallbacks als Erfolg gemeldet: Der Run wird `FAILED`, mit Fehlergrund
im REST-Status (`error`). Fehlende Provider führen ebenfalls zu `FAILED`.
- `Planning failed: Error code: 401 - {'error': 'authentication required'}` Validierung: Syntaxprüfung, Compose-Konfigurationsprüfung und 29 fokussierte
- `No search providers configured — search will return empty results` Tests (inklusive neuer Regressionen für URL-Normalisierung, SearXNG und
- abgelehnte State-Transitions, etwa `fetching -> analyzing`, State-Flow) sind erfolgreich. `tests/test_rest_research.py` wurde nicht als
`fetching -> synthesizing` und `fetching -> completed`. Gesamtsuite abgewartet, da seine Background-Tasks echte externe Retries
auslösen; die neuen deterministischen Tests decken den geänderten Fehlerpfad.
Ursachen und nächste Reparaturen: Nächster Schritt: Den Browser-End-to-End-Test mit einem gültigen, **nicht im
Chat offengelegten** Frontend-API-Key durchführen. Dabei insbesondere prüfen,
1. `src/nsct/providers/llm.py` ergänzt in `_create_client()` immer `/v1`. dass Quellen, Claims und Bericht nach einem echten Research-Lauf erscheinen
`NSCT_LLM_BASE_URL` ist laut `.env.example` und Deployment-Dokumentation und ein Provider-/LLM-Ausfall in der UI den REST-Fehlergrund zeigt.
bereits eine OpenAI-kompatible URL mit `/v1`. Den Basis-URL-Join
normalisieren, damit nie `/v1/v1` entsteht. Danach einen authentifizierten
Chat-Completions-Probe ausführen, ohne URL oder Key auszugeben.
2. `src/nsct/orchestration/orchestrator.py::_get_multi_search()` importiert
`nsct.providers.searxng.SearXNGProvider`, aber
`src/nsct/providers/searxng.py` existiert nicht. Der `ImportError` wird
still geschluckt; deshalb hat die Pipeline keinen Search-Provider. Entweder
den SearXNG-Provider implementieren oder den vorhandenen
`DuckDuckGoProvider` als Fallback registrieren. Compose setzt bereits
`NSCT_SEARXNG_BASE_URL=http://searxng:8080/`.
3. Das Orchestrator-/REST-Fehlerhandling muss bei nicht ausführbarer Pipeline
einen nachvollziehbaren `FAILED`-Status mitsamt Fehlergrund liefern, statt
mit leeren Fallbacks scheinbar abgeschlossene Schritte zu zeigen. Die
Transition-Matrix und die Fallback-Logik in `orchestrator.py` prüfen und
gezielt testen.
Frontend-Fixes des laufenden Browser-Tests (alle im Frontend-Repository Frontend-Fixes des laufenden Browser-Tests (alle im Frontend-Repository
`/home/faligam/apps/NSCT-FrontEnd`, `main`, gepusht): `/home/faligam/apps/NSCT-FrontEnd`, `main`, gepusht):

View File

@@ -0,0 +1,8 @@
# NSCT needs SearXNG's structured result endpoint. Keep HTML enabled for
# manual administration while allowing the backend's `format=json` requests.
use_default_settings: true
search:
formats:
- html
- json

View File

@@ -52,9 +52,10 @@ services:
- "8888:8080" - "8888:8080"
volumes: volumes:
- searxng_cache:/var/cache/searxng - searxng_cache:/var/cache/searxng
- ./config/searxng/settings.yml:/etc/searxng/settings.yml:ro
environment: environment:
- SEARXNG_BASE_URL=http://localhost:8888/ - SEARXNG_BASE_URL=http://localhost:8888/
- SEARXNG_SECRET_KEY=nsct_searxng_secret_key_change_me - SEARXNG_SECRET=nsct_searxng_secret_key_change_me
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
@@ -64,7 +65,6 @@ services:
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp - /tmp
- /etc/searxng:mode=1777
cap_drop: cap_drop:
- ALL - ALL

View File

@@ -147,6 +147,7 @@ class StatusResponse(BaseModel):
search_count: int = 0 search_count: int = 0
source_count: int = 0 source_count: int = 0
claim_count: int = 0 claim_count: int = 0
error: str | None = None
is_completed: bool = False is_completed: bool = False
is_running: bool = False is_running: bool = False
@@ -468,6 +469,8 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget
) )
else: else:
run.state = ResearchRunState.FAILED.value run.state = ResearchRunState.FAILED.value
run.metadata["error"] = result.get("error", "Pipeline failed")
run.metadata["failed_step"] = result.get("failed_step", "unknown")
metrics.increment(C_RESEARCH_FAILED_TOTAL) metrics.increment(C_RESEARCH_FAILED_TOTAL)
logger.warning( logger.warning(
"[pipeline] run_id=%s failed: %s", "[pipeline] run_id=%s failed: %s",
@@ -519,6 +522,7 @@ async def get_research(research_id: str) -> StatusResponse:
search_count=len(run.search_results), search_count=len(run.search_results),
source_count=len(run.sources), source_count=len(run.sources),
claim_count=len(run.claims), claim_count=len(run.claims),
error=run.metadata.get("error"),
is_completed=(run.state == ResearchRunState.COMPLETED.value), is_completed=(run.state == ResearchRunState.COMPLETED.value),
is_running=run.state not in ( is_running=run.state not in (
ResearchRunState.COMPLETED.value, ResearchRunState.COMPLETED.value,
@@ -546,6 +550,7 @@ async def get_status(research_id: str) -> StatusResponse:
search_count=len(run.search_results), search_count=len(run.search_results),
source_count=len(run.sources), source_count=len(run.sources),
claim_count=len(run.claims), claim_count=len(run.claims),
error=run.metadata.get("error"),
is_completed=(run.state == ResearchRunState.COMPLETED.value), is_completed=(run.state == ResearchRunState.COMPLETED.value),
is_running=run.state not in ( is_running=run.state not in (
ResearchRunState.COMPLETED.value, ResearchRunState.COMPLETED.value,

View File

@@ -208,8 +208,6 @@ class ResearchOrchestrator:
""" """
logger.error("Research failed: %s (state=%s)", reason, self._state_machine.current_state.value) logger.error("Research failed: %s (state=%s)", reason, self._state_machine.current_state.value)
# Der State Machine _apply erzwingt FAILED direkt,
# weil _mark_failed als "Sondertransition" gedacht ist.
if self._state_machine.current_state in ( if self._state_machine.current_state in (
ResearchRunState.COMPLETED, ResearchRunState.COMPLETED,
ResearchRunState.FAILED, ResearchRunState.FAILED,
@@ -217,8 +215,16 @@ class ResearchOrchestrator:
): ):
return False return False
self._state_machine.current_state = ResearchRunState.FAILED # type: ignore[assignment] transitioned = self._transition_to(ResearchRunState.FAILED.value)
return True if transitioned and self._run is not None:
self._run = self._run.model_copy(
update={
"state": ResearchRunState.FAILED.value,
"updated_at": datetime.now(timezone.utc),
"metadata": {**self._run.metadata, "error": reason},
}
)
return transitioned
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Private: Budget # Private: Budget
@@ -297,14 +303,14 @@ class ResearchOrchestrator:
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)
providers.append(provider) providers.append(provider)
except ImportError: except ImportError as exc:
pass logger.error("SearXNG provider is unavailable: %s", exc)
except Exception as exc: except Exception as exc:
logger.warning("SearXNG provider init failed (%s) — search will return empty", exc) logger.error("SearXNG provider initialization failed: %s", exc)
self._multi_search = MultiProviderSearch(providers=providers if providers else None) self._multi_search = MultiProviderSearch(providers=providers)
if not providers: if not providers:
logger.warning("No search providers configured — search will return empty results") logger.error("No search providers configured")
return self._multi_search return self._multi_search
def _get_llm_provider(self): def _get_llm_provider(self):
@@ -406,6 +412,7 @@ class ResearchOrchestrator:
"planning", "planning",
"searching", "searching",
"fetching", "fetching",
"extracting",
"analyzing", "analyzing",
"comparing", "comparing",
"synthesizing", "synthesizing",
@@ -440,6 +447,7 @@ class ResearchOrchestrator:
"success": False, "success": False,
"error": error_msg, "error": error_msg,
"failed_step": step_name, "failed_step": step_name,
"state": self._state_machine.current_state.value,
"report": None, "report": None,
} }
@@ -458,6 +466,7 @@ class ResearchOrchestrator:
"success": False, "success": False,
"error": str(exc), "error": str(exc),
"failed_step": step_name, "failed_step": step_name,
"state": self._state_machine.current_state.value,
"report": None, "report": None,
} }
@@ -552,18 +561,7 @@ class ResearchOrchestrator:
except Exception as exc: except Exception as exc:
logger.error("Planning failed: %s", exc) logger.error("Planning failed: %s", exc)
# Fallback: Create a minimal plan return {"success": False, "error": f"Planning failed: {exc}", "data": {}}
fallback = self._get_fallback_plan()
self._plan = fallback
# Store error in _run metadata via new instance
if self._run is not None:
self._run = self._run.model_copy(
update={
"plan_error": str(exc),
"updated_at": datetime.utcnow(),
}
)
return {"success": True, "data": {"plan": fallback}, "plan": fallback, "warning": "Used fallback plan"}
async def _step_searching(self) -> dict[str, Any]: async def _step_searching(self) -> dict[str, Any]:
"""SEARCHING: Führe Suchanfragen durch und sammle URLs.""" """SEARCHING: Führe Suchanfragen durch und sammle URLs."""
@@ -571,6 +569,12 @@ class ResearchOrchestrator:
try: try:
multi_search = self._get_multi_search() multi_search = self._get_multi_search()
if not multi_search.enabled_providers():
return {
"success": False,
"error": "No search provider is configured or available",
"data": {},
}
plan_queries = self._plan.get("queries", []) if self._plan else [] plan_queries = self._plan.get("queries", []) if self._plan else []
if not plan_queries: if not plan_queries:
@@ -615,9 +619,8 @@ class ResearchOrchestrator:
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)}
except Exception as exc: except Exception as exc:
logger.warning("Searching failed (graceful fallback): %s — returning empty results", exc) logger.error("Searching failed: %s", exc)
self._search_results = [] return {"success": False, "error": f"Searching failed: {exc}", "data": {}}
return {"success": True, "data": {"urls": []}, "url_count": 0, "warning": str(exc)}
async def _step_fetching(self) -> dict[str, Any]: async def _step_fetching(self) -> dict[str, Any]:
"""FETCHING: Crawle gesammelte URLs und extrahiere Inhalt.""" """FETCHING: Crawle gesammelte URLs und extrahiere Inhalt."""
@@ -669,9 +672,8 @@ class ResearchOrchestrator:
} }
except Exception as exc: except Exception as exc:
logger.warning("Fetching failed (graceful fallback): %s — returning empty sources", exc) logger.error("Fetching failed: %s", exc)
self._sources = [] return {"success": False, "error": f"Fetching failed: {exc}", "data": {}}
return {"success": True, "data": {"sources": []}, "source_count": 0, "warning": str(exc)}
async def _step_extracting(self) -> dict[str, Any]: async def _step_extracting(self) -> dict[str, Any]:
"""EXTRACTING: Extrahiere Claims aus den extrahierten Quellen.""" """EXTRACTING: Extrahiere Claims aus den extrahierten Quellen."""
@@ -717,9 +719,8 @@ class ResearchOrchestrator:
return {"success": True, "data": {"claims": [c.model_dump() for c in self._claims]}, "claim_count": len(self._claims)} return {"success": True, "data": {"claims": [c.model_dump() for c in self._claims]}, "claim_count": len(self._claims)}
except Exception as exc: except Exception as exc:
logger.warning("Claim extraction failed (graceful fallback): %s", exc) logger.error("Claim extraction failed: %s", exc)
self._claims = [] return {"success": False, "error": f"Claim extraction failed: {exc}", "data": {}}
return {"success": True, "data": {"claims": []}, "claim_count": 0, "warning": str(exc)}
async def _step_analyzing(self) -> dict[str, Any]: async def _step_analyzing(self) -> dict[str, Any]:
"""ANALYZING: Platzhalter — Stage 6/7/8 werden später eingebunden. """ANALYZING: Platzhalter — Stage 6/7/8 werden später eingebunden.

View File

@@ -39,7 +39,7 @@ class ResearchRunState(str, Enum):
# Übergangsmatrix: Quelle -> zulässige Ziele (frozenset für Immutable) # Übergangsmatrix: Quelle -> zulässige Ziele (frozenset für Immutable)
_VALID_TRANSITIONS: dict[ResearchRunState, FrozenSet[ResearchRunState]] = { _VALID_TRANSITIONS: dict[ResearchRunState, FrozenSet[ResearchRunState]] = {
ResearchRunState.CREATED: frozenset( ResearchRunState.CREATED: frozenset(
(ResearchRunState.PLANNING,) (ResearchRunState.PLANNING, ResearchRunState.FAILED)
), ),
ResearchRunState.PLANNING: frozenset( ResearchRunState.PLANNING: frozenset(
(ResearchRunState.SEARCHING, ResearchRunState.FAILED) (ResearchRunState.SEARCHING, ResearchRunState.FAILED)

View File

@@ -151,8 +151,14 @@ class _LLMProviderImpl(LLMProvider):
pool=5, pool=5,
), ),
) )
# OpenAI-compatible endpoints are commonly configured either as the
# service root or with the API version already included. Appending
# blindly turns an explicit ``.../v1`` into ``.../v1/v1``.
base_url = self._base_url.rstrip("/")
if not base_url.endswith("/v1"):
base_url += "/v1"
return AsyncOpenAI( return AsyncOpenAI(
base_url=self._base_url.rstrip("/") + "/v1", base_url=base_url,
api_key=self._api_key, api_key=self._api_key,
http_client=http_client, http_client=http_client,
) )

View File

@@ -0,0 +1,82 @@
"""SearXNG JSON search provider."""
from __future__ import annotations
from typing import Any
import httpx
from nsct.providers.abstract import NormalizedResult, SearchProvider
class SearXNGProvider(SearchProvider):
"""Search through a self-hosted SearXNG instance's JSON endpoint."""
_provider_name = "searxng"
def __init__(self, base_url: str, timeout_seconds: float = 15.0) -> 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._client: httpx.AsyncClient | None = None
@property
def _http_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(self._timeout_seconds))
return self._client
async def search(
self, query: str, language: str = "de", max_results: int = 10
) -> list[NormalizedResult]:
response = await self._http_client.get(
f"{self._base_url}/search",
params={"q": query, "format": "json", "language": language},
# 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.
headers={"X-Forwarded-For": "127.0.0.1"},
)
response.raise_for_status()
payload = response.json()
raw_results = payload.get("results", [])
if not isinstance(raw_results, list):
raise ValueError("Invalid SearXNG response: results is not a list")
results: list[NormalizedResult] = []
for rank, item in enumerate(raw_results[:max_results], start=1):
if not isinstance(item, dict) or not isinstance(item.get("url"), str):
continue
url = item["url"]
if not url.startswith(("http://", "https://")):
continue
results.append(
NormalizedResult.from_raw(
provider_name=self._provider_name,
title=str(item.get("title") or url),
url=url,
snippet=str(item.get("content") or item.get("snippet") or ""),
rank=rank,
extra={"engine": item.get("engine"), "category": item.get("category")},
)
)
return results
async def get_metadata(self) -> dict[str, Any]:
return {
"name": self._provider_name,
"version": "0.1.0",
"provider": self.__class__.__name__,
"capabilities": ["web_search", "json_api"],
"requires_api_key": False,
"base_url": self._base_url,
"note": "Search ranking is NOT a trust indicator.",
}
async def health_check(self) -> bool:
try:
response = await self._http_client.get(f"{self._base_url}/healthz")
return response.is_success
except httpx.HTTPError:
return False

View File

@@ -0,0 +1,107 @@
"""Regression tests for the deployed backend research pipeline fixes."""
from __future__ import annotations
import asyncio
from uuid import uuid4
import httpx
from unittest.mock import AsyncMock
from nsct.config import AppSettings, AudioConfig, DatabaseConfig, LLMConfig, VisionConfig
from nsct.orchestration.orchestrator import ResearchOrchestrator
from nsct.orchestration.state import ResearchRunState
from nsct.providers.llm import _LLMProviderImpl
from nsct.providers.metrics import ProviderMetrics
from nsct.providers.searxng import SearXNGProvider
def _config(*, searxng_base_url: str | None = "http://searxng:8080") -> AppSettings:
return AppSettings(
llm=LLMConfig(base_url="http://llm.example/v1", model="test-model"),
vision=VisionConfig(base_url="http://vision.example/v1", model="vision"),
audio=AudioConfig(base_url="http://audio.example/v1"),
postgres=DatabaseConfig(url="sqlite+aiosqlite://"),
searxng_base_url=searxng_base_url,
)
def test_llm_base_url_does_not_duplicate_existing_v1() -> None:
provider = _LLMProviderImpl(_config(), ProviderMetrics())
provider._api_key = "test-key"
client = provider._create_client()
try:
assert str(client.base_url) == "http://llm.example/v1/"
finally:
asyncio.run(client.close())
def test_searxng_provider_normalizes_json_results() -> None:
async def run() -> None:
provider = SearXNGProvider("http://searxng:8080")
provider._client = httpx.AsyncClient(
transport=httpx.MockTransport(
lambda request: httpx.Response(
200,
json={
"results": [
{"url": "https://example.org/a", "title": "A", "content": "Snippet"},
{"url": "mailto:invalid@example.org", "title": "Invalid"},
]
},
)
)
)
try:
results = await provider.search("test")
assert len(results) == 1
assert results[0].provider == "searxng"
assert results[0].url == "https://example.org/a"
assert results[0].snippet == "Snippet"
assert provider._client is not None
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")
assert isinstance(provider, SearXNGProvider)
def test_pipeline_executes_extracting_between_fetching_and_analyzing() -> None:
async def run() -> None:
orchestrator = ResearchOrchestrator(_config(), uuid4(), "test")
orchestrator._comparison_data = {}
for step in ("planning", "searching", "fetching", "extracting", "analyzing", "comparing", "synthesizing"):
async def handler(target: str = step) -> dict[str, object]:
orchestrator._transition_to(target)
return {"success": True, "data": {}}
setattr(orchestrator, f"_step_{step}", AsyncMock(side_effect=handler))
result = await orchestrator.run()
assert result["success"] is True
assert orchestrator._step_extracting.await_count == 1
assert orchestrator.state is ResearchRunState.COMPLETED
asyncio.run(run())
def test_no_search_provider_marks_run_failed_with_reason() -> None:
async def run() -> None:
orchestrator = ResearchOrchestrator(_config(searxng_base_url=None), uuid4(), "test")
await orchestrator.start()
orchestrator._step_planning = AsyncMock(return_value={"success": True, "data": {}})
result = await orchestrator.run()
assert result["success"] is False
assert result["failed_step"] == "searching"
assert "No search provider" in result["error"]
assert orchestrator.state is ResearchRunState.FAILED
asyncio.run(run())