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.
226 lines
7.3 KiB
Python
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
|