Fix research pipeline budget tracking
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user