feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
"""Tests for the InferenceGateway facade, lineage recording, and adapters.
|
||||
|
||||
Covers:
|
||||
- Gateway creates correct client type per protocol
|
||||
- Gateway reuses clients for same endpoint
|
||||
- Target refresh invalidates cached client
|
||||
- Lineage recording captures all required fields
|
||||
- Extraction adapter returns lineage metadata
|
||||
- Unknown protocols fail closed
|
||||
|
||||
Requirements: 2.1, 2.6, 2.12, 13.6
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.shared.inference.gateway import InferenceGateway
|
||||
from services.shared.inference.lineage import (
|
||||
build_lineage_from_result,
|
||||
lineage_to_persistence_dict,
|
||||
)
|
||||
from services.shared.inference.models import (
|
||||
ChatMessage,
|
||||
InferenceResult,
|
||||
InferenceTarget,
|
||||
ModelLineage,
|
||||
ProviderCapabilities,
|
||||
StructuredGenerationRequest,
|
||||
TokenUsage,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_target(
|
||||
protocol: str = "openai_chat",
|
||||
endpoint_id: uuid.UUID | None = None,
|
||||
deployment_id: uuid.UUID | None = None,
|
||||
model: str = "test-model",
|
||||
) -> InferenceTarget:
|
||||
"""Create a minimal InferenceTarget for testing."""
|
||||
return InferenceTarget(
|
||||
endpoint_id=endpoint_id or uuid.uuid4(),
|
||||
deployment_id=deployment_id or uuid.uuid4(),
|
||||
protocol=protocol,
|
||||
base_url="http://localhost:8000",
|
||||
model=model,
|
||||
capabilities=ProviderCapabilities(
|
||||
chat_completions=True,
|
||||
json_schema=True,
|
||||
usage=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_request() -> StructuredGenerationRequest:
|
||||
"""Create a minimal StructuredGenerationRequest."""
|
||||
return StructuredGenerationRequest(
|
||||
messages=[ChatMessage(role="user", content="hello")],
|
||||
max_output_tokens=256,
|
||||
)
|
||||
|
||||
|
||||
def _make_inference_result(
|
||||
endpoint_id: uuid.UUID | None = None,
|
||||
deployment_id: uuid.UUID | None = None,
|
||||
model: str = "test-model",
|
||||
protocol: str = "openai_chat",
|
||||
) -> InferenceResult:
|
||||
"""Create a typical InferenceResult."""
|
||||
return InferenceResult(
|
||||
content='{"answer": 42}',
|
||||
parsed={"answer": 42},
|
||||
endpoint_id=endpoint_id or uuid.uuid4(),
|
||||
deployment_id=deployment_id or uuid.uuid4(),
|
||||
model=model,
|
||||
protocol=protocol,
|
||||
structured_mode="json_schema",
|
||||
latency_ms=150,
|
||||
usage=TokenUsage(input_tokens=50, output_tokens=20, total_tokens=70),
|
||||
request_id="req-001",
|
||||
retries=0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway: correct client type per protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGatewayClientCreation:
|
||||
"""Gateway creates the correct client type based on protocol."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_openai_client_for_openai_chat(self) -> None:
|
||||
"""openai_chat protocol creates OpenAICompatibleClient."""
|
||||
gateway = InferenceGateway()
|
||||
target = _make_target(protocol="openai_chat")
|
||||
|
||||
with patch(
|
||||
"services.shared.inference.clients.openai_compatible.OpenAICompatibleClient.generate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_make_inference_result(
|
||||
endpoint_id=target.endpoint_id,
|
||||
deployment_id=target.deployment_id,
|
||||
),
|
||||
):
|
||||
result = await gateway.generate(target, _make_request())
|
||||
assert result.protocol == "openai_chat"
|
||||
|
||||
await gateway.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_ollama_client_for_ollama_native(self) -> None:
|
||||
"""ollama_native protocol creates OllamaNativeClient."""
|
||||
gateway = InferenceGateway()
|
||||
target = _make_target(protocol="ollama_native")
|
||||
|
||||
with patch(
|
||||
"services.shared.inference.clients.ollama_native.OllamaNativeClient.generate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_make_inference_result(
|
||||
endpoint_id=target.endpoint_id,
|
||||
deployment_id=target.deployment_id,
|
||||
protocol="ollama_native",
|
||||
),
|
||||
):
|
||||
result = await gateway.generate(target, _make_request())
|
||||
assert result.protocol == "ollama_native"
|
||||
|
||||
await gateway.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_protocol_fails_closed(self) -> None:
|
||||
"""Unknown protocol raises ValueError — never silently routes to Ollama."""
|
||||
gateway = InferenceGateway()
|
||||
# Use a type-ignore here since we're intentionally passing an invalid protocol
|
||||
target = InferenceTarget(
|
||||
endpoint_id=uuid.uuid4(),
|
||||
deployment_id=uuid.uuid4(),
|
||||
protocol="unknown_protocol", # type: ignore[arg-type]
|
||||
base_url="http://localhost:8000",
|
||||
model="test",
|
||||
capabilities=ProviderCapabilities(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown inference protocol"):
|
||||
await gateway.generate(target, _make_request())
|
||||
|
||||
await gateway.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway: client reuse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGatewayClientReuse:
|
||||
"""Gateway reuses clients for the same endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reuses_client_for_same_endpoint(self) -> None:
|
||||
"""Repeated calls with the same target reuse the cached client."""
|
||||
gateway = InferenceGateway()
|
||||
endpoint_id = uuid.uuid4()
|
||||
target = _make_target(endpoint_id=endpoint_id)
|
||||
|
||||
mock_result = _make_inference_result(endpoint_id=endpoint_id)
|
||||
|
||||
with patch(
|
||||
"services.shared.inference.clients.openai_compatible.OpenAICompatibleClient.generate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
):
|
||||
await gateway.generate(target, _make_request())
|
||||
await gateway.generate(target, _make_request())
|
||||
|
||||
# Only one client should be cached
|
||||
assert len(gateway._clients) == 1
|
||||
assert endpoint_id in gateway._clients
|
||||
|
||||
await gateway.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_separate_clients_for_different_endpoints(self) -> None:
|
||||
"""Different endpoint IDs get separate cached clients."""
|
||||
gateway = InferenceGateway()
|
||||
target_a = _make_target(endpoint_id=uuid.uuid4())
|
||||
target_b = _make_target(endpoint_id=uuid.uuid4())
|
||||
|
||||
mock_result_a = _make_inference_result(endpoint_id=target_a.endpoint_id)
|
||||
mock_result_b = _make_inference_result(endpoint_id=target_b.endpoint_id)
|
||||
|
||||
with patch(
|
||||
"services.shared.inference.clients.openai_compatible.OpenAICompatibleClient.generate",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=[mock_result_a, mock_result_b],
|
||||
):
|
||||
await gateway.generate(target_a, _make_request())
|
||||
await gateway.generate(target_b, _make_request())
|
||||
|
||||
assert len(gateway._clients) == 2
|
||||
assert target_a.endpoint_id in gateway._clients
|
||||
assert target_b.endpoint_id in gateway._clients
|
||||
|
||||
await gateway.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway: target refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGatewayTargetRefresh:
|
||||
"""Target refresh invalidates cached client."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_invalidates_cached_client(self) -> None:
|
||||
"""After refresh, the next call creates a new client."""
|
||||
gateway = InferenceGateway()
|
||||
endpoint_id = uuid.uuid4()
|
||||
target = _make_target(endpoint_id=endpoint_id)
|
||||
mock_result = _make_inference_result(endpoint_id=endpoint_id)
|
||||
|
||||
with patch(
|
||||
"services.shared.inference.clients.openai_compatible.OpenAICompatibleClient.generate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
), patch(
|
||||
"services.shared.inference.clients.openai_compatible.OpenAICompatibleClient.close",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_close:
|
||||
await gateway.generate(target, _make_request())
|
||||
assert endpoint_id in gateway._clients
|
||||
|
||||
await gateway.refresh_target(endpoint_id)
|
||||
assert endpoint_id not in gateway._clients
|
||||
mock_close.assert_called_once()
|
||||
|
||||
await gateway.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_nonexistent_endpoint_is_safe(self) -> None:
|
||||
"""Refreshing an endpoint that isn't cached does nothing."""
|
||||
gateway = InferenceGateway()
|
||||
# Should not raise
|
||||
await gateway.refresh_target(uuid.uuid4())
|
||||
await gateway.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lineage recording
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLineageRecording:
|
||||
"""Lineage recording captures all required fields."""
|
||||
|
||||
def test_build_lineage_from_result_captures_all_fields(self) -> None:
|
||||
"""All lineage fields are extracted from InferenceResult."""
|
||||
eid = uuid.uuid4()
|
||||
did = uuid.uuid4()
|
||||
result = InferenceResult(
|
||||
content="test",
|
||||
endpoint_id=eid,
|
||||
deployment_id=did,
|
||||
model="qwen-9b",
|
||||
protocol="openai_chat",
|
||||
structured_mode="json_schema",
|
||||
latency_ms=300,
|
||||
request_id="req-abc",
|
||||
retries=1,
|
||||
)
|
||||
|
||||
lineage = build_lineage_from_result(result, trace_id="trace-123")
|
||||
|
||||
assert lineage.endpoint_id == eid
|
||||
assert lineage.deployment_id == did
|
||||
assert lineage.model == "qwen-9b"
|
||||
assert lineage.protocol == "openai_chat"
|
||||
assert lineage.structured_mode == "json_schema"
|
||||
assert lineage.request_id == "req-abc"
|
||||
assert lineage.latency_ms == 300
|
||||
assert lineage.retries == 1
|
||||
assert lineage.trace_id == "trace-123"
|
||||
|
||||
def test_lineage_to_persistence_dict_maps_protocol(self) -> None:
|
||||
"""Protocol is mapped to human-friendly model_provider for persistence."""
|
||||
lineage = ModelLineage(
|
||||
endpoint_id=uuid.uuid4(),
|
||||
deployment_id=uuid.uuid4(),
|
||||
model="test-model",
|
||||
protocol="openai_chat",
|
||||
structured_mode="json_schema",
|
||||
request_id="req-1",
|
||||
latency_ms=100,
|
||||
retries=0,
|
||||
trace_id="t-1",
|
||||
)
|
||||
|
||||
d = lineage_to_persistence_dict(lineage)
|
||||
|
||||
assert d["model_provider"] == "openai_compatible"
|
||||
assert d["model_name"] == "test-model"
|
||||
assert d["protocol"] == "openai_chat"
|
||||
assert d["endpoint_id"] is not None
|
||||
assert d["deployment_id"] is not None
|
||||
assert d["structured_mode"] == "json_schema"
|
||||
assert d["request_id"] == "req-1"
|
||||
assert d["latency_ms"] == 100
|
||||
assert d["retries"] == 0
|
||||
assert d["trace_id"] == "t-1"
|
||||
|
||||
def test_lineage_ollama_protocol_maps_to_ollama_provider(self) -> None:
|
||||
"""ollama_native protocol maps to 'ollama' provider."""
|
||||
lineage = ModelLineage(
|
||||
model="qwen-9b",
|
||||
protocol="ollama_native",
|
||||
)
|
||||
d = lineage_to_persistence_dict(lineage)
|
||||
assert d["model_provider"] == "ollama"
|
||||
|
||||
def test_lineage_specialist_protocol_maps_to_specialist_provider(self) -> None:
|
||||
"""specialist_http protocol maps to 'specialist' provider."""
|
||||
lineage = ModelLineage(
|
||||
model="gliner2-large",
|
||||
protocol="specialist_http",
|
||||
)
|
||||
d = lineage_to_persistence_dict(lineage)
|
||||
assert d["model_provider"] == "specialist"
|
||||
|
||||
def test_lineage_serialization(self) -> None:
|
||||
"""ModelLineage serializes all fields via model_dump()."""
|
||||
lineage = ModelLineage(
|
||||
endpoint_id=uuid.uuid4(),
|
||||
deployment_id=uuid.uuid4(),
|
||||
model="test",
|
||||
protocol="openai_chat",
|
||||
structured_mode="json_object",
|
||||
request_id="r-1",
|
||||
latency_ms=42,
|
||||
retries=2,
|
||||
trace_id="t-abc",
|
||||
)
|
||||
data = lineage.model_dump()
|
||||
assert data["model"] == "test"
|
||||
assert data["protocol"] == "openai_chat"
|
||||
assert data["trace_id"] == "t-abc"
|
||||
assert data["latency_ms"] == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction adapter: lineage metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractionAdapterLineage:
|
||||
"""Extraction adapter returns lineage metadata."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_document_returns_lineage(self) -> None:
|
||||
"""extract_document bundles lineage with the extraction response."""
|
||||
from services.extractor.inference_adapter import extract_document
|
||||
|
||||
gateway = InferenceGateway()
|
||||
endpoint_id = uuid.uuid4()
|
||||
deployment_id = uuid.uuid4()
|
||||
target = _make_target(
|
||||
endpoint_id=endpoint_id,
|
||||
deployment_id=deployment_id,
|
||||
)
|
||||
|
||||
# Mock the gateway to return a valid extraction JSON
|
||||
extraction_json = '{"summary":"test","companies":[],"macro_themes":[],"novelty_score":0.5,"confidence":0.8,"extraction_warnings":[]}'
|
||||
mock_result = InferenceResult(
|
||||
content=extraction_json,
|
||||
endpoint_id=endpoint_id,
|
||||
deployment_id=deployment_id,
|
||||
model="test-model",
|
||||
protocol="openai_chat",
|
||||
structured_mode="json_schema",
|
||||
latency_ms=200,
|
||||
usage=TokenUsage(input_tokens=100, output_tokens=50),
|
||||
request_id="req-ext-1",
|
||||
retries=0,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
gateway,
|
||||
"generate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
):
|
||||
result = await extract_document(
|
||||
gateway=gateway,
|
||||
target=target,
|
||||
document_text="Test document text for extraction.",
|
||||
document_id="doc-123",
|
||||
)
|
||||
|
||||
# Verify lineage is populated
|
||||
assert result.lineage is not None
|
||||
assert result.lineage.endpoint_id == endpoint_id
|
||||
assert result.lineage.deployment_id == deployment_id
|
||||
assert result.lineage.model == "test-model"
|
||||
assert result.lineage.protocol == "openai_chat"
|
||||
assert result.lineage.request_id == "req-ext-1"
|
||||
assert result.lineage.latency_ms == 200
|
||||
|
||||
await gateway.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_document_lineage_on_failure(self) -> None:
|
||||
"""Lineage is still captured even when extraction fails."""
|
||||
from services.extractor.inference_adapter import extract_document
|
||||
|
||||
gateway = InferenceGateway()
|
||||
endpoint_id = uuid.uuid4()
|
||||
deployment_id = uuid.uuid4()
|
||||
target = _make_target(
|
||||
endpoint_id=endpoint_id,
|
||||
deployment_id=deployment_id,
|
||||
)
|
||||
|
||||
# Mock the gateway to return an error result
|
||||
mock_result = InferenceResult(
|
||||
content="",
|
||||
endpoint_id=endpoint_id,
|
||||
deployment_id=deployment_id,
|
||||
model="test-model",
|
||||
protocol="openai_chat",
|
||||
structured_mode="json_schema",
|
||||
latency_ms=5000,
|
||||
request_id="req-timeout",
|
||||
retries=3,
|
||||
error="Request timed out",
|
||||
error_category="timeout",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
gateway,
|
||||
"generate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
):
|
||||
result = await extract_document(
|
||||
gateway=gateway,
|
||||
target=target,
|
||||
document_text="Some text",
|
||||
document_id="doc-fail",
|
||||
max_retries=0, # no retries for test speed
|
||||
)
|
||||
|
||||
# Extraction failed but lineage is still captured
|
||||
assert not result.response.success
|
||||
assert result.lineage.endpoint_id == endpoint_id
|
||||
assert result.lineage.model == "test-model"
|
||||
assert result.lineage.protocol == "openai_chat"
|
||||
|
||||
await gateway.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway: active_endpoints property
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGatewayProperties:
|
||||
"""Gateway exposes useful state for monitoring."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_endpoints_tracks_cached_clients(self) -> None:
|
||||
"""active_endpoints shows all cached endpoint IDs."""
|
||||
gateway = InferenceGateway()
|
||||
eid = uuid.uuid4()
|
||||
target = _make_target(endpoint_id=eid)
|
||||
mock_result = _make_inference_result(endpoint_id=eid)
|
||||
|
||||
with patch(
|
||||
"services.shared.inference.clients.openai_compatible.OpenAICompatibleClient.generate",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_result,
|
||||
):
|
||||
await gateway.generate(target, _make_request())
|
||||
|
||||
assert eid in gateway.active_endpoints
|
||||
assert len(gateway.active_endpoints) == 1
|
||||
|
||||
await gateway.close()
|
||||
assert len(gateway.active_endpoints) == 0
|
||||
Reference in New Issue
Block a user