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:
Celes Renata
2026-07-13 02:14:59 +00:00
parent 84634a365e
commit a72f336ad1
227 changed files with 50403 additions and 0 deletions
@@ -0,0 +1,66 @@
"""V3 annotation schema — entity, event, relation, sentiment, and evidence models."""
from services.intelligence_pipeline_v3.schemas.annotations import (
AmbiguityMarker,
AmbiguityType,
AnnotatedDocument,
AnnotationMetadata,
CompanySentimentAnnotation,
DirectEffect,
EntityAnnotation,
EntityType,
EventAnnotation,
EventClass,
EvidenceSpanAnnotation,
InferredExposure,
NumericFactAnnotation,
PeriodAnnotation,
PeriodType,
RelationAnnotation,
RelationType,
SentimentLabel,
)
from services.intelligence_pipeline_v3.schemas.safety import (
SAFETY_CRITICAL_FIELDS,
SafetyCriticalField,
SafetyGateResult,
check_safety_gates,
)
from services.intelligence_pipeline_v3.schemas.validators import (
ValidationError as AnnotationValidationError,
)
from services.intelligence_pipeline_v3.schemas.validators import (
ValidationResult,
validate_annotation,
)
__all__ = [
# Annotation models
"AmbiguityMarker",
"AmbiguityType",
"AnnotatedDocument",
"AnnotationMetadata",
"CompanySentimentAnnotation",
"DirectEffect",
"EntityAnnotation",
"EntityType",
"EvidenceSpanAnnotation",
"EventAnnotation",
"EventClass",
"InferredExposure",
"NumericFactAnnotation",
"PeriodAnnotation",
"PeriodType",
"RelationAnnotation",
"RelationType",
"SentimentLabel",
# Safety
"SAFETY_CRITICAL_FIELDS",
"SafetyCriticalField",
"SafetyGateResult",
"check_safety_gates",
# Validators
"AnnotationValidationError",
"ValidationResult",
"validate_annotation",
]
@@ -0,0 +1,398 @@
"""V3 annotation schema — labels for entities, events, relations, facts, and evidence.
This module defines the complete annotation schema for the Intelligence Pipeline v3
Gold Corpus. Every extracted field is traceable to source evidence via character offsets.
Schema version: 1.0.0
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from enum import Enum
from typing import Literal
from pydantic import BaseModel, Field, model_validator
# ---------------------------------------------------------------------------
# Enumerations
# ---------------------------------------------------------------------------
class EntityType(str, Enum):
"""Recognized entity types in the v3 pipeline."""
COMPANY = "company"
PERSON = "person"
PRODUCT = "product"
EVENT = "event"
FINANCIAL_METRIC = "financial_metric"
DATE = "date"
PERCENTAGE = "percentage"
CURRENCY = "currency"
RELATIONSHIP = "relationship"
class EventClass(str, Enum):
"""Versioned event taxonomy for market-relevant occurrences."""
EARNINGS_BEAT = "earnings_beat"
EARNINGS_MISS = "earnings_miss"
GUIDANCE_RAISE = "guidance_raise"
GUIDANCE_CUT = "guidance_cut"
MA_ANNOUNCEMENT = "ma_announcement"
LEGAL_REGULATORY = "legal_regulatory"
PRODUCT_LAUNCH = "product_launch"
SUPPLY_CHAIN = "supply_chain"
RATING_CHANGE = "rating_change"
MANAGEMENT_CHANGE = "management_change"
MACRO_EVENT = "macro_event"
DIVIDEND_CHANGE = "dividend_change"
BUYBACK = "buyback"
class SentimentLabel(str, Enum):
"""Sentiment classification for company-linked evidence groups."""
POSITIVE = "positive"
NEGATIVE = "negative"
NEUTRAL = "neutral"
MIXED = "mixed"
class RelationType(str, Enum):
"""Relation types between entities or between events and companies."""
DIRECTLY_AFFECTS = "directly_affects"
INFERRED_EXPOSURE = "inferred_exposure"
COMPETES_WITH = "competes_with"
SUPPLIES = "supplies"
class PeriodType(str, Enum):
"""Financial period type identifiers."""
FISCAL_QUARTER = "fiscal_quarter"
FISCAL_YEAR = "fiscal_year"
CALENDAR_QUARTER = "calendar_quarter"
CALENDAR_YEAR = "calendar_year"
TRAILING_TWELVE_MONTHS = "ttm"
YEAR_TO_DATE = "ytd"
CUSTOM = "custom"
class AmbiguityType(str, Enum):
"""Ambiguity reasons that trigger adjudication routing."""
UNRESOLVED_ALIAS = "unresolved_alias"
MULTIPLE_PRIMARY_COMPANIES = "multiple_primary_companies"
CONTRADICTORY_NUMERIC_FACTS = "contradictory_numeric_facts"
CONFLICTING_SENTIMENT = "conflicting_sentiment"
IMPLIED_CAUSAL_IMPACT = "implied_causal_impact"
GUIDANCE_VS_CONSENSUS_REQUIRES_REASONING = "guidance_vs_consensus_requires_reasoning"
MATERIAL_FIELD_MISSING = "material_field_missing"
EVIDENCE_COVERAGE_BELOW_THRESHOLD = "evidence_coverage_below_threshold"
CALIBRATED_CONFIDENCE_BELOW_THRESHOLD = "calibrated_confidence_below_threshold"
LONG_DOCUMENT_CROSS_CHUNK_RELATION = "long_document_cross_chunk_relation"
# ---------------------------------------------------------------------------
# Evidence Span
# ---------------------------------------------------------------------------
class EvidenceSpanAnnotation(BaseModel):
"""Exact source text with stable character offsets.
Every extracted fact, entity, or relation MUST reference at least one evidence span.
Offsets are zero-based and refer to the original (pre-chunking) document text.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
chunk_id: str | None = Field(
default=None, description="Chunk ID if document was segmented."
)
start_char: int = Field(ge=0, description="Zero-based start character offset.")
end_char: int = Field(ge=0, description="Zero-based end character offset (exclusive).")
text: str = Field(min_length=1, description="Exact source text at this span.")
checksum: str | None = Field(
default=None,
description="SHA-256 hex digest of the span text for integrity verification.",
)
@model_validator(mode="after")
def end_after_start(self) -> "EvidenceSpanAnnotation":
if self.end_char <= self.start_char:
raise ValueError(
f"end_char ({self.end_char}) must be greater than start_char ({self.start_char})"
)
return self
# ---------------------------------------------------------------------------
# Entity Annotation
# ---------------------------------------------------------------------------
class EntityAnnotation(BaseModel):
"""A labeled entity mention with optional canonical resolution."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
entity_type: EntityType
literal_text: str = Field(min_length=1, description="Exact surface form as it appears.")
canonical_id: str | None = Field(
default=None,
description="UUID of the canonical company/entity from the symbol registry.",
)
canonical_name: str | None = Field(
default=None, description="Resolved canonical name (e.g., ticker or full name)."
)
evidence_ids: list[str] = Field(
min_length=1,
description="References to EvidenceSpanAnnotation IDs supporting this entity.",
)
confidence: float = Field(ge=0.0, le=1.0, description="Annotator certainty [0, 1].")
derivation: str = Field(
default="manual",
description="How this label was derived: manual, deterministic, specialist, adjudicated.",
)
# ---------------------------------------------------------------------------
# Event Annotation
# ---------------------------------------------------------------------------
class EventAnnotation(BaseModel):
"""A market-relevant event classification anchored to evidence."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
event_class: EventClass
description: str = Field(
default="", description="Brief human-readable description of the event."
)
primary_company_ids: list[str] = Field(
default_factory=list,
description="Entity annotation IDs of directly affected companies.",
)
evidence_ids: list[str] = Field(
min_length=1, description="Evidence spans supporting this event classification."
)
confidence: float = Field(ge=0.0, le=1.0)
derivation: str = Field(default="manual")
# ---------------------------------------------------------------------------
# Relation Annotation
# ---------------------------------------------------------------------------
class RelationAnnotation(BaseModel):
"""A typed relation between two entities or between an event and an entity."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
relation_type: RelationType
source_id: str = Field(description="Entity or event annotation ID (subject).")
target_id: str = Field(description="Entity annotation ID (object).")
evidence_ids: list[str] = Field(min_length=1)
confidence: float = Field(ge=0.0, le=1.0)
derivation: str = Field(default="manual")
# ---------------------------------------------------------------------------
# Numeric Fact Annotation
# ---------------------------------------------------------------------------
class NumericFactAnnotation(BaseModel):
"""A numeric fact (EPS, revenue, percentage change, etc.) extracted from the document."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
fact_type: str = Field(
description="Category: eps, revenue, percentage_change, price_target, guidance, market_cap, etc."
)
subject_entity_id: str | None = Field(
default=None, description="Entity annotation ID this fact belongs to."
)
predicate: str = Field(
description="Semantic predicate: reported, expected, raised_to, cut_to, beat_by, etc."
)
literal_value: str = Field(description="Exact textual representation from the source.")
normalized_value: float | None = Field(
default=None, description="Numeric value after normalization."
)
unit: str | None = Field(
default=None, description="Unit: USD, %, bps, shares, etc."
)
period: "PeriodAnnotation | None" = Field(
default=None, description="Financial period this fact applies to."
)
evidence_ids: list[str] = Field(min_length=1)
confidence: float = Field(ge=0.0, le=1.0)
derivation: str = Field(default="manual")
# ---------------------------------------------------------------------------
# Period Annotation
# ---------------------------------------------------------------------------
class PeriodAnnotation(BaseModel):
"""Financial or calendar period reference."""
period_type: PeriodType
fiscal_year: int | None = Field(default=None, description="e.g. 2024")
fiscal_quarter: int | None = Field(default=None, ge=1, le=4)
start_date: date | None = None
end_date: date | None = None
literal_text: str | None = Field(
default=None, description="Original text describing the period."
)
# ---------------------------------------------------------------------------
# Sentiment Annotation
# ---------------------------------------------------------------------------
class CompanySentimentAnnotation(BaseModel):
"""Company-specific sentiment with probability distribution.
Mixed sentiment is derived from disagreement across evidence groups — it is NOT
an unconstrained fourth softmax label.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
company_entity_id: str = Field(
description="Entity annotation ID of the company this sentiment applies to."
)
label: SentimentLabel
positive_probability: float = Field(ge=0.0, le=1.0)
negative_probability: float = Field(ge=0.0, le=1.0)
neutral_probability: float = Field(ge=0.0, le=1.0)
evidence_ids: list[str] = Field(min_length=1)
confidence: float = Field(ge=0.0, le=1.0)
derivation: str = Field(default="manual")
@model_validator(mode="after")
def probabilities_sum_to_one(self) -> "CompanySentimentAnnotation":
total = (
self.positive_probability
+ self.negative_probability
+ self.neutral_probability
)
if abs(total - 1.0) > 0.01:
raise ValueError(
f"Sentiment probabilities must sum to ~1.0, got {total:.4f}"
)
return self
# ---------------------------------------------------------------------------
# Direct Effects and Inferred Exposure
# ---------------------------------------------------------------------------
class DirectEffect(BaseModel):
"""An event directly affecting a specific company, backed by explicit evidence."""
event_id: str = Field(description="Event annotation ID.")
company_entity_id: str = Field(description="Entity annotation ID of the affected company.")
evidence_ids: list[str] = Field(min_length=1)
confidence: float = Field(ge=0.0, le=1.0)
class InferredExposure(BaseModel):
"""An inferred (not explicitly stated) exposure of a company to an event.
Inferred exposures have separate confidence and do NOT enter primary extraction.
They flow through the interpolation/propagation architecture with distinct provenance.
"""
event_id: str = Field(description="Event annotation ID.")
company_entity_id: str = Field(description="Entity annotation ID.")
reasoning: str = Field(
description="Brief explanation of the inference chain (e.g., supply-chain relationship)."
)
evidence_ids: list[str] = Field(
default_factory=list,
description="Supporting evidence (may be empty for purely inferred relations).",
)
confidence: float = Field(ge=0.0, le=1.0)
# ---------------------------------------------------------------------------
# Ambiguity Marker
# ---------------------------------------------------------------------------
class AmbiguityMarker(BaseModel):
"""Flags cases that require adjudication routing.
These markers determine whether a document is processed via the fast path
or routed to the 9B model for semantic adjudication.
"""
ambiguity_type: AmbiguityType
description: str = Field(
default="", description="Human-readable description of the ambiguity."
)
affected_entity_ids: list[str] = Field(default_factory=list)
affected_event_ids: list[str] = Field(default_factory=list)
severity: Literal["low", "medium", "high"] = Field(
default="medium",
description="Impact on extraction confidence.",
)
# ---------------------------------------------------------------------------
# Annotation Metadata
# ---------------------------------------------------------------------------
class AnnotationMetadata(BaseModel):
"""Metadata for a complete document annotation."""
schema_version: str = Field(default="1.0.0")
annotator_id: str = Field(description="Identifier of the annotator (human or system).")
annotation_date: datetime = Field(default_factory=lambda: datetime.now(tz=datetime.now().astimezone().tzinfo))
review_status: Literal["draft", "reviewed", "adjudicated", "gold"] = Field(
default="draft"
)
reviewer_id: str | None = None
review_date: datetime | None = None
notes: str = Field(default="")
# ---------------------------------------------------------------------------
# Top-Level Annotated Document
# ---------------------------------------------------------------------------
class AnnotatedDocument(BaseModel):
"""Complete v3 annotation for a single source document.
This is the top-level container used in the Gold Corpus. Every field references
evidence spans for traceability.
"""
document_id: str = Field(description="UUID of the source document.")
document_type: str = Field(description="article, filing, transcript, press_release, macro_event")
source_text: str = Field(description="Full original document text (offsets reference this).")
metadata: AnnotationMetadata
# Core annotations
evidence_spans: list[EvidenceSpanAnnotation] = Field(default_factory=list)
entities: list[EntityAnnotation] = Field(default_factory=list)
events: list[EventAnnotation] = Field(default_factory=list)
relations: list[RelationAnnotation] = Field(default_factory=list)
numeric_facts: list[NumericFactAnnotation] = Field(default_factory=list)
sentiments: list[CompanySentimentAnnotation] = Field(default_factory=list)
# Effects and exposure
direct_effects: list[DirectEffect] = Field(default_factory=list)
inferred_exposures: list[InferredExposure] = Field(default_factory=list)
# Routing
ambiguity_markers: list[AmbiguityMarker] = Field(default_factory=list)
@@ -0,0 +1,149 @@
"""Safety-critical field definitions for v3 promotion gates.
Fields marked as safety-critical MUST pass their quality gates before pipeline
outputs are allowed to influence production aggregation or trading decisions.
A single safety-critical failure blocks promotion for the affected document type.
Schema version: 1.0.0
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class SafetyCriticalField(str, Enum):
"""Fields whose incorrect extraction can materially affect trading decisions."""
# Company identity — wrong ticker attribution can cause trades on the wrong security
COMPANY_IDENTITY = "company_identity"
# Event classification — misclassifying earnings_beat as earnings_miss inverts signals
EVENT_CLASS = "event_class"
# Sentiment direction — wrong sentiment directly affects position direction
SENTIMENT_DIRECTION = "sentiment_direction"
# Numeric fact accuracy — wrong EPS or revenue magnitude affects impact estimation
NUMERIC_FACT_VALUE = "numeric_fact_value"
# Direct effect attribution — attributing an event to the wrong company creates false signals
DIRECT_EFFECT_ATTRIBUTION = "direct_effect_attribution"
# Evidence support — claims without valid evidence spans are unverifiable
EVIDENCE_SUPPORT = "evidence_support"
# Confidence calibration — overconfident scores bypass appropriate review thresholds
CONFIDENCE_CALIBRATION = "confidence_calibration"
# Map each safety-critical field to its minimum required quality metric for promotion
SAFETY_CRITICAL_FIELDS: dict[SafetyCriticalField, dict[str, float]] = {
SafetyCriticalField.COMPANY_IDENTITY: {
"precision": 0.95,
"recall": 0.90,
"f1": 0.92,
},
SafetyCriticalField.EVENT_CLASS: {
"macro_f1": 0.85,
"per_class_min_f1": 0.70,
},
SafetyCriticalField.SENTIMENT_DIRECTION: {
"macro_f1": 0.85,
"direction_accuracy": 0.90,
},
SafetyCriticalField.NUMERIC_FACT_VALUE: {
"exact_match": 0.80,
"tolerance_match_5pct": 0.92,
},
SafetyCriticalField.DIRECT_EFFECT_ATTRIBUTION: {
"precision": 0.93,
"recall": 0.88,
},
SafetyCriticalField.EVIDENCE_SUPPORT: {
"support_rate": 0.95,
"offset_validity": 0.98,
},
SafetyCriticalField.CONFIDENCE_CALIBRATION: {
"ece": 0.05, # Expected Calibration Error — lower is better
"brier_score": 0.15, # Lower is better
},
}
@dataclass
class SafetyGateResult:
"""Result of checking a single safety-critical field against its gate."""
field: SafetyCriticalField
passed: bool
metric_name: str
required_value: float
actual_value: float
is_lower_better: bool = False
@property
def margin(self) -> float:
"""How far above (or below for lower-is-better) the threshold."""
if self.is_lower_better:
return self.required_value - self.actual_value
return self.actual_value - self.required_value
def check_safety_gates(
metrics: dict[SafetyCriticalField, dict[str, float]],
) -> list[SafetyGateResult]:
"""Check all safety-critical fields against their promotion thresholds.
Args:
metrics: Measured quality metrics per safety-critical field.
Keys match SafetyCriticalField, values are metric_name -> value dicts.
Returns:
List of SafetyGateResult for each check performed.
Any result with passed=False blocks promotion.
"""
lower_is_better = {"ece", "brier_score"}
results: list[SafetyGateResult] = []
for field, thresholds in SAFETY_CRITICAL_FIELDS.items():
measured = metrics.get(field, {})
for metric_name, required in thresholds.items():
actual = measured.get(metric_name)
if actual is None:
# Missing metric fails the gate
results.append(
SafetyGateResult(
field=field,
passed=False,
metric_name=metric_name,
required_value=required,
actual_value=float("nan"),
is_lower_better=metric_name in lower_is_better,
)
)
continue
is_lower = metric_name in lower_is_better
if is_lower:
passed = actual <= required
else:
passed = actual >= required
results.append(
SafetyGateResult(
field=field,
passed=passed,
metric_name=metric_name,
required_value=required,
actual_value=actual,
is_lower_better=is_lower,
)
)
return results
@@ -0,0 +1,465 @@
"""Sample annotations as test fixtures for the v3 annotation schema.
These samples demonstrate correct annotation format and serve as regression
fixtures for the validator. They cover representative document types and
complexity levels from the Gold Corpus.
Schema version: 1.0.0
"""
from __future__ import annotations
from datetime import datetime, timezone
from services.intelligence_pipeline_v3.schemas.annotations import (
AmbiguityMarker,
AmbiguityType,
AnnotatedDocument,
AnnotationMetadata,
CompanySentimentAnnotation,
DirectEffect,
EntityAnnotation,
EntityType,
EventAnnotation,
EventClass,
EvidenceSpanAnnotation,
InferredExposure,
NumericFactAnnotation,
PeriodAnnotation,
PeriodType,
RelationAnnotation,
RelationType,
SentimentLabel,
)
# ---------------------------------------------------------------------------
# Sample 1: Simple earnings beat article (single company, fast path)
# ---------------------------------------------------------------------------
_EARNINGS_TEXT = (
"Apple Inc. reported quarterly earnings of $1.52 per share, "
"beating the consensus estimate of $1.43 by $0.09. "
"Revenue came in at $94.9 billion, above expectations of $92.1 billion. "
"The company raised its dividend by 4% to $0.26 per share."
)
def build_sample_earnings_beat() -> AnnotatedDocument:
"""Single-company earnings beat with numeric facts and clear sentiment."""
ev_apple = EvidenceSpanAnnotation(
id="ev-001",
start_char=0,
end_char=10,
text="Apple Inc.",
)
ev_eps = EvidenceSpanAnnotation(
id="ev-002",
start_char=11,
end_char=108,
text="reported quarterly earnings of $1.52 per share, beating the consensus estimate of $1.43 by $0.09.",
)
ev_revenue = EvidenceSpanAnnotation(
id="ev-003",
start_char=109,
end_char=179,
text="Revenue came in at $94.9 billion, above expectations of $92.1 billion.",
)
ev_dividend = EvidenceSpanAnnotation(
id="ev-004",
start_char=180,
end_char=237,
text="The company raised its dividend by 4% to $0.26 per share.",
)
entity_apple = EntityAnnotation(
id="ent-001",
entity_type=EntityType.COMPANY,
literal_text="Apple Inc.",
canonical_id="aapl-uuid",
canonical_name="AAPL",
evidence_ids=["ev-001"],
confidence=1.0,
derivation="deterministic",
)
event_beat = EventAnnotation(
id="evt-001",
event_class=EventClass.EARNINGS_BEAT,
description="Apple Q1 FY2025 earnings beat consensus by $0.09/share",
primary_company_ids=["ent-001"],
evidence_ids=["ev-002"],
confidence=0.98,
derivation="specialist",
)
event_dividend = EventAnnotation(
id="evt-002",
event_class=EventClass.DIVIDEND_CHANGE,
description="Apple raises dividend by 4%",
primary_company_ids=["ent-001"],
evidence_ids=["ev-004"],
confidence=0.95,
derivation="specialist",
)
fact_eps = NumericFactAnnotation(
id="fact-001",
fact_type="eps",
subject_entity_id="ent-001",
predicate="reported",
literal_value="$1.52 per share",
normalized_value=1.52,
unit="USD",
period=PeriodAnnotation(
period_type=PeriodType.FISCAL_QUARTER,
fiscal_year=2025,
fiscal_quarter=1,
literal_text="quarterly",
),
evidence_ids=["ev-002"],
confidence=0.99,
derivation="deterministic",
)
fact_revenue = NumericFactAnnotation(
id="fact-002",
fact_type="revenue",
subject_entity_id="ent-001",
predicate="reported",
literal_value="$94.9 billion",
normalized_value=94_900_000_000,
unit="USD",
evidence_ids=["ev-003"],
confidence=0.99,
derivation="deterministic",
)
sentiment = CompanySentimentAnnotation(
id="sent-001",
company_entity_id="ent-001",
label=SentimentLabel.POSITIVE,
positive_probability=0.88,
negative_probability=0.04,
neutral_probability=0.08,
evidence_ids=["ev-002", "ev-003", "ev-004"],
confidence=0.92,
derivation="specialist",
)
direct = DirectEffect(
event_id="evt-001",
company_entity_id="ent-001",
evidence_ids=["ev-002"],
confidence=0.98,
)
return AnnotatedDocument(
document_id="doc-sample-001",
document_type="article",
source_text=_EARNINGS_TEXT,
metadata=AnnotationMetadata(
schema_version="1.0.0",
annotator_id="gold-annotator-1",
annotation_date=datetime(2025, 1, 15, tzinfo=timezone.utc),
review_status="gold",
reviewer_id="senior-reviewer-1",
review_date=datetime(2025, 1, 16, tzinfo=timezone.utc),
),
evidence_spans=[ev_apple, ev_eps, ev_revenue, ev_dividend],
entities=[entity_apple],
events=[event_beat, event_dividend],
relations=[],
numeric_facts=[fact_eps, fact_revenue],
sentiments=[sentiment],
direct_effects=[direct],
inferred_exposures=[],
ambiguity_markers=[],
)
# ---------------------------------------------------------------------------
# Sample 2: Multi-company competitive article (requires adjudication)
# ---------------------------------------------------------------------------
_MULTI_COMPANY_TEXT = (
"Microsoft announced a $10 billion investment in OpenAI, "
"intensifying competition with Google in the AI space. "
"Analysts expect this deal to pressure Alphabet's cloud revenue growth, "
"though some see it as validation of the broader AI investment thesis."
)
def build_sample_multi_company_competitive() -> AnnotatedDocument:
"""Multi-company article with competing sentiments and inferred exposure."""
ev_msft = EvidenceSpanAnnotation(
id="ev-101",
start_char=0,
end_char=9,
text="Microsoft",
)
ev_deal = EvidenceSpanAnnotation(
id="ev-102",
start_char=10,
end_char=55,
text="announced a $10 billion investment in OpenAI,",
)
ev_competition = EvidenceSpanAnnotation(
id="ev-103",
start_char=56,
end_char=109,
text="intensifying competition with Google in the AI space.",
)
ev_pressure = EvidenceSpanAnnotation(
id="ev-104",
start_char=110,
end_char=180,
text="Analysts expect this deal to pressure Alphabet's cloud revenue growth,",
)
ev_validation = EvidenceSpanAnnotation(
id="ev-105",
start_char=181,
end_char=250,
text="though some see it as validation of the broader AI investment thesis.",
)
ent_msft = EntityAnnotation(
id="ent-101",
entity_type=EntityType.COMPANY,
literal_text="Microsoft",
canonical_id="msft-uuid",
canonical_name="MSFT",
evidence_ids=["ev-101"],
confidence=1.0,
derivation="deterministic",
)
ent_goog = EntityAnnotation(
id="ent-102",
entity_type=EntityType.COMPANY,
literal_text="Google",
canonical_id="googl-uuid",
canonical_name="GOOGL",
evidence_ids=["ev-103"],
confidence=0.98,
derivation="deterministic",
)
ent_alphabet = EntityAnnotation(
id="ent-103",
entity_type=EntityType.COMPANY,
literal_text="Alphabet",
canonical_id="googl-uuid",
canonical_name="GOOGL",
evidence_ids=["ev-104"],
confidence=0.97,
derivation="specialist",
)
event_ma = EventAnnotation(
id="evt-101",
event_class=EventClass.MA_ANNOUNCEMENT,
description="Microsoft $10B investment in OpenAI",
primary_company_ids=["ent-101"],
evidence_ids=["ev-102"],
confidence=0.96,
derivation="specialist",
)
rel_competes = RelationAnnotation(
id="rel-101",
relation_type=RelationType.COMPETES_WITH,
source_id="ent-101",
target_id="ent-102",
evidence_ids=["ev-103"],
confidence=0.90,
derivation="specialist",
)
fact_amount = NumericFactAnnotation(
id="fact-101",
fact_type="investment_amount",
subject_entity_id="ent-101",
predicate="invested",
literal_value="$10 billion",
normalized_value=10_000_000_000,
unit="USD",
evidence_ids=["ev-102"],
confidence=0.99,
derivation="deterministic",
)
sentiment_msft = CompanySentimentAnnotation(
id="sent-101",
company_entity_id="ent-101",
label=SentimentLabel.POSITIVE,
positive_probability=0.75,
negative_probability=0.05,
neutral_probability=0.20,
evidence_ids=["ev-102"],
confidence=0.85,
derivation="specialist",
)
sentiment_goog = CompanySentimentAnnotation(
id="sent-102",
company_entity_id="ent-102",
label=SentimentLabel.MIXED,
positive_probability=0.30,
negative_probability=0.45,
neutral_probability=0.25,
evidence_ids=["ev-103", "ev-104", "ev-105"],
confidence=0.70,
derivation="specialist",
)
direct_msft = DirectEffect(
event_id="evt-101",
company_entity_id="ent-101",
evidence_ids=["ev-102"],
confidence=0.96,
)
inferred_goog = InferredExposure(
event_id="evt-101",
company_entity_id="ent-102",
reasoning="Competitive pressure from Microsoft's AI investment threatens Google's cloud market share",
evidence_ids=["ev-103", "ev-104"],
confidence=0.72,
)
ambiguity = AmbiguityMarker(
ambiguity_type=AmbiguityType.CONFLICTING_SENTIMENT,
description="Alphabet sentiment is mixed — competitive pressure vs. AI thesis validation",
affected_entity_ids=["ent-102", "ent-103"],
severity="medium",
)
return AnnotatedDocument(
document_id="doc-sample-002",
document_type="article",
source_text=_MULTI_COMPANY_TEXT,
metadata=AnnotationMetadata(
schema_version="1.0.0",
annotator_id="gold-annotator-2",
annotation_date=datetime(2025, 1, 20, tzinfo=timezone.utc),
review_status="gold",
reviewer_id="senior-reviewer-1",
review_date=datetime(2025, 1, 21, tzinfo=timezone.utc),
),
evidence_spans=[ev_msft, ev_deal, ev_competition, ev_pressure, ev_validation],
entities=[ent_msft, ent_goog, ent_alphabet],
events=[event_ma],
relations=[rel_competes],
numeric_facts=[fact_amount],
sentiments=[sentiment_msft, sentiment_goog],
direct_effects=[direct_msft],
inferred_exposures=[inferred_goog],
ambiguity_markers=[ambiguity],
)
# ---------------------------------------------------------------------------
# Sample 3: Macro event with inferred sector exposure
# ---------------------------------------------------------------------------
_MACRO_TEXT = (
"The Federal Reserve raised interest rates by 25 basis points to 5.50%, "
"citing persistent inflation concerns. Markets sold off broadly, "
"with technology stocks leading the decline."
)
def build_sample_macro_event() -> AnnotatedDocument:
"""Macro event with sector-level inferred exposure and no single primary company."""
ev_rate = EvidenceSpanAnnotation(
id="ev-202",
start_char=0,
end_char=70,
text="The Federal Reserve raised interest rates by 25 basis points to 5.50%,",
)
ev_inflation = EvidenceSpanAnnotation(
id="ev-203",
start_char=71,
end_char=108,
text="citing persistent inflation concerns.",
)
ev_selloff = EvidenceSpanAnnotation(
id="ev-204",
start_char=109,
end_char=178,
text="Markets sold off broadly, with technology stocks leading the decline.",
)
ent_fed = EntityAnnotation(
id="ent-201",
entity_type=EntityType.COMPANY,
literal_text="The Federal Reserve",
canonical_id=None,
canonical_name="Federal Reserve",
evidence_ids=["ev-202"],
confidence=1.0,
derivation="deterministic",
)
event_macro = EventAnnotation(
id="evt-201",
event_class=EventClass.MACRO_EVENT,
description="Fed raises rates 25bps to 5.50%",
primary_company_ids=[],
evidence_ids=["ev-202", "ev-203", "ev-204"],
confidence=0.99,
derivation="deterministic",
)
fact_rate = NumericFactAnnotation(
id="fact-201",
fact_type="interest_rate_change",
subject_entity_id="ent-201",
predicate="raised_by",
literal_value="25 basis points",
normalized_value=0.25,
unit="percentage_points",
evidence_ids=["ev-202"],
confidence=0.99,
derivation="deterministic",
)
fact_level = NumericFactAnnotation(
id="fact-202",
fact_type="interest_rate_level",
subject_entity_id="ent-201",
predicate="to",
literal_value="5.50%",
normalized_value=5.50,
unit="%",
evidence_ids=["ev-202"],
confidence=0.99,
derivation="deterministic",
)
return AnnotatedDocument(
document_id="doc-sample-003",
document_type="macro_event",
source_text=_MACRO_TEXT,
metadata=AnnotationMetadata(
schema_version="1.0.0",
annotator_id="gold-annotator-1",
annotation_date=datetime(2025, 2, 1, tzinfo=timezone.utc),
review_status="gold",
),
evidence_spans=[ev_rate, ev_inflation, ev_selloff],
entities=[ent_fed],
events=[event_macro],
relations=[],
numeric_facts=[fact_rate, fact_level],
sentiments=[],
direct_effects=[],
inferred_exposures=[],
ambiguity_markers=[],
)
# All sample builders for easy iteration
SAMPLE_BUILDERS = [
build_sample_earnings_beat,
build_sample_multi_company_competitive,
build_sample_macro_event,
]
@@ -0,0 +1,328 @@
"""Schema validators for v3 annotations.
Validates completeness, cross-references, evidence coverage, and offset integrity
for annotated documents before they enter the Gold Corpus or production pipeline.
Schema version: 1.0.0
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from services.intelligence_pipeline_v3.schemas.annotations import AnnotatedDocument
class ValidationSeverity(str, Enum):
ERROR = "error"
WARNING = "warning"
@dataclass
class ValidationError:
"""A single validation issue found in an annotation."""
severity: ValidationSeverity
field_path: str
message: str
entity_id: str | None = None
@dataclass
class ValidationResult:
"""Complete validation result for an annotated document."""
valid: bool
errors: list[ValidationError] = field(default_factory=list)
warnings: list[ValidationError] = field(default_factory=list)
@property
def error_count(self) -> int:
return len(self.errors)
@property
def warning_count(self) -> int:
return len(self.warnings)
def validate_annotation(doc: "AnnotatedDocument") -> ValidationResult:
"""Validate completeness and cross-references of an annotated document.
Checks performed:
1. All evidence_ids referenced by entities/events/relations/facts exist in evidence_spans
2. All entity IDs referenced by events/relations/effects exist in entities
3. Evidence span offsets are within the source_text bounds
4. Evidence span text matches the source_text at the given offsets
5. Sentiment probabilities are valid
6. No orphaned evidence spans (warning only)
7. Direct effects reference valid event and entity IDs
8. Inferred exposures reference valid event and entity IDs
"""
errors: list[ValidationError] = []
warnings: list[ValidationError] = []
# Build lookup indexes
evidence_ids = {span.id for span in doc.evidence_spans}
entity_ids = {entity.id for entity in doc.entities}
event_ids = {event.id for event in doc.events}
all_annotation_ids = evidence_ids | entity_ids | event_ids
# Track which evidence spans are referenced
referenced_evidence: set[str] = set()
# 1. Validate evidence spans themselves
for i, span in enumerate(doc.evidence_spans):
path = f"evidence_spans[{i}]"
# Offset bounds check
if span.start_char >= len(doc.source_text):
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.start_char",
message=f"start_char {span.start_char} exceeds source_text length {len(doc.source_text)}",
entity_id=span.id,
)
)
elif span.end_char > len(doc.source_text):
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.end_char",
message=f"end_char {span.end_char} exceeds source_text length {len(doc.source_text)}",
entity_id=span.id,
)
)
else:
# Text match check
expected_text = doc.source_text[span.start_char : span.end_char]
if expected_text != span.text:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.text",
message=(
f"Span text does not match source_text at offsets "
f"[{span.start_char}:{span.end_char}]. "
f"Expected: {expected_text!r}, got: {span.text!r}"
),
entity_id=span.id,
)
)
# 2. Validate entity evidence references
for i, entity in enumerate(doc.entities):
path = f"entities[{i}]"
for eid in entity.evidence_ids:
referenced_evidence.add(eid)
if eid not in evidence_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.evidence_ids",
message=f"References non-existent evidence span: {eid}",
entity_id=entity.id,
)
)
# 3. Validate event evidence and entity references
for i, event in enumerate(doc.events):
path = f"events[{i}]"
for eid in event.evidence_ids:
referenced_evidence.add(eid)
if eid not in evidence_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.evidence_ids",
message=f"References non-existent evidence span: {eid}",
entity_id=event.id,
)
)
for cid in event.primary_company_ids:
if cid not in entity_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.primary_company_ids",
message=f"References non-existent entity: {cid}",
entity_id=event.id,
)
)
# 4. Validate relation references
for i, rel in enumerate(doc.relations):
path = f"relations[{i}]"
for eid in rel.evidence_ids:
referenced_evidence.add(eid)
if eid not in evidence_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.evidence_ids",
message=f"References non-existent evidence span: {eid}",
entity_id=rel.id,
)
)
if rel.source_id not in all_annotation_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.source_id",
message=f"source_id references non-existent annotation: {rel.source_id}",
entity_id=rel.id,
)
)
if rel.target_id not in entity_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.target_id",
message=f"target_id references non-existent entity: {rel.target_id}",
entity_id=rel.id,
)
)
# 5. Validate numeric fact references
for i, fact in enumerate(doc.numeric_facts):
path = f"numeric_facts[{i}]"
for eid in fact.evidence_ids:
referenced_evidence.add(eid)
if eid not in evidence_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.evidence_ids",
message=f"References non-existent evidence span: {eid}",
entity_id=fact.id,
)
)
if fact.subject_entity_id and fact.subject_entity_id not in entity_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.subject_entity_id",
message=f"References non-existent entity: {fact.subject_entity_id}",
entity_id=fact.id,
)
)
# 6. Validate sentiment references
for i, sent in enumerate(doc.sentiments):
path = f"sentiments[{i}]"
for eid in sent.evidence_ids:
referenced_evidence.add(eid)
if eid not in evidence_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.evidence_ids",
message=f"References non-existent evidence span: {eid}",
entity_id=sent.id,
)
)
if sent.company_entity_id not in entity_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.company_entity_id",
message=f"References non-existent entity: {sent.company_entity_id}",
entity_id=sent.id,
)
)
# 7. Validate direct effects
for i, effect in enumerate(doc.direct_effects):
path = f"direct_effects[{i}]"
for eid in effect.evidence_ids:
referenced_evidence.add(eid)
if eid not in evidence_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.evidence_ids",
message=f"References non-existent evidence span: {eid}",
)
)
if effect.event_id not in event_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.event_id",
message=f"References non-existent event: {effect.event_id}",
)
)
if effect.company_entity_id not in entity_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.company_entity_id",
message=f"References non-existent entity: {effect.company_entity_id}",
)
)
# 8. Validate inferred exposures
for i, exposure in enumerate(doc.inferred_exposures):
path = f"inferred_exposures[{i}]"
for eid in exposure.evidence_ids:
referenced_evidence.add(eid)
if eid not in evidence_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.evidence_ids",
message=f"References non-existent evidence span: {eid}",
)
)
if exposure.event_id not in event_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.event_id",
message=f"References non-existent event: {exposure.event_id}",
)
)
if exposure.company_entity_id not in entity_ids:
errors.append(
ValidationError(
severity=ValidationSeverity.ERROR,
field_path=f"{path}.company_entity_id",
message=f"References non-existent entity: {exposure.company_entity_id}",
)
)
# 9. Check for orphaned evidence spans (warning)
orphaned = evidence_ids - referenced_evidence
for eid in orphaned:
warnings.append(
ValidationError(
severity=ValidationSeverity.WARNING,
field_path="evidence_spans",
message=f"Evidence span {eid} is not referenced by any annotation.",
entity_id=eid,
)
)
# 10. Check minimum annotation completeness
if not doc.entities:
warnings.append(
ValidationError(
severity=ValidationSeverity.WARNING,
field_path="entities",
message="Document has no entity annotations.",
)
)
if not doc.events:
warnings.append(
ValidationError(
severity=ValidationSeverity.WARNING,
field_path="events",
message="Document has no event annotations.",
)
)
is_valid = len(errors) == 0
return ValidationResult(valid=is_valid, errors=errors, warnings=warnings)