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,60 @@
|
||||
"""Adjudication layer for Intelligence Pipeline v3.
|
||||
|
||||
This package provides:
|
||||
- Schemas for adjudication candidates, conflicts, evidence, questions, and decisions
|
||||
- Focused adjudication prompt building with strict JSON Schema output
|
||||
- 9B adjudicator deployment configuration and VRAM gating
|
||||
- Post-adjudication verification ensuring evidence grounding
|
||||
"""
|
||||
|
||||
from services.intelligence_pipeline_v3.adjudication.deployment import (
|
||||
APPROVED_MODEL,
|
||||
APPROVED_VLLM_VERSION,
|
||||
AlertConfig,
|
||||
ConcurrencySemaphore,
|
||||
check_vram_gate,
|
||||
verify_structured_output,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.adjudication.prompts import (
|
||||
AdjudicationPacket,
|
||||
PromptMetadata,
|
||||
build_adjudication_packet,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.adjudication.schemas import (
|
||||
AdjudicationCandidate,
|
||||
AdjudicationDecision,
|
||||
AdjudicationQuestion,
|
||||
ConflictDescription,
|
||||
EvidencePacket,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.adjudication.verification import (
|
||||
AdjudicationRecord,
|
||||
RejectionResult,
|
||||
preserve_pre_and_post,
|
||||
reject_unsupported_decisions,
|
||||
route_repeated_failures,
|
||||
verify_evidence_references,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"APPROVED_MODEL",
|
||||
"APPROVED_VLLM_VERSION",
|
||||
"AdjudicationCandidate",
|
||||
"AdjudicationDecision",
|
||||
"AdjudicationPacket",
|
||||
"AdjudicationQuestion",
|
||||
"AdjudicationRecord",
|
||||
"AlertConfig",
|
||||
"ConcurrencySemaphore",
|
||||
"ConflictDescription",
|
||||
"EvidencePacket",
|
||||
"PromptMetadata",
|
||||
"RejectionResult",
|
||||
"build_adjudication_packet",
|
||||
"check_vram_gate",
|
||||
"preserve_pre_and_post",
|
||||
"reject_unsupported_decisions",
|
||||
"route_repeated_failures",
|
||||
"verify_evidence_references",
|
||||
"verify_structured_output",
|
||||
]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""9B adjudicator deployment configuration for Intelligence Pipeline v3.
|
||||
|
||||
Manages the approved model/version pins, VRAM gating, concurrency
|
||||
semaphore configuration, and alerting thresholds for the 9B adjudicator
|
||||
running on RTX 4070 Ti SUPER via vLLM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# --- Pinned model and version constants ---
|
||||
|
||||
APPROVED_MODEL: str = "AxionML/Qwen3.5-9B-NVFP4"
|
||||
"""Approved 9B model for adjudication. NVFP4 quantization for 4070 Ti SUPER."""
|
||||
|
||||
APPROVED_VLLM_VERSION: str = "0.8.5"
|
||||
"""Approved vLLM version matching the cluster deployment."""
|
||||
|
||||
APPROVED_SERVED_NAME: str = "stonks-adjudicator-9b"
|
||||
"""The served model name exposed by the vLLM deployment."""
|
||||
|
||||
MAX_MODEL_LEN: int = 8192
|
||||
"""Maximum model context length configured for the deployment."""
|
||||
|
||||
MAX_NUM_SEQS: int = 8
|
||||
"""Maximum concurrent sequences for the vLLM deployment."""
|
||||
|
||||
GPU_MEMORY_UTILIZATION: float = 0.80
|
||||
"""Target GPU memory utilization fraction."""
|
||||
|
||||
VRAM_GATE_PERCENT: float = 5.0
|
||||
"""Maximum allowed VRAM increase over baseline (percentage)."""
|
||||
|
||||
|
||||
# --- Structured output verification ---
|
||||
|
||||
|
||||
def verify_structured_output(target: dict[str, Any]) -> bool:
|
||||
"""Verify that structured output works with the given deployment target.
|
||||
|
||||
Checks that the target deployment declares json_schema support in its
|
||||
capabilities and that the required configuration fields are present.
|
||||
|
||||
Args:
|
||||
target: Deployment target configuration dict containing at minimum:
|
||||
- capabilities: dict with json_schema boolean
|
||||
- served_model_name: str matching APPROVED_SERVED_NAME
|
||||
- vllm_version: str for version verification
|
||||
|
||||
Returns:
|
||||
True if strict schema output is expected to work, False otherwise.
|
||||
"""
|
||||
capabilities = target.get("capabilities", {})
|
||||
if not capabilities.get("json_schema", False):
|
||||
return False
|
||||
|
||||
# Verify model matches approved deployment
|
||||
served_name = target.get("served_model_name", "")
|
||||
if served_name and served_name != APPROVED_SERVED_NAME:
|
||||
return False
|
||||
|
||||
# Verify vLLM version compatibility
|
||||
vllm_version = target.get("vllm_version", "")
|
||||
if vllm_version and vllm_version != APPROVED_VLLM_VERSION:
|
||||
return False
|
||||
|
||||
# Verify the model is the approved one
|
||||
model = target.get("model", "")
|
||||
if model and model != APPROVED_MODEL:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# --- VRAM gate ---
|
||||
|
||||
|
||||
def check_vram_gate(peak_mb: float, baseline_mb: float) -> bool:
|
||||
"""Check whether peak VRAM usage is within the +5% gate of baseline.
|
||||
|
||||
The gate ensures that no deployment update exceeds the measured current
|
||||
9B deployment VRAM by more than 5 percent.
|
||||
|
||||
Args:
|
||||
peak_mb: Measured peak VRAM in megabytes during test.
|
||||
baseline_mb: Baseline VRAM measurement in megabytes.
|
||||
|
||||
Returns:
|
||||
True if peak is within acceptable range, False if it exceeds the gate.
|
||||
"""
|
||||
if baseline_mb <= 0:
|
||||
return False
|
||||
if peak_mb <= 0:
|
||||
return False
|
||||
|
||||
max_allowed_mb = baseline_mb * (1.0 + VRAM_GATE_PERCENT / 100.0)
|
||||
return peak_mb <= max_allowed_mb
|
||||
|
||||
|
||||
# --- Concurrency semaphore ---
|
||||
|
||||
|
||||
class ConcurrencySemaphore(BaseModel):
|
||||
"""Configuration for the adjudication concurrency semaphore.
|
||||
|
||||
Limits concurrent adjudication requests to protect vLLM from
|
||||
overload. Aligned with max-num-seqs and KV-cache behavior.
|
||||
"""
|
||||
|
||||
max_concurrent: int = Field(
|
||||
default=MAX_NUM_SEQS,
|
||||
gt=0,
|
||||
description="Maximum concurrent adjudication requests",
|
||||
)
|
||||
queue_timeout_seconds: float = Field(
|
||||
default=120.0,
|
||||
gt=0,
|
||||
description="Maximum time to wait for semaphore acquisition",
|
||||
)
|
||||
backpressure_threshold: int = Field(
|
||||
default=MAX_NUM_SEQS * 4,
|
||||
ge=0,
|
||||
description="Queue depth at which backpressure signals are emitted",
|
||||
)
|
||||
|
||||
def create_semaphore(self) -> asyncio.Semaphore:
|
||||
"""Create an asyncio.Semaphore with the configured max_concurrent."""
|
||||
return asyncio.Semaphore(self.max_concurrent)
|
||||
|
||||
|
||||
# --- Alert configuration ---
|
||||
|
||||
|
||||
class AlertConfig(BaseModel):
|
||||
"""Alert thresholds for adjudicator monitoring.
|
||||
|
||||
Defines queue-depth and availability thresholds that trigger alerts
|
||||
when the adjudicator is overloaded or unavailable.
|
||||
"""
|
||||
|
||||
queue_depth_warning: int = Field(
|
||||
default=16,
|
||||
ge=1,
|
||||
description="Queue depth that triggers a warning alert",
|
||||
)
|
||||
queue_depth_critical: int = Field(
|
||||
default=32,
|
||||
ge=1,
|
||||
description="Queue depth that triggers a critical alert",
|
||||
)
|
||||
availability_threshold_percent: float = Field(
|
||||
default=95.0,
|
||||
gt=0.0,
|
||||
le=100.0,
|
||||
description="Minimum availability percentage before alerting",
|
||||
)
|
||||
latency_p95_warning_ms: int = Field(
|
||||
default=5000,
|
||||
gt=0,
|
||||
description="p95 latency (ms) that triggers a warning",
|
||||
)
|
||||
latency_p95_critical_ms: int = Field(
|
||||
default=15000,
|
||||
gt=0,
|
||||
description="p95 latency (ms) that triggers a critical alert",
|
||||
)
|
||||
consecutive_failures_alert: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
description="Number of consecutive failures before alerting",
|
||||
)
|
||||
health_check_interval_seconds: float = Field(
|
||||
default=30.0,
|
||||
gt=0,
|
||||
description="Interval between health checks in seconds",
|
||||
)
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Focused adjudication prompt building for Intelligence Pipeline v3.
|
||||
|
||||
Builds adjudication packets containing only relevant chunks and candidates,
|
||||
uses strict JSON Schema with temperature zero, and enforces a bounded output
|
||||
budget (max 1536 tokens for decisions, not summaries).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.intelligence_pipeline_v3.adjudication.schemas import (
|
||||
AdjudicationCandidate,
|
||||
AdjudicationQuestion,
|
||||
ConflictDescription,
|
||||
EvidencePacket,
|
||||
QuestionCode,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.segmenter.models import DocumentChunk
|
||||
|
||||
# --- Constants ---
|
||||
|
||||
MAX_OUTPUT_TOKENS: int = 1536
|
||||
"""Maximum tokens for adjudication decisions output. Bounded to prevent
|
||||
long summaries — the adjudicator produces decisions, not narratives."""
|
||||
|
||||
TEMPERATURE: float = 0.0
|
||||
"""Temperature for adjudication requests. Zero for deterministic output."""
|
||||
|
||||
PROMPT_SCHEMA_VERSION: str = "1.0.0"
|
||||
"""Version of the adjudication prompt schema format."""
|
||||
|
||||
PROVIDER_LINEAGE_KEY: str = "adjudication_v3"
|
||||
"""Lineage identifier for adjudication prompts."""
|
||||
|
||||
|
||||
# --- Models ---
|
||||
|
||||
|
||||
class PromptMetadata(BaseModel):
|
||||
"""Metadata for the adjudication prompt including version and lineage.
|
||||
|
||||
Tracks prompt version, schema version, and provider lineage for
|
||||
reproducibility and auditing.
|
||||
"""
|
||||
|
||||
prompt_version: str = Field(
|
||||
default="1.0.0",
|
||||
description="Version of the prompt template",
|
||||
)
|
||||
schema_version: str = Field(
|
||||
default=PROMPT_SCHEMA_VERSION,
|
||||
description="Version of the JSON Schema format used",
|
||||
)
|
||||
provider_lineage: str = Field(
|
||||
default=PROVIDER_LINEAGE_KEY,
|
||||
description="Identifier for the prompt provider/pipeline stage",
|
||||
)
|
||||
max_output_tokens: int = Field(
|
||||
default=MAX_OUTPUT_TOKENS,
|
||||
description="Maximum output token budget for this prompt",
|
||||
)
|
||||
temperature: float = Field(
|
||||
default=TEMPERATURE,
|
||||
description="Generation temperature",
|
||||
)
|
||||
|
||||
|
||||
class AdjudicationPacket(BaseModel):
|
||||
"""Complete packet sent to the 9B adjudicator.
|
||||
|
||||
Contains only the information relevant to resolving the specific
|
||||
ambiguity — relevant chunks, candidates, conflicts, and questions.
|
||||
"""
|
||||
|
||||
document_id: str = Field(description="Source document identifier")
|
||||
document_type: str = Field(description="Type of document")
|
||||
relevant_chunks: list[DocumentChunk] = Field(
|
||||
description="Only chunks relevant to the adjudication questions",
|
||||
)
|
||||
candidates: list[AdjudicationCandidate] = Field(
|
||||
description="Candidates requiring adjudication",
|
||||
)
|
||||
conflicts: list[ConflictDescription] = Field(
|
||||
default_factory=list,
|
||||
description="Conflicts between candidates",
|
||||
)
|
||||
questions: list[AdjudicationQuestion] = Field(
|
||||
description="Specific questions the adjudicator must answer",
|
||||
)
|
||||
evidence: list[EvidencePacket] = Field(
|
||||
description="Evidence spans available for reference",
|
||||
)
|
||||
metadata: PromptMetadata = Field(
|
||||
default_factory=PromptMetadata,
|
||||
description="Prompt metadata for versioning and lineage",
|
||||
)
|
||||
|
||||
|
||||
# --- Output schema for strict JSON mode ---
|
||||
|
||||
|
||||
def get_decision_json_schema() -> dict[str, Any]:
|
||||
"""Return the strict JSON Schema for adjudication decisions.
|
||||
|
||||
Used as the `response_format.json_schema.schema` payload when
|
||||
calling the 9B model with strict structured output.
|
||||
"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decisions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision_id": {"type": "string"},
|
||||
"question_code": {
|
||||
"type": "string",
|
||||
"enum": [code.value for code in QuestionCode],
|
||||
},
|
||||
"verdict": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"accept",
|
||||
"reject",
|
||||
"merge",
|
||||
"split",
|
||||
"reattribute",
|
||||
],
|
||||
},
|
||||
"candidate_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
},
|
||||
"evidence_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
},
|
||||
"reasoning": {"type": "string"},
|
||||
"resolved_value": {"type": "object"},
|
||||
},
|
||||
"required": [
|
||||
"decision_id",
|
||||
"question_code",
|
||||
"verdict",
|
||||
"candidate_ids",
|
||||
"evidence_ids",
|
||||
"reasoning",
|
||||
],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["decisions"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
# --- Packet builder ---
|
||||
|
||||
|
||||
def _get_relevant_chunk_ids(
|
||||
candidates: list[AdjudicationCandidate],
|
||||
conflicts: list[ConflictDescription],
|
||||
questions: list[AdjudicationQuestion],
|
||||
) -> set[str]:
|
||||
"""Collect chunk IDs referenced by candidates, conflicts, and questions."""
|
||||
chunk_ids: set[str] = set()
|
||||
for candidate in candidates:
|
||||
chunk_ids.update(candidate.source_chunk_ids)
|
||||
return chunk_ids
|
||||
|
||||
|
||||
def _filter_relevant_chunks(
|
||||
document_chunks: list[DocumentChunk],
|
||||
relevant_chunk_ids: set[str],
|
||||
) -> list[DocumentChunk]:
|
||||
"""Filter document chunks to include only those referenced by candidates."""
|
||||
if not relevant_chunk_ids:
|
||||
# If no specific chunks referenced, include all (fallback for
|
||||
# cases where chunk IDs weren't specified in candidates)
|
||||
return document_chunks
|
||||
return [c for c in document_chunks if c.chunk_id in relevant_chunk_ids]
|
||||
|
||||
|
||||
def _collect_evidence_ids(
|
||||
candidates: list[AdjudicationCandidate],
|
||||
conflicts: list[ConflictDescription],
|
||||
) -> set[str]:
|
||||
"""Collect all evidence IDs referenced by candidates and conflicts."""
|
||||
evidence_ids: set[str] = set()
|
||||
for candidate in candidates:
|
||||
evidence_ids.update(candidate.evidence_ids)
|
||||
for conflict in conflicts:
|
||||
evidence_ids.update(conflict.evidence_ids)
|
||||
return evidence_ids
|
||||
|
||||
|
||||
def build_adjudication_packet(
|
||||
document_id: str,
|
||||
document_type: str,
|
||||
document_chunks: list[DocumentChunk],
|
||||
candidates: list[AdjudicationCandidate],
|
||||
conflicts: list[ConflictDescription],
|
||||
questions: list[AdjudicationQuestion],
|
||||
evidence: list[EvidencePacket],
|
||||
*,
|
||||
question_codes: list[str] | None = None,
|
||||
) -> AdjudicationPacket:
|
||||
"""Build an adjudication packet with only relevant chunks and evidence.
|
||||
|
||||
Filters document_chunks to include only those referenced by the
|
||||
candidates being adjudicated. Ensures the packet is focused and
|
||||
within the bounded context the adjudicator expects.
|
||||
|
||||
Args:
|
||||
document_id: Source document identifier.
|
||||
document_type: Type of document (article, filing, transcript, etc.).
|
||||
document_chunks: All available chunks for the document.
|
||||
candidates: Candidates requiring adjudication.
|
||||
conflicts: Conflicts between candidates.
|
||||
questions: Specific questions to resolve.
|
||||
evidence: Available evidence spans.
|
||||
question_codes: Optional filter to limit questions by code.
|
||||
|
||||
Returns:
|
||||
AdjudicationPacket with only relevant chunks included.
|
||||
"""
|
||||
# Filter questions by code if specified
|
||||
filtered_questions = questions
|
||||
if question_codes:
|
||||
code_set = set(question_codes)
|
||||
filtered_questions = [
|
||||
q for q in questions if q.question_code.value in code_set
|
||||
]
|
||||
|
||||
# Determine which chunks are relevant
|
||||
relevant_chunk_ids = _get_relevant_chunk_ids(
|
||||
candidates, conflicts, filtered_questions
|
||||
)
|
||||
relevant_chunks = _filter_relevant_chunks(document_chunks, relevant_chunk_ids)
|
||||
|
||||
# Filter evidence to only include those referenced by candidates/conflicts
|
||||
referenced_evidence_ids = _collect_evidence_ids(candidates, conflicts)
|
||||
if referenced_evidence_ids:
|
||||
relevant_evidence = [
|
||||
e for e in evidence if e.evidence_id in referenced_evidence_ids
|
||||
]
|
||||
else:
|
||||
# Include all evidence if none specifically referenced
|
||||
relevant_evidence = evidence
|
||||
|
||||
return AdjudicationPacket(
|
||||
document_id=document_id,
|
||||
document_type=document_type,
|
||||
relevant_chunks=relevant_chunks,
|
||||
candidates=candidates,
|
||||
conflicts=conflicts,
|
||||
questions=filtered_questions,
|
||||
evidence=relevant_evidence,
|
||||
metadata=PromptMetadata(),
|
||||
)
|
||||
|
||||
|
||||
def build_request_payload(packet: AdjudicationPacket) -> dict[str, Any]:
|
||||
"""Build the full inference request payload for the adjudicator.
|
||||
|
||||
Returns a dict suitable for passing to the inference gateway,
|
||||
including strict JSON Schema response format and temperature zero.
|
||||
"""
|
||||
system_prompt = (
|
||||
"You are a semantic adjudicator for financial document extraction. "
|
||||
"Resolve the ambiguities described in the questions using ONLY the "
|
||||
"provided evidence spans. Every decision MUST reference evidence_ids "
|
||||
"from the provided evidence. Do NOT estimate confidence, novelty, "
|
||||
"impact magnitude, or time horizon — those are computed by separate "
|
||||
"calibrated pipelines. Output valid JSON matching the required schema."
|
||||
)
|
||||
|
||||
user_content = packet.model_dump_json()
|
||||
|
||||
return {
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": packet.metadata.temperature,
|
||||
"max_tokens": packet.metadata.max_output_tokens,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "adjudication_response",
|
||||
"strict": True,
|
||||
"schema": get_decision_json_schema(),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Adjudication schemas for Intelligence Pipeline v3.
|
||||
|
||||
Defines Pydantic models for the adjudication layer:
|
||||
- AdjudicationCandidate: a proposed entity/fact/event requiring adjudication
|
||||
- ConflictDescription: describes a conflict between candidates
|
||||
- AdjudicationQuestion: a specific question the adjudicator must resolve
|
||||
- EvidencePacket: evidence spans provided to the adjudicator
|
||||
- AdjudicationDecision: the adjudicator's resolution (excludes confidence,
|
||||
novelty, impact, and horizon — those come from calibrated pipelines)
|
||||
|
||||
Every decision requires evidence_ids linking back to packet evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CandidateType(str, Enum):
|
||||
"""Type of candidate being adjudicated."""
|
||||
|
||||
ENTITY = "entity"
|
||||
EVENT = "event"
|
||||
FACT = "fact"
|
||||
RELATION = "relation"
|
||||
SENTIMENT = "sentiment"
|
||||
|
||||
|
||||
class AdjudicationCandidate(BaseModel):
|
||||
"""A proposed extraction candidate that requires adjudication.
|
||||
|
||||
Represents an entity, event, fact, relation, or sentiment that the
|
||||
fast-path could not resolve with sufficient confidence.
|
||||
"""
|
||||
|
||||
candidate_id: str = Field(description="Unique identifier for this candidate")
|
||||
candidate_type: CandidateType = Field(description="Type of candidate")
|
||||
label: str = Field(description="Human-readable label or description")
|
||||
source_chunk_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Chunk IDs where this candidate was found",
|
||||
)
|
||||
evidence_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Evidence span IDs supporting this candidate",
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Additional type-specific metadata",
|
||||
)
|
||||
score: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Specialist extraction score (0.0-1.0)",
|
||||
)
|
||||
|
||||
|
||||
class ConflictType(str, Enum):
|
||||
"""Type of conflict between candidates."""
|
||||
|
||||
CONTRADICTORY_VALUES = "contradictory_values"
|
||||
AMBIGUOUS_IDENTITY = "ambiguous_identity"
|
||||
OPPOSING_SENTIMENT = "opposing_sentiment"
|
||||
OVERLAPPING_EVENTS = "overlapping_events"
|
||||
CAUSAL_AMBIGUITY = "causal_ambiguity"
|
||||
|
||||
|
||||
class ConflictDescription(BaseModel):
|
||||
"""Describes a conflict between two or more candidates.
|
||||
|
||||
Used to inform the adjudicator about what needs resolution.
|
||||
"""
|
||||
|
||||
conflict_id: str = Field(description="Unique identifier for this conflict")
|
||||
conflict_type: ConflictType = Field(description="Type of conflict")
|
||||
candidate_ids: list[str] = Field(
|
||||
min_length=2,
|
||||
description="IDs of conflicting candidates",
|
||||
)
|
||||
description: str = Field(description="Human-readable conflict description")
|
||||
evidence_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Evidence IDs relevant to this conflict",
|
||||
)
|
||||
|
||||
|
||||
class QuestionCode(str, Enum):
|
||||
"""Codes representing specific adjudication questions."""
|
||||
|
||||
RESOLVE_ENTITY_IDENTITY = "RESOLVE_ENTITY_IDENTITY"
|
||||
RESOLVE_EVENT_TYPE = "RESOLVE_EVENT_TYPE"
|
||||
RESOLVE_CAUSAL_DIRECTION = "RESOLVE_CAUSAL_DIRECTION"
|
||||
RESOLVE_NUMERIC_CONFLICT = "RESOLVE_NUMERIC_CONFLICT"
|
||||
RESOLVE_SENTIMENT_DIRECTION = "RESOLVE_SENTIMENT_DIRECTION"
|
||||
RESOLVE_TEMPORAL_ORDERING = "RESOLVE_TEMPORAL_ORDERING"
|
||||
RESOLVE_COMPANY_ATTRIBUTION = "RESOLVE_COMPANY_ATTRIBUTION"
|
||||
CONFIRM_CROSS_CHUNK_RELATION = "CONFIRM_CROSS_CHUNK_RELATION"
|
||||
|
||||
|
||||
class AdjudicationQuestion(BaseModel):
|
||||
"""A specific question the adjudicator must answer.
|
||||
|
||||
Each question references candidates and conflicts that need resolution.
|
||||
"""
|
||||
|
||||
question_code: QuestionCode = Field(description="Structured question code")
|
||||
description: str = Field(description="Natural language question for the adjudicator")
|
||||
candidate_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Candidate IDs this question applies to",
|
||||
)
|
||||
conflict_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Conflict IDs this question resolves",
|
||||
)
|
||||
|
||||
|
||||
class EvidencePacket(BaseModel):
|
||||
"""Evidence spans provided to the adjudicator.
|
||||
|
||||
Contains the exact text and location of evidence the adjudicator
|
||||
can reference in its decisions.
|
||||
"""
|
||||
|
||||
evidence_id: str = Field(description="Unique identifier for this evidence span")
|
||||
chunk_id: str = Field(description="Source chunk identifier")
|
||||
start_char: int = Field(ge=0, description="Start character offset within chunk")
|
||||
end_char: int = Field(gt=0, description="End character offset within chunk")
|
||||
text: str = Field(min_length=1, description="Evidence text content")
|
||||
source_document_id: str = Field(description="Parent document identifier")
|
||||
|
||||
|
||||
class DecisionVerdict(str, Enum):
|
||||
"""Possible verdicts for an adjudication decision."""
|
||||
|
||||
ACCEPT = "accept"
|
||||
REJECT = "reject"
|
||||
MERGE = "merge"
|
||||
SPLIT = "split"
|
||||
REATTRIBUTE = "reattribute"
|
||||
|
||||
|
||||
class AdjudicationDecision(BaseModel):
|
||||
"""The adjudicator's resolution for one or more candidates.
|
||||
|
||||
IMPORTANT: This model intentionally EXCLUDES:
|
||||
- authoritative confidence (comes from calibration pipeline)
|
||||
- novelty (comes from retrieval-based novelty stage)
|
||||
- impact (comes from stock-specific impact model)
|
||||
- horizon (comes from impact model)
|
||||
|
||||
The adjudicator resolves candidate identity, relationships, event
|
||||
interpretation, and supported qualitative direction only. Every
|
||||
decision MUST reference evidence_ids from the provided packet.
|
||||
"""
|
||||
|
||||
decision_id: str = Field(description="Unique identifier for this decision")
|
||||
question_code: QuestionCode = Field(description="Which question this resolves")
|
||||
verdict: DecisionVerdict = Field(description="The adjudication verdict")
|
||||
candidate_ids: list[str] = Field(
|
||||
min_length=1,
|
||||
description="Candidate IDs this decision applies to",
|
||||
)
|
||||
evidence_ids: list[str] = Field(
|
||||
min_length=1,
|
||||
description="Evidence IDs supporting this decision (required, non-empty)",
|
||||
)
|
||||
reasoning: str = Field(
|
||||
description="Brief reasoning for the decision",
|
||||
)
|
||||
resolved_value: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="The resolved value(s) if applicable",
|
||||
)
|
||||
@@ -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