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.
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""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)
|