Fix research pipeline runtime failures

This commit is contained in:
faligam
2026-09-07 11:16:36 +02:00
parent cf10cd9636
commit a5324d3971
9 changed files with 184 additions and 24 deletions

2
API.md
View File

@@ -148,7 +148,7 @@ Die Antwort kommt sofort — das eigentliche Ergebnis ist über
| Tiefe | max_search_queries | max_sources | max_llm_requests | max_duration | max_context | | Tiefe | max_search_queries | max_sources | max_llm_requests | max_duration | max_context |
|-------|--------------------|-------------|-------------------|--------------|-------------| |-------|--------------------|-------------|-------------------|--------------|-------------|
| `quick` | 10 | 15 | 15 | 120s | 16k | | `quick` | 10 | 15 | 15 | 300s | 16k |
| `normal` | 20 | 30 | 30 | 300s | 24k | | `normal` | 20 | 30 | 30 | 300s | 24k |
| `deep` | 40 | 60 | 60 | 600s | 32k | | `deep` | 40 | 60 | 60 | 600s | 32k |

View File

@@ -5,7 +5,41 @@
--- ---
## Aktueller Stand — 2026-09-06 ## Aktueller Stand — 2026-09-07
### Browser-E2E: Such- und Crawler-Pipeline repariert — 2026-09-07
Während des Browser-E2E-Tests traten drei aufeinanderfolgende Backend-Fehler
auf. Alle sind lokal repariert, durch gezielte Tests abgedeckt und mit
`docker compose up -d --build nsct-api` deployed:
1. SearXNG liefert `NormalizedResult`-Pydantic-Objekte. Der Orchestrator
behandelte optionale leere Felder fälschlich als Dictionary und rief `.get()`
auf. Die Ergebnisnormalisierung unterscheidet nun sauber Modelle, Mappings
und ungültige Werte.
2. `AsyncFetcher` konfigurierte bei `httpx.Timeout` connect/read/write, aber
keinen `pool`-Timeout. Der Crawler konnte deshalb nicht initialisiert
werden. `pool` ist nun konfigurierbar über `NSCT_CRAWLER_POOL_TIMEOUT`
(Default 10 Sekunden).
3. Die Budgetierung buchte für jede Ergebnis-URL vor dem Abruf pauschal 500 KB.
Ein Quick-Run mit 22 URLs überschritt so fälschlich das 500-KB-Budget, obwohl
die tatsächlichen Downloads deutlich kleiner waren. Abrufe werden jetzt auf
verbleibende Quellen- und Bytebudgets begrenzt; die Buchung erfolgt nach
tatsächlich abgerufenen Response-Bytes. Budgetobergrenzen sind inklusiv.
Das Quick-Zeitlimit beträgt nun 300 Sekunden (API-Dokumentation angepasst).
Validierung: 63 gezielte Tests (`test_backend_pipeline_repairs.py`,
`test_crawler.py`, `test_search.py`) erfolgreich; Docker-Container und
`/health` erfolgreich. Die komplette REST-Suite startet echte externe
Background-Retries und wurde deshalb nicht abgewartet. Pytest ist lokal in der
ignorierten `.nsct-test-venv` installiert.
Wichtig für den nächsten Browser-Test: Research-Runs werden derzeit nur in
`_research_store` im Arbeitsspeicher gehalten. Jeder API-Neubau/-neustart
entfernt bisherige Run-IDs; die alten Runs liefern danach erwartungsgemäß 404.
Persistente Research-Runs sind ein offener Production-Readiness-Punkt. Vor dem
erneuten Test zuerst eine neue Recherche starten, nicht eine alte Detail-URL
wiederverwenden.
### Laufende Deployments ### Laufende Deployments

View File

