Fix research pipeline budget tracking

This commit is contained in:
faligam
2026-09-07 11:56:13 +02:00
parent a5324d3971
commit 5f237d535f
7 changed files with 148 additions and 17 deletions

View File

@@ -6,17 +6,18 @@ import asyncio
from uuid import uuid4
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.orchestration.orchestrator import ResearchOrchestrator
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.llm import _LLMProviderImpl
from nsct.providers.metrics import ProviderMetrics
from nsct.providers.searxng import SearXNGProvider
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:
@@ -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")
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:
async def run() -> None:
orchestrator = ResearchOrchestrator(_config(), uuid4(), "test")

View File

@@ -11,6 +11,7 @@ import pytest
from nsct.crawler.extraction import extract_main_content
from nsct.crawler.fetcher import AsyncFetcher, FetchResult, FetchStatus
from nsct.crawler.manager import CrawlerManager
from nsct.crawler.normalize import NormalizedDocument
from nsct.crawler.policy import (
CrawlerPolicyError,
@@ -209,6 +210,15 @@ async def test_fetcher_unreachable_domain() -> None:
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
# ---------------------------------------------------------------------------