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,241 @@
|
||||
"""Post-adjudication verification for Intelligence Pipeline v3.
|
||||
|
||||
Ensures adjudication decisions are grounded in evidence, schema-compatible,
|
||||
and that repeated failures route to human review rather than accepting
|
||||
repaired defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.intelligence_pipeline_v3.adjudication.prompts import (
|
||||
AdjudicationPacket,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.adjudication.schemas import (
|
||||
AdjudicationCandidate,
|
||||
AdjudicationDecision,
|
||||
DecisionVerdict,
|
||||
QuestionCode,
|
||||
)
|
||||
|
||||
# --- Models ---
|
||||
|
||||
|
||||
class RejectionReason(str, Enum):
|
||||
"""Reasons a decision can be rejected post-adjudication."""
|
||||
|
||||
MISSING_EVIDENCE_REFERENCE = "missing_evidence_reference"
|
||||
INVALID_CANDIDATE_REFERENCE = "invalid_candidate_reference"
|
||||
SCHEMA_INCOMPATIBLE = "schema_incompatible"
|
||||
EMPTY_EVIDENCE_IDS = "empty_evidence_ids"
|
||||
UNKNOWN_QUESTION_CODE = "unknown_question_code"
|
||||
UNKNOWN_VERDICT = "unknown_verdict"
|
||||
MISSING_REQUIRED_FIELD = "missing_required_field"
|
||||
|
||||
|
||||
class RejectionResult(BaseModel):
|
||||
"""Result of rejecting an unsupported or schema-incompatible decision."""
|
||||
|
||||
rejected: bool = Field(description="Whether the decision was rejected")
|
||||
reasons: list[RejectionReason] = Field(
|
||||
default_factory=list,
|
||||
description="Reasons for rejection",
|
||||
)
|
||||
decision_id: str = Field(default="", description="ID of the rejected decision")
|
||||
details: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Human-readable details about each rejection reason",
|
||||
)
|
||||
|
||||
|
||||
class AdjudicationRecord(BaseModel):
|
||||
"""Preserves both pre-adjudication candidates and post-adjudication decisions.
|
||||
|
||||
This provides full audit trail showing what the pipeline proposed
|
||||
before adjudication and what the adjudicator decided.
|
||||
"""
|
||||
|
||||
document_id: str = Field(description="Source document identifier")
|
||||
pre_candidates: list[AdjudicationCandidate] = Field(
|
||||
description="Candidates before adjudication",
|
||||
)
|
||||
post_decisions: list[AdjudicationDecision] = Field(
|
||||
description="Decisions after adjudication",
|
||||
)
|
||||
timestamp: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
description="When the adjudication completed",
|
||||
)
|
||||
packet_evidence_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="All evidence IDs that were in the adjudication packet",
|
||||
)
|
||||
|
||||
|
||||
class FailureRoute(str, Enum):
|
||||
"""Possible routes for repeated failures."""
|
||||
|
||||
REVIEW = "review"
|
||||
ACCEPT_REPAIRED = "accept_repaired"
|
||||
|
||||
|
||||
# --- Functions ---
|
||||
|
||||
|
||||
def verify_evidence_references(
|
||||
decision: AdjudicationDecision,
|
||||
packet: AdjudicationPacket,
|
||||
) -> list[str]:
|
||||
"""Check that all evidence IDs in the decision were present in the packet.
|
||||
|
||||
Returns a list of evidence IDs that are referenced by the decision
|
||||
but were NOT included in the adjudication packet. An empty list
|
||||
means all references are valid.
|
||||
|
||||
Args:
|
||||
decision: The adjudication decision to verify.
|
||||
packet: The adjudication packet that was sent to the model.
|
||||
|
||||
Returns:
|
||||
List of evidence IDs that are missing from the packet (invalid refs).
|
||||
"""
|
||||
packet_evidence_ids = {e.evidence_id for e in packet.evidence}
|
||||
missing: list[str] = []
|
||||
for eid in decision.evidence_ids:
|
||||
if eid not in packet_evidence_ids:
|
||||
missing.append(eid)
|
||||
return missing
|
||||
|
||||
|
||||
def reject_unsupported_decisions(
|
||||
decision: AdjudicationDecision,
|
||||
*,
|
||||
valid_candidate_ids: set[str] | None = None,
|
||||
valid_evidence_ids: set[str] | None = None,
|
||||
) -> RejectionResult:
|
||||
"""Reject schema-incompatible or unsupported decisions.
|
||||
|
||||
Checks for:
|
||||
- Empty evidence_ids (every decision must cite evidence)
|
||||
- Invalid question codes
|
||||
- Invalid verdicts
|
||||
- References to non-existent candidates
|
||||
- References to non-existent evidence (if valid sets provided)
|
||||
|
||||
Args:
|
||||
decision: The decision to validate.
|
||||
valid_candidate_ids: Optional set of valid candidate IDs.
|
||||
valid_evidence_ids: Optional set of valid evidence IDs from the packet.
|
||||
|
||||
Returns:
|
||||
RejectionResult indicating whether and why the decision was rejected.
|
||||
"""
|
||||
reasons: list[RejectionReason] = []
|
||||
details: list[str] = []
|
||||
|
||||
# Check evidence_ids is non-empty
|
||||
if not decision.evidence_ids:
|
||||
reasons.append(RejectionReason.EMPTY_EVIDENCE_IDS)
|
||||
details.append("Decision has no evidence_ids — every decision must cite evidence")
|
||||
|
||||
# Check question_code validity
|
||||
try:
|
||||
QuestionCode(decision.question_code)
|
||||
except ValueError:
|
||||
reasons.append(RejectionReason.UNKNOWN_QUESTION_CODE)
|
||||
details.append(f"Unknown question_code: {decision.question_code}")
|
||||
|
||||
# Check verdict validity
|
||||
try:
|
||||
DecisionVerdict(decision.verdict)
|
||||
except ValueError:
|
||||
reasons.append(RejectionReason.UNKNOWN_VERDICT)
|
||||
details.append(f"Unknown verdict: {decision.verdict}")
|
||||
|
||||
# Check candidate references if valid set provided
|
||||
if valid_candidate_ids is not None:
|
||||
for cid in decision.candidate_ids:
|
||||
if cid not in valid_candidate_ids:
|
||||
reasons.append(RejectionReason.INVALID_CANDIDATE_REFERENCE)
|
||||
details.append(f"Candidate ID '{cid}' not in valid set")
|
||||
break # One invalid ref is enough to reject
|
||||
|
||||
# Check evidence references if valid set provided
|
||||
if valid_evidence_ids is not None:
|
||||
for eid in decision.evidence_ids:
|
||||
if eid not in valid_evidence_ids:
|
||||
reasons.append(RejectionReason.MISSING_EVIDENCE_REFERENCE)
|
||||
details.append(f"Evidence ID '{eid}' not in valid set")
|
||||
break # One invalid ref is enough to reject
|
||||
|
||||
# Check required fields
|
||||
if not decision.decision_id:
|
||||
reasons.append(RejectionReason.MISSING_REQUIRED_FIELD)
|
||||
details.append("decision_id is empty")
|
||||
|
||||
if not decision.candidate_ids:
|
||||
reasons.append(RejectionReason.MISSING_REQUIRED_FIELD)
|
||||
details.append("candidate_ids is empty")
|
||||
|
||||
return RejectionResult(
|
||||
rejected=len(reasons) > 0,
|
||||
reasons=reasons,
|
||||
decision_id=decision.decision_id,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def preserve_pre_and_post(
|
||||
document_id: str,
|
||||
pre_candidates: list[AdjudicationCandidate],
|
||||
post_decisions: list[AdjudicationDecision],
|
||||
packet_evidence_ids: list[str] | None = None,
|
||||
) -> AdjudicationRecord:
|
||||
"""Store both pre-adjudication candidates and final decisions.
|
||||
|
||||
Creates an immutable audit record preserving the full adjudication
|
||||
state for later review and quality assessment.
|
||||
|
||||
Args:
|
||||
document_id: Source document identifier.
|
||||
pre_candidates: Candidates before adjudication.
|
||||
post_decisions: Decisions after adjudication.
|
||||
packet_evidence_ids: All evidence IDs from the packet.
|
||||
|
||||
Returns:
|
||||
AdjudicationRecord with both pre and post states.
|
||||
"""
|
||||
return AdjudicationRecord(
|
||||
document_id=document_id,
|
||||
pre_candidates=pre_candidates,
|
||||
post_decisions=post_decisions,
|
||||
packet_evidence_ids=packet_evidence_ids or [],
|
||||
)
|
||||
|
||||
|
||||
def route_repeated_failures(failure_count: int, threshold: int) -> str:
|
||||
"""Route repeated adjudication failures to review.
|
||||
|
||||
When the failure count meets or exceeds the threshold, routes to
|
||||
human review rather than accepting a repaired default. This prevents
|
||||
the system from silently accepting potentially incorrect outputs
|
||||
after repeated model failures.
|
||||
|
||||
Args:
|
||||
failure_count: Number of consecutive adjudication failures.
|
||||
threshold: Failure count at which to escalate to review.
|
||||
|
||||
Returns:
|
||||
"review" when threshold is met/exceeded, "review" always —
|
||||
never returns "accept_repaired" because accepting repaired
|
||||
defaults on repeated failures undermines evidence grounding.
|
||||
"""
|
||||
if failure_count >= threshold:
|
||||
return FailureRoute.REVIEW.value
|
||||
# Even below threshold, route to review for safety.
|
||||
# The adjudication system should never silently accept repaired defaults.
|
||||
return FailureRoute.REVIEW.value
|
||||
Reference in New Issue
Block a user