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,91 @@
|
||||
"""Pydantic models for company-specific sentiment analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class EvidenceGroup(BaseModel):
|
||||
"""A group of evidence spans associated with a specific company.
|
||||
|
||||
A single evidence span can appear in multiple groups when the
|
||||
underlying text mentions multiple companies.
|
||||
"""
|
||||
|
||||
company_id: str = Field(description="Resolved company identifier")
|
||||
evidence_ids: list[str] = Field(description="IDs of evidence spans in this group")
|
||||
texts: list[str] = Field(description="Text snippets from evidence spans")
|
||||
|
||||
@field_validator("evidence_ids")
|
||||
@classmethod
|
||||
def evidence_ids_non_empty(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("evidence_ids must not be empty")
|
||||
return v
|
||||
|
||||
@field_validator("texts")
|
||||
@classmethod
|
||||
def texts_non_empty(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("texts must not be empty")
|
||||
return v
|
||||
|
||||
|
||||
class TextSentiment(BaseModel):
|
||||
"""Per-text sentiment probability distribution with evidence linkage.
|
||||
|
||||
Stores the raw FinBERT output for a single evidence span text,
|
||||
enabling full provenance from probability to source evidence.
|
||||
"""
|
||||
|
||||
evidence_id: str = Field(description="Evidence span ID this score belongs to")
|
||||
positive_prob: float = Field(ge=0.0, le=1.0, description="Probability of positive sentiment")
|
||||
negative_prob: float = Field(ge=0.0, le=1.0, description="Probability of negative sentiment")
|
||||
neutral_prob: float = Field(ge=0.0, le=1.0, description="Probability of neutral sentiment")
|
||||
|
||||
@property
|
||||
def dominant_label(self) -> str:
|
||||
"""Return the label with highest probability."""
|
||||
if self.positive_prob >= self.negative_prob and self.positive_prob >= self.neutral_prob:
|
||||
return "positive"
|
||||
elif self.negative_prob >= self.positive_prob and self.negative_prob >= self.neutral_prob:
|
||||
return "negative"
|
||||
return "neutral"
|
||||
|
||||
|
||||
class CompanySentimentResult(BaseModel):
|
||||
"""Sentiment classification result for a single company.
|
||||
|
||||
Contains full probability distribution, supporting evidence IDs,
|
||||
per-text scores, and model/calibration versioning for lineage tracking.
|
||||
"""
|
||||
|
||||
company_id: str = Field(description="Resolved company identifier")
|
||||
label: str = Field(description="Derived label: positive, negative, neutral, or mixed")
|
||||
positive_prob: float = Field(ge=0.0, le=1.0, description="Probability of positive sentiment")
|
||||
negative_prob: float = Field(ge=0.0, le=1.0, description="Probability of negative sentiment")
|
||||
neutral_prob: float = Field(ge=0.0, le=1.0, description="Probability of neutral sentiment")
|
||||
evidence_ids: list[str] = Field(description="All evidence span IDs contributing to this result")
|
||||
is_mixed: bool = Field(default=False, description="Whether mixed sentiment was detected from disagreement")
|
||||
per_text_scores: list[TextSentiment] = Field(
|
||||
default_factory=list,
|
||||
description="Full probability distributions per evidence text",
|
||||
)
|
||||
model_version: str = Field(description="Sentiment model name and version")
|
||||
calibration_version: str = Field(default="uncalibrated", description="Calibration artifact version")
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
def label_valid(cls, v: str) -> str:
|
||||
valid_labels = {"positive", "negative", "neutral", "mixed"}
|
||||
if v not in valid_labels:
|
||||
raise ValueError(f"label must be one of {valid_labels}, got '{v}'")
|
||||
return v
|
||||
|
||||
|
||||
class SentimentBatchResult(BaseModel):
|
||||
"""Result of sentiment classification for a batch of companies."""
|
||||
|
||||
results: list[CompanySentimentResult] = Field(description="Per-company sentiment results")
|
||||
model_version: str = Field(description="Sentiment model version used for batch")
|
||||
processing_time_ms: int = Field(ge=0, description="Total processing time in milliseconds")
|
||||
Reference in New Issue
Block a user