Files
Celes Renata a72f336ad1 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.
2026-07-13 02:14:59 +00:00

226 lines
7.3 KiB
Python

"""Inference adapter bridging the document extractor to the InferenceGateway.
Replaces direct use of llm_factory / VLLMClient / OllamaClient in the
extraction pipeline. Uses the shared InferenceGateway with extraction-
specific prompt construction and records actual endpoint, deployment,
model, and protocol lineage in the result.
Requirements: 2.12, 13.6
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from services.extractor.client import (
ExtractionAttempt,
ExtractionResponse,
_repair_json,
_strip_markdown_fences,
)
from services.extractor.prompts import (
build_extraction_prompt,
get_json_schema,
get_prompt_metadata,
)
from services.extractor.schemas import validate_extraction
from services.shared.inference.gateway import InferenceGateway
from services.shared.inference.lineage import ModelLineage, build_lineage_from_result
from services.shared.inference.models import (
ChatMessage,
InferenceResult,
InferenceTarget,
StructuredGenerationRequest,
)
logger = logging.getLogger("extractor.inference_adapter")
@dataclass
class ExtractionWithLineage:
"""Extraction result bundled with inference lineage metadata.
The lineage records the actual endpoint, deployment, model, and
protocol used — fixing the hardcoded ``model_provider = 'ollama'``.
"""
response: ExtractionResponse
lineage: ModelLineage
raw_inference_results: list[InferenceResult] = field(default_factory=list)
async def extract_document(
gateway: InferenceGateway,
target: InferenceTarget,
document_text: str,
document_type: str = "article",
document_id: str = "",
known_tickers: list[str] | None = None,
max_retries: int = 3,
retry_base_delay: float = 2.0,
retry_max_delay: float = 30.0,
retry_backoff_multiplier: float = 2.0,
) -> ExtractionWithLineage:
"""Extract structured intelligence from a document via the InferenceGateway.
This adapter:
1. Builds extraction-specific prompts (same as current pipeline)
2. Constructs a StructuredGenerationRequest
3. Routes through the InferenceGateway (correct client per protocol)
4. Parses / repairs JSON, validates against the extraction schema
5. Records actual lineage (endpoint_id, deployment_id, model, protocol)
Args:
gateway: The shared InferenceGateway instance.
target: Resolved inference target for extraction.
document_text: The document to extract from.
document_type: Type of document (article, filing, transcript, etc.).
document_id: UUID of the source document.
known_tickers: Optional list of tracked tickers for context.
max_retries: Maximum number of retry attempts.
retry_base_delay: Initial retry delay in seconds.
retry_max_delay: Maximum retry delay in seconds.
retry_backoff_multiplier: Backoff multiplier for retries.
Returns:
ExtractionWithLineage containing the extraction response and lineage.
"""
import asyncio
prompts = build_extraction_prompt(
document_text=document_text,
document_type=document_type,
document_id=document_id,
known_tickers=known_tickers,
)
json_schema = get_json_schema()
prompt_meta = get_prompt_metadata()
response = ExtractionResponse(
prompt_metadata=prompt_meta,
model=target.model,
)
inference_results: list[InferenceResult] = []
last_lineage: ModelLineage | None = None
total_start = time.monotonic()
for attempt_num in range(max_retries + 1):
# Build request
request = StructuredGenerationRequest(
messages=[
ChatMessage(role="system", content=prompts["system"]),
ChatMessage(role="user", content=prompts["user"]),
],
json_schema=json_schema,
max_output_tokens=target.extra_body.get("max_tokens", 4096),
temperature=0.0,
seed=0,
timeout_seconds=target.timeout_seconds,
trace_id=document_id,
)
# Call via gateway
result = await gateway.generate(target, request)
inference_results.append(result)
last_lineage = build_lineage_from_result(result, trace_id=document_id)
# Convert to ExtractionAttempt for compatibility
attempt = _inference_result_to_attempt(result, target.model, document_text)
response.attempts.append(attempt)
if attempt.error is None and attempt.validation and attempt.validation.valid:
response.success = True
response.result = attempt.validation.parsed
break
# Determine if retryable
retryable = _is_result_retryable(result)
attempt.retryable = retryable
if not retryable:
logger.warning(
"Non-retryable error for doc %s: %s — stopping retries",
document_id or "unknown",
attempt.error,
)
break
if attempt_num < max_retries:
delay = retry_base_delay * (retry_backoff_multiplier ** attempt_num)
delay = min(delay, retry_max_delay)
logger.warning(
"Extraction attempt %d/%d failed for doc %s: %s — retrying in %.1fs",
attempt_num + 1,
max_retries + 1,
document_id or "unknown",
attempt.error or "validation failed",
delay,
)
await asyncio.sleep(delay)
response.total_duration_ms = int((time.monotonic() - total_start) * 1000)
# Use actual lineage from last inference call
lineage = last_lineage or ModelLineage(model=target.model, protocol=target.protocol)
return ExtractionWithLineage(
response=response,
lineage=lineage,
raw_inference_results=inference_results,
)
def _inference_result_to_attempt(
result: InferenceResult,
model: str,
document_text: str,
) -> ExtractionAttempt:
"""Convert an InferenceResult to the legacy ExtractionAttempt format.
Applies the same markdown-fence stripping, JSON repair, and schema
validation as the existing VLLMClient and OllamaClient.
"""
attempt = ExtractionAttempt(model=model)
attempt.duration_ms = result.latency_ms
attempt.raw_output = result.content
# Check for gateway-level errors
if result.error:
attempt.error = result.error
attempt.retryable = _is_result_retryable(result)
return attempt
content = result.content
if not content:
attempt.error = "empty_model_response"
return attempt
# Strip markdown fences if present
content = _strip_markdown_fences(content)
# Repair malformed JSON
content = _repair_json(content)
# Validate against extraction schema
attempt.validation = validate_extraction(content, document_text=document_text)
if not attempt.validation.valid:
attempt.error = "; ".join(attempt.validation.errors)
return attempt
def _is_result_retryable(result: InferenceResult) -> bool:
"""Determine if an inference result error is retryable."""
if result.error_category in (
"timeout",
"rate_limit",
"server_error",
"connection_error",
):
return True
if result.error and "empty" in result.error.lower():
return True
return False