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,225 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user