@@ -66,7 +66,7 @@ DEPTH_CONFIGS: dict[str, DepthConfig] = {
max_pages_per_domain=2, max_pages_per_domain=2,
max_total_download_bytes=500_000, max_total_download_bytes=500_000,
max_llm_requests=15, max_llm_requests=15,
max_research_duration_seconds=120, max_research_duration_seconds=300,
max_context_per_llm_call=16_000, max_context_per_llm_call=16_000,
), ),
"normal": DepthConfig( "normal": DepthConfig(

View File

@@ -98,6 +98,7 @@ class AsyncFetcher:
connect=float(os.environ.get("NSCT_CRAWLER_CONNECT_TIMEOUT", "10")), connect=float(os.environ.get("NSCT_CRAWLER_CONNECT_TIMEOUT", "10")),
read=float(os.environ.get("NSCT_CRAWLER_READ_TIMEOUT", "30")), read=float(os.environ.get("NSCT_CRAWLER_READ_TIMEOUT", "30")),
write=float(os.environ.get("NSCT_CRAWLER_WRITE_TIMEOUT", "10")), write=float(os.environ.get("NSCT_CRAWLER_WRITE_TIMEOUT", "10")),
pool=float(os.environ.get("NSCT_CRAWLER_POOL_TIMEOUT", "10")),
) )
self._client = httpx.AsyncClient( self._client = httpx.AsyncClient(
timeout=timeout_config, timeout=timeout_config,

View File

@@ -36,7 +36,7 @@ class CrawlerManager:
# Public API # Public API
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def fetch_and_extract(self, url: str) -> NormalizedDocument: async def fetch_and_extract(self, url: str, max_size: int = 0) -> NormalizedDocument:
"""Fetch a single URL and extract main content. """Fetch a single URL and extract main content.
Pipeline: Pipeline:
@@ -73,7 +73,7 @@ class CrawlerManager:
logger.warning("DNS check warning for %s: %s", url, exc) logger.warning("DNS check warning for %s: %s", url, exc)
# 3. HTTP fetch # 3. HTTP fetch
result = await self.fetcher.fetch(url) result = await self.fetcher.fetch(url, max_size=max_size)
if result.status in (FetchStatus.BLOCKED, FetchStatus.ERROR, FetchStatus.TIMEOUT, if result.status in (FetchStatus.BLOCKED, FetchStatus.ERROR, FetchStatus.TIMEOUT,
FetchStatus.SIZE_LIMIT_EXCEEDED, FetchStatus.CONTENT_TYPE_BLOCKED): FetchStatus.SIZE_LIMIT_EXCEEDED, FetchStatus.CONTENT_TYPE_BLOCKED):
@@ -86,7 +86,7 @@ class CrawlerManager:
# 4. Content-based extraction # 4. Content-based extraction
text = "" text = ""
extraction_tool = "" extraction_tool = ""
metadata: dict[str, Any] = {} metadata: dict[str, Any] = {"download_bytes": len(content)}
links: list[str] = [] links: list[str] = []
title = "" title = ""
@@ -131,7 +131,7 @@ class CrawlerManager:
return doc return doc
async def fetch_and_extract_many(self, urls: list[str]) -> list[NormalizedDocument]: async def fetch_and_extract_many(self, urls: list[str], max_size: int = 0) -> list[NormalizedDocument]:
"""Fetch and extract multiple URLs in parallel. """Fetch and extract multiple URLs in parallel.
Each URL is processed independently — failures do not block others. Each URL is processed independently — failures do not block others.
@@ -143,7 +143,7 @@ class CrawlerManager:
List of NormalizedDocument, one per input URL (in order). List of NormalizedDocument, one per input URL (in order).
Failed URLs produce error documents with error info in metadata. Failed URLs produce error documents with error info in metadata.
""" """
tasks = [self.fetch_and_extract(url) for url in urls] tasks = [self.fetch_and_extract(url, max_size=max_size) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
final_docs: list[NormalizedDocument] = [] final_docs: list[NormalizedDocument] = []

View File

@@ -160,7 +160,10 @@ class BudgetTracker:
counter_name = field.replace("max_", "", 1) counter_name = field.replace("max_", "", 1)
limit = getattr(self._config, field) limit = getattr(self._config, field)
usage = self._counters.get(counter_name, 0) usage = self._counters.get(counter_name, 0)
if limit > 0 and usage >= limit: # Limits are inclusive: exactly max_sources or exactly the byte
# budget is permitted; only use beyond the configured maximum
# exhausts the budget.
if limit > 0 and usage > limit:
exceeded.append(field) exceeded.append(field)
logger.warning( logger.warning(
"Budget limit exceeded: %s (%.2f / %s)", "Budget limit exceeded: %s (%.2f / %s)",

View File

@@ -11,6 +11,7 @@ import asyncio
import json import json
import logging import logging
import time import time
from collections.abc import Mapping
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -603,14 +604,30 @@ class ResearchOrchestrator:
seen_urls = set() seen_urls = set()
self._search_results = [] self._search_results = []
for r in all_results: for r in all_results:
url = r.url if hasattr(r, "url") else r.get("url", "") # SearchProvider returns NormalizedResult instances. Keep the
# mapping branch for test doubles and legacy providers, but do
# not call ``.get()`` on a Pydantic model when an optional
# field such as ``snippet`` is empty.
if hasattr(r, "url"):
url = r.url
title = r.title
snippet = r.snippet
provider = r.provider
elif isinstance(r, Mapping):
url = r.get("url", "")
title = r.get("title", "")
snippet = r.get("snippet", "")
provider = r.get("provider", "")
else:
logger.warning("Ignoring unsupported search result type: %s", type(r).__name__)
continue
if url and url not in seen_urls: if url and url not in seen_urls:
seen_urls.add(url) seen_urls.add(url)
result_dict = { result_dict = {
"url": url, "url": url,
"title": getattr(r, "title", "") or r.get("title", ""), "title": title,
"snippet": getattr(r, "snippet", "") or r.get("snippet", ""), "snippet": snippet,
"provider": getattr(r, "provider", "") or r.get("provider", ""), "provider": provider,
} }
self._search_results.append(result_dict) self._search_results.append(result_dict)
@@ -635,11 +652,31 @@ class ResearchOrchestrator:
crawler = self._get_crawler() crawler = self._get_crawler()
urls = [r["url"] for r in self._search_results] urls = [r["url"] for r in self._search_results]
# Budget: Abschätzung der Download-Größe # Restrict the batch before fetching. Charging each URL a
self._budget_tracker.increment_download_bytes(len(urls) * 500_000) # ~500KB pro Seite # fictitious 500 KB exhausted a quick run before any bytes had
# actually been downloaded.
usage = self._budget_tracker.get_usage()
remaining_sources = self._budget_config.max_sources - int(
usage["max_sources"]["usage"]
)
urls = urls[:max(0, remaining_sources)]
if not urls:
return {
"success": False,
"error": "Source budget exhausted before fetching",
"data": {},
}
remaining_download_bytes = self._budget_config.max_total_download_bytes - int(
usage["max_total_download_bytes"]["usage"]
)
max_size_per_url = 0
if self._budget_config.max_total_download_bytes > 0:
max_size_per_url = max(1, remaining_download_bytes // len(urls))
self._budget_tracker.increment_sources(len(urls)) self._budget_tracker.increment_sources(len(urls))
docs = await crawler.fetch_and_extract_many(urls) docs = await crawler.fetch_and_extract_many(urls, max_size=max_size_per_url)
self._budget_tracker.record_time_elapsed() self._budget_tracker.record_time_elapsed()
# In sources-Dicts umwandeln # In sources-Dicts umwandeln
@@ -657,9 +694,9 @@ class ResearchOrchestrator:
} }
self._sources.append(src) self._sources.append(src)
# Track bytes # Account for response bytes, not only extracted text.
content_len = len(doc.text or "") downloaded_bytes = int(doc.metadata.get("download_bytes", len(doc.text.encode("utf-8"))))
self._budget_tracker.increment_download_bytes(content_len) self._budget_tracker.increment_download_bytes(downloaded_bytes)
successful = [s for s in self._sources if not s.get("error")] successful = [s for s in self._sources if not s.get("error")]
metrics.increment(C_SOURCES_FETCHED_TOTAL, len(self._sources)) metrics.increment(C_SOURCES_FETCHED_TOTAL, len(self._sources))

View File

@@ -6,14 +6,17 @@ import asyncio
from uuid import uuid4 from uuid import uuid4
import httpx import httpx
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, Mock
from nsct.config import AppSettings, AudioConfig, DatabaseConfig, LLMConfig, VisionConfig from nsct.config import AppSettings, AudioConfig, DatabaseConfig, LLMConfig, VisionConfig
from nsct.orchestration.orchestrator import ResearchOrchestrator from nsct.orchestration.orchestrator import ResearchOrchestrator
from nsct.orchestration.budget import BudgetExhaustedError, BudgetTracker, HardBudgetConfig
from nsct.orchestration.state import ResearchRunState from nsct.orchestration.state import ResearchRunState
from nsct.providers.abstract import NormalizedResult
from nsct.providers.llm import _LLMProviderImpl from nsct.providers.llm import _LLMProviderImpl
from nsct.providers.metrics import ProviderMetrics from nsct.providers.metrics import ProviderMetrics
from nsct.providers.searxng import SearXNGProvider from nsct.providers.searxng import SearXNGProvider
from nsct.crawler.normalize import NormalizedDocument
def _config(*, searxng_base_url: str | None = "http://searxng:8080") -> AppSettings: def _config(*, searxng_base_url: str | None = "http://searxng:8080") -> AppSettings:
@@ -71,6 +74,81 @@ def test_orchestrator_registers_searxng_provider() -> None:
assert isinstance(provider, SearXNGProvider) assert isinstance(provider, SearXNGProvider)
def test_searching_accepts_normalized_result_with_empty_snippet() -> None:
"""A Pydantic result must not be treated as a mapping during conversion."""
async def run() -> None:
orchestrator = ResearchOrchestrator(_config(), uuid4(), "test")
result = NormalizedResult.from_raw(
provider_name="searxng",
title="Example",
url="https://example.org/article",
snippet="",
)
multi_search = Mock()
multi_search.enabled_providers.return_value = ["searxng"]
multi_search.search = AsyncMock(return_value=[result])
orchestrator._multi_search = multi_search
response = await orchestrator._step_searching()
assert response["success"] is True
assert response["url_count"] == 1
assert orchestrator._search_results == [{
"url": "https://example.org/article",
"title": "Example",
"snippet": "",
"provider": "searxng",
}]
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)
orchestrator = ResearchOrchestrator(_config(), uuid4(), "test", budget_config=budget)
orchestrator._search_results = [
{"url": "https://example.org/one"},
{"url": "https://example.org/two"},
{"url": "https://example.org/three"},
]
doc_one = NormalizedDocument.from_text(
"https://example.org/one", "one", metadata={"download_bytes": 120}
)
doc_two = NormalizedDocument.from_text(
"https://example.org/two", "two", metadata={"download_bytes": 180}
)
crawler = Mock()
crawler.fetch_and_extract_many = AsyncMock(return_value=[doc_one, doc_two])
orchestrator._crawler = crawler
response = await orchestrator._step_fetching()
assert response["success"] is True
crawler.fetch_and_extract_many.assert_awaited_once_with(
["https://example.org/one", "https://example.org/two"], max_size=500
)
usage = orchestrator.budget_tracker.get_usage()
assert usage["max_sources"]["usage"] == 2
assert usage["max_total_download_bytes"]["usage"] == 300
asyncio.run(run())
def test_hard_budget_allows_exactly_the_configured_limit() -> None:
tracker = BudgetTracker(HardBudgetConfig(max_sources=2))
tracker.increment_sources(2)
tracker.check_budget()
tracker.increment_sources()
try:
tracker.check_budget()
except BudgetExhaustedError as exc:
assert exc.exhausted_limits == ["max_sources"]
else:
raise AssertionError("Source usage above the limit must exhaust the budget")
def test_pipeline_executes_extracting_between_fetching_and_analyzing() -> None: def test_pipeline_executes_extracting_between_fetching_and_analyzing() -> None:
async def run() -> None: async def run() -> None:
orchestrator = ResearchOrchestrator(_config(), uuid4(), "test") orchestrator = ResearchOrchestrator(_config(), uuid4(), "test")

View File

@@ -176,6 +176,13 @@ def test_url_dedup_no_duplicate() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def test_fetcher_configures_all_httpx_timeout_phases() -> None:
"""httpx requires a pool timeout when the other phases are explicit."""
fetcher = AsyncFetcher()
assert fetcher._client.timeout.pool == 10.0
await fetcher.close()
async def test_fetcher_blocked_url() -> None: async def test_fetcher_blocked_url() -> None:
"""Fetcher must return BLOCKED for SSRF-blocked URLs.""" """Fetcher must return BLOCKED for SSRF-blocked URLs."""
fetcher = AsyncFetcher() fetcher = AsyncFetcher()