Fix research pipeline budget tracking
This commit is contained in:
37
HANDOFF.md
37
HANDOFF.md
@@ -7,6 +7,40 @@
|
|||||||
|
|
||||||
## Aktueller Stand — 2026-09-07
|
## Aktueller Stand — 2026-09-07
|
||||||
|
|
||||||
|
### Browser-E2E: Fehlerpfade und Quick-Budgets weiter repariert — 2026-09-07
|
||||||
|
|
||||||
|
Die nachfolgenden echten Browser-/Proxy-Tests erreichten erstmals die Crawler-
|
||||||
|
und Analysephasen. Drei weitere Fehler sind lokal repariert, durch gezielte
|
||||||
|
Regressionstests abgedeckt und mit `docker compose up -d --build nsct-api`
|
||||||
|
deployed:
|
||||||
|
|
||||||
|
1. Der Crawler erzeugte für einzelne Fetch-Fehler ein `NormalizedDocument` mit
|
||||||
|
leerem `content_hash`. Der Fehlerpfad verwendet nun `from_text()`, das auch
|
||||||
|
für leeren Inhalt einen gültigen SHA-256-Hash erzeugt. Einzelne kaputte
|
||||||
|
Quellen brechen damit nicht mehr die gesamte Recherche ab.
|
||||||
|
2. Quick-Runs buchten beim Start einen nicht existierenden Planner-Request und
|
||||||
|
anschließend eine Claim-LLM-Anfrage für jede Quelle, einschließlich
|
||||||
|
Fehlerquellen. Bei 15 Quellen standen so vor der Analyse 17 statt maximal
|
||||||
|
15 Requests im Budget. Der Phantom-Eintrag entfällt; nur erfolgreiche
|
||||||
|
Quellen werden für Claims verarbeitet und ein Request bleibt für die
|
||||||
|
Synthese reserviert. Ein voll ausgelasteter Quick-Run nutzt damit höchstens
|
||||||
|
`1 Planung + 13 Claims + 1 Synthese = 15` LLM-Requests.
|
||||||
|
3. `BudgetTracker.record_time_elapsed()` addierte bei jedem Aufruf erneut die
|
||||||
|
gesamte Zeit seit Run-Start. Der Tracker verbucht nun ausschließlich das
|
||||||
|
Intervall seit der letzten Messung. Für das lokale 35B-Modell mit ca.
|
||||||
|
8 Tokens/s gelten jetzt Zeitlimits von 30 Minuten (Quick), 60 Minuten
|
||||||
|
(Normal) und 120 Minuten (Deep).
|
||||||
|
|
||||||
|
Validierung: 68 gezielte Tests (`test_backend_pipeline_repairs.py`,
|
||||||
|
`test_crawler.py`, `test_search.py`) erfolgreich; Backend-Container neu
|
||||||
|
gebaut und `/health` liefert HTTP 200. Der vollständige neue Research-Run
|
||||||
|
wurde nicht abgewartet, weil die lokale 35B-Inferenz absichtlich mehrere
|
||||||
|
Minuten dauern kann. Beim nächsten Test eine frische Recherche starten und
|
||||||
|
insbesondere Quellen, Claims und Bericht nach Abschluss prüfen.
|
||||||
|
|
||||||
|
Für die laufende Testphase ist es akzeptiert, dass Research-Runs nur im
|
||||||
|
Arbeitsspeicher liegen und ein API-Neubau vorhandene Run-IDs verwirft.
|
||||||
|
|
||||||
### Browser-E2E: Such- und Crawler-Pipeline repariert — 2026-09-07
|
### Browser-E2E: Such- und Crawler-Pipeline repariert — 2026-09-07
|
||||||
|
|
||||||
Während des Browser-E2E-Tests traten drei aufeinanderfolgende Backend-Fehler
|
Während des Browser-E2E-Tests traten drei aufeinanderfolgende Backend-Fehler
|
||||||
@@ -26,7 +60,8 @@ auf. Alle sind lokal repariert, durch gezielte Tests abgedeckt und mit
|
|||||||
die tatsächlichen Downloads deutlich kleiner waren. Abrufe werden jetzt auf
|
die tatsächlichen Downloads deutlich kleiner waren. Abrufe werden jetzt auf
|
||||||
verbleibende Quellen- und Bytebudgets begrenzt; die Buchung erfolgt nach
|
verbleibende Quellen- und Bytebudgets begrenzt; die Buchung erfolgt nach
|
||||||
tatsächlich abgerufenen Response-Bytes. Budgetobergrenzen sind inklusiv.
|
tatsächlich abgerufenen Response-Bytes. Budgetobergrenzen sind inklusiv.
|
||||||
Das Quick-Zeitlimit beträgt nun 300 Sekunden (API-Dokumentation angepasst).
|
Das Quick-Zeitlimit betrug in diesem Zwischenstand 300 Sekunden und wurde
|
||||||
|
im späteren Budget-Fix für die lokale 35B-Inferenz auf 30 Minuten erhöht.
|
||||||
|
|
||||||
Validierung: 63 gezielte Tests (`test_backend_pipeline_repairs.py`,
|
Validierung: 63 gezielte Tests (`test_backend_pipeline_repairs.py`,
|
||||||
`test_crawler.py`, `test_search.py`) erfolgreich; Docker-Container und
|
`test_crawler.py`, `test_search.py`) erfolgreich; Docker-Container und
|
||||||
|
|||||||
@@ -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=300,
|
max_research_duration_seconds=1_800,
|
||||||
max_context_per_llm_call=16_000,
|
max_context_per_llm_call=16_000,
|
||||||
),
|
),
|
||||||
"normal": DepthConfig(
|
"normal": DepthConfig(
|
||||||
@@ -76,7 +76,7 @@ DEPTH_CONFIGS: dict[str, DepthConfig] = {
|
|||||||
max_pages_per_domain=5,
|
max_pages_per_domain=5,
|
||||||
max_total_download_bytes=2_000_000,
|
max_total_download_bytes=2_000_000,
|
||||||
max_llm_requests=30,
|
max_llm_requests=30,
|
||||||
max_research_duration_seconds=300,
|
max_research_duration_seconds=3_600,
|
||||||
max_context_per_llm_call=24_000,
|
max_context_per_llm_call=24_000,
|
||||||
),
|
),
|
||||||
"deep": DepthConfig(
|
"deep": DepthConfig(
|
||||||
@@ -86,7 +86,7 @@ DEPTH_CONFIGS: dict[str, DepthConfig] = {
|
|||||||
max_pages_per_domain=10,
|
max_pages_per_domain=10,
|
||||||
max_total_download_bytes=5_000_000,
|
max_total_download_bytes=5_000_000,
|
||||||
max_llm_requests=60,
|
max_llm_requests=60,
|
||||||
max_research_duration_seconds=600,
|
max_research_duration_seconds=7_200,
|
||||||
max_context_per_llm_call=32_000,
|
max_context_per_llm_call=32_000,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,10 @@ class CrawlerManager:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _error_doc(url: str, error: str) -> NormalizedDocument:
|
def _error_doc(url: str, error: str) -> NormalizedDocument:
|
||||||
"""Create a NormalizedDocument representing an error."""
|
"""Create a NormalizedDocument representing an error."""
|
||||||
doc = NormalizedDocument(
|
# Keep the error result structurally identical to a successfully
|
||||||
|
# normalized document. ``from_text`` hashes the (empty) content, so
|
||||||
|
# downstream consumers can safely rely on content_hash being present.
|
||||||
|
doc = NormalizedDocument.from_text(
|
||||||
url=url,
|
url=url,
|
||||||
text="",
|
text="",
|
||||||
title="",
|
title="",
|
||||||
@@ -176,7 +179,6 @@ class CrawlerManager:
|
|||||||
"error": error,
|
"error": error,
|
||||||
"content_type": "error",
|
"content_type": "error",
|
||||||
},
|
},
|
||||||
content_hash="",
|
|
||||||
extraction_tool="",
|
extraction_tool="",
|
||||||
)
|
)
|
||||||
return doc
|
return doc
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class BudgetTracker:
|
|||||||
def __init__(self, config: HardBudgetConfig) -> None:
|
def __init__(self, config: HardBudgetConfig) -> None:
|
||||||
self._config = config
|
self._config = config
|
||||||
self._start_time = time.monotonic()
|
self._start_time = time.monotonic()
|
||||||
|
self._last_time_recorded = self._start_time
|
||||||
# internal counters
|
# internal counters
|
||||||
self._counters: dict[str, int | float] = {
|
self._counters: dict[str, int | float] = {
|
||||||
"search_queries": 0,
|
"search_queries": 0,
|
||||||
@@ -132,12 +133,11 @@ class BudgetTracker:
|
|||||||
def record_time_elapsed(self) -> None:
|
def record_time_elapsed(self) -> None:
|
||||||
"""Tick the internal elapsed-time clock.
|
"""Tick the internal elapsed-time clock.
|
||||||
|
|
||||||
Adds the time since the tracker was created (or the last call)
|
Adds only the time since the previous call to the elapsed duration.
|
||||||
to the elapsed duration.
|
|
||||||
"""
|
"""
|
||||||
self.update_research_duration(
|
now = time.monotonic()
|
||||||
time.monotonic() - self._start_time
|
self.update_research_duration(now - self._last_time_recorded)
|
||||||
)
|
self._last_time_recorded = now
|
||||||
|
|
||||||
def increment_context_tokens(self, n: int) -> None:
|
def increment_context_tokens(self, n: int) -> None:
|
||||||
"""Add *n* tokens to the current LLM context window counter."""
|
"""Add *n* tokens to the current LLM context window counter."""
|
||||||
|
|||||||
@@ -345,8 +345,8 @@ class ResearchOrchestrator:
|
|||||||
ResearchRun
|
ResearchRun
|
||||||
Das erstellte Run-Objekt.
|
Das erstellte Run-Objekt.
|
||||||
"""
|
"""
|
||||||
# Budget init
|
# The planner call itself is accounted for in ``_step_planning``.
|
||||||
self._budget_tracker.increment_llm_requests(1) # planner call
|
# Starting a run performs no LLM request.
|
||||||
self._budget_tracker.increment_search(1) # initial search plan
|
self._budget_tracker.increment_search(1) # initial search plan
|
||||||
|
|
||||||
# ResearchRun erstellen
|
# ResearchRun erstellen
|
||||||
@@ -724,7 +724,26 @@ class ResearchOrchestrator:
|
|||||||
self._claims = []
|
self._claims = []
|
||||||
return {"success": True, "data": {"claims": []}, "claim_count": 0}
|
return {"success": True, "data": {"claims": []}, "claim_count": 0}
|
||||||
|
|
||||||
self._budget_tracker.increment_llm_requests(len(self._sources))
|
# Error documents are retained in ``_sources`` for transparent
|
||||||
|
# reporting, but they must never consume a claim-extraction LLM
|
||||||
|
# request. Reserve one request for the synthesis step as well,
|
||||||
|
# otherwise a quick run can use its entire LLM budget here and
|
||||||
|
# fail deterministically before synthesis.
|
||||||
|
extractable_sources = [source for source in self._sources if not source.get("error")]
|
||||||
|
usage = self._budget_tracker.get_usage()
|
||||||
|
remaining_llm_requests = self._budget_config.max_llm_requests - int(
|
||||||
|
usage["max_llm_requests"]["usage"]
|
||||||
|
)
|
||||||
|
synthesis_reserve = 1
|
||||||
|
max_sources_for_extraction = max(0, remaining_llm_requests - synthesis_reserve)
|
||||||
|
extractable_sources = extractable_sources[:max_sources_for_extraction]
|
||||||
|
|
||||||
|
if not extractable_sources:
|
||||||
|
logger.warning("No successful sources or LLM budget for claim extraction")
|
||||||
|
self._claims = []
|
||||||
|
return {"success": True, "data": {"claims": []}, "claim_count": 0}
|
||||||
|
|
||||||
|
self._budget_tracker.increment_llm_requests(len(extractable_sources))
|
||||||
|
|
||||||
# Stage 5: Claim Extraction
|
# Stage 5: Claim Extraction
|
||||||
try:
|
try:
|
||||||
@@ -734,7 +753,7 @@ class ResearchOrchestrator:
|
|||||||
llm_provider=llm_provider,
|
llm_provider=llm_provider,
|
||||||
config=self._config,
|
config=self._config,
|
||||||
research_run_id=self._run.id if self._run else uuid4(),
|
research_run_id=self._run.id if self._run else uuid4(),
|
||||||
sources=self._sources,
|
sources=extractable_sources,
|
||||||
)
|
)
|
||||||
self._claims = await extractor.extract()
|
self._claims = await extractor.extract()
|
||||||
except NameError:
|
except NameError:
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ import asyncio
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
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.budget import BudgetExhaustedError, BudgetTracker, HardBudgetConfig
|
||||||
from nsct.orchestration.state import ResearchRunState
|
from nsct.orchestration.state import ResearchRunState, StateMachine
|
||||||
from nsct.providers.abstract import NormalizedResult
|
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
|
from nsct.crawler.normalize import NormalizedDocument
|
||||||
|
from nsct.api.rest_research import DEPTH_CONFIGS
|
||||||
|
|
||||||
|
|
||||||
def _config(*, searxng_base_url: str | None = "http://searxng:8080") -> AppSettings:
|
def _config(*, searxng_base_url: str | None = "http://searxng:8080") -> AppSettings:
|
||||||
@@ -149,6 +150,70 @@ def test_hard_budget_allows_exactly_the_configured_limit() -> None:
|
|||||||
raise AssertionError("Source usage above the limit must exhaust the budget")
|
raise AssertionError("Source usage above the limit must exhaust the budget")
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_tracking_records_only_each_new_elapsed_interval() -> None:
|
||||||
|
with patch("nsct.orchestration.budget.time.monotonic", side_effect=[100.0, 110.0, 125.0]):
|
||||||
|
tracker = BudgetTracker(HardBudgetConfig())
|
||||||
|
tracker.record_time_elapsed()
|
||||||
|
tracker.record_time_elapsed()
|
||||||
|
|
||||||
|
usage = tracker.get_usage()
|
||||||
|
assert usage["max_research_duration_seconds"]["usage"] == 25.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_time_budgets_allow_local_35b_inference() -> None:
|
||||||
|
assert DEPTH_CONFIGS["quick"].max_research_duration_seconds == 1_800
|
||||||
|
assert DEPTH_CONFIGS["normal"].max_research_duration_seconds == 3_600
|
||||||
|
assert DEPTH_CONFIGS["deep"].max_research_duration_seconds == 7_200
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_does_not_charge_a_phantom_planner_request() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
orchestrator = ResearchOrchestrator(_config(), uuid4(), "test")
|
||||||
|
|
||||||
|
await orchestrator.start()
|
||||||
|
|
||||||
|
usage = orchestrator.budget_tracker.get_usage()
|
||||||
|
assert usage["max_llm_requests"]["usage"] == 0
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_extraction_reserves_synthesis_budget_and_skips_failed_sources() -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
budget = HardBudgetConfig(max_sources=15, max_llm_requests=15)
|
||||||
|
orchestrator = ResearchOrchestrator(_config(), uuid4(), "test", budget_config=budget)
|
||||||
|
await orchestrator.start()
|
||||||
|
orchestrator._state_machine = StateMachine(ResearchRunState.FETCHING)
|
||||||
|
orchestrator._budget_tracker.increment_llm_requests() # completed planning call
|
||||||
|
orchestrator._llm_provider = Mock()
|
||||||
|
orchestrator._sources = [
|
||||||
|
{"id": str(index), "url": f"https://example.org/{index}", "error": ""}
|
||||||
|
for index in range(14)
|
||||||
|
] + [{"id": "failed", "url": "https://example.org/failed", "error": "timeout"}]
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class FakeExtractor:
|
||||||
|
def __init__(self, **kwargs: object) -> None:
|
||||||
|
captured["sources"] = kwargs["sources"]
|
||||||
|
|
||||||
|
async def extract(self) -> list[object]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
with patch("nsct.stages.stage5_extract_claims.Stage5Extractor", FakeExtractor):
|
||||||
|
response = await orchestrator._step_extracting()
|
||||||
|
|
||||||
|
assert response["success"] is True
|
||||||
|
assert len(captured["sources"]) == 13
|
||||||
|
usage = orchestrator.budget_tracker.get_usage()
|
||||||
|
assert usage["max_llm_requests"]["usage"] == 14
|
||||||
|
|
||||||
|
# The reserved final request reaches, but does not exceed, the quick limit.
|
||||||
|
orchestrator._budget_tracker.increment_llm_requests()
|
||||||
|
orchestrator.budget_tracker.check_budget()
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import pytest
|
|||||||
|
|
||||||
from nsct.crawler.extraction import extract_main_content
|
from nsct.crawler.extraction import extract_main_content
|
||||||
from nsct.crawler.fetcher import AsyncFetcher, FetchResult, FetchStatus
|
from nsct.crawler.fetcher import AsyncFetcher, FetchResult, FetchStatus
|
||||||
|
from nsct.crawler.manager import CrawlerManager
|
||||||
from nsct.crawler.normalize import NormalizedDocument
|
from nsct.crawler.normalize import NormalizedDocument
|
||||||
from nsct.crawler.policy import (
|
from nsct.crawler.policy import (
|
||||||
CrawlerPolicyError,
|
CrawlerPolicyError,
|
||||||
@@ -209,6 +210,15 @@ async def test_fetcher_unreachable_domain() -> None:
|
|||||||
await fetcher.close()
|
await fetcher.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_document_has_hash_for_empty_content() -> None:
|
||||||
|
"""A failed fetch must still yield a valid normalized document."""
|
||||||
|
doc = CrawlerManager._error_doc("https://example.com/unavailable", "connection refused")
|
||||||
|
|
||||||
|
assert doc.text == ""
|
||||||
|
assert doc.metadata["error"] == "connection refused"
|
||||||
|
assert doc.content_hash == hashlib.sha256(b"").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# NormalizedDocument has all required fields
|
# NormalizedDocument has all required fields
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user