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,136 @@
"""Sentiment aggregation logic.
Aggregates per-text sentiment scores into a single company result,
with mixed sentiment detection when evidence groups disagree.
Mixed sentiment is NOT an unconstrained fourth softmax label — it is
computed from disagreement between evidence texts (some positive,
some negative with margin > threshold).
"""
from __future__ import annotations
from services.intelligence_pipeline_v3.sentiment.models import (
CompanySentimentResult,
TextSentiment,
)
# Disagreement threshold: both positive and negative probabilities
# must be >= this value across evidence texts to signal disagreement.
# A text with positive_prob >= threshold AND another text with
# negative_prob >= threshold indicates conflicting evidence.
MIXED_DISAGREEMENT_THRESHOLD = 0.3
def aggregate_evidence_sentiments(
company_id: str,
per_text_scores: list[TextSentiment],
model_version: str,
calibration_version: str = "uncalibrated",
) -> CompanySentimentResult:
"""Aggregate per-text sentiment scores into a company-level result.
Computes weighted average probabilities and detects mixed sentiment
from evidence-group disagreement. Mixed is triggered when at least
one text has positive_prob >= threshold AND at least one other text
has negative_prob >= threshold.
Parameters
----------
company_id
Resolved company identifier.
per_text_scores
List of TextSentiment objects, one per evidence text.
model_version
Sentiment model version string for lineage.
calibration_version
Calibration artifact version.
Returns
-------
CompanySentimentResult
Aggregated result with label, probabilities, per-text scores,
is_mixed flag, and evidence IDs.
"""
if not per_text_scores:
return CompanySentimentResult(
company_id=company_id,
label="neutral",
positive_prob=0.0,
negative_prob=0.0,
neutral_prob=1.0,
evidence_ids=[],
is_mixed=False,
per_text_scores=[],
model_version=model_version,
calibration_version=calibration_version,
)
# Detect mixed sentiment from disagreement
is_mixed = _detect_mixed_from_disagreement(per_text_scores)
# Compute average probabilities across texts
n = len(per_text_scores)
avg_pos = sum(s.positive_prob for s in per_text_scores) / n
avg_neg = sum(s.negative_prob for s in per_text_scores) / n
avg_neu = sum(s.neutral_prob for s in per_text_scores) / n
# Normalize to ensure probabilities sum to 1.0
total = avg_pos + avg_neg + avg_neu
if total > 0:
avg_pos /= total
avg_neg /= total
avg_neu /= total
else:
avg_pos = 0.0
avg_neg = 0.0
avg_neu = 1.0
# Determine label
if is_mixed:
label = "mixed"
else:
label = _argmax_label(avg_pos, avg_neg, avg_neu)
evidence_ids = [s.evidence_id for s in per_text_scores]
return CompanySentimentResult(
company_id=company_id,
label=label,
positive_prob=round(avg_pos, 6),
negative_prob=round(avg_neg, 6),
neutral_prob=round(avg_neu, 6),
evidence_ids=evidence_ids,
is_mixed=is_mixed,
per_text_scores=per_text_scores,
model_version=model_version,
calibration_version=calibration_version,
)
def _detect_mixed_from_disagreement(per_text_scores: list[TextSentiment]) -> bool:
"""Detect mixed sentiment from evidence-group disagreement.
Returns True when at least one text has positive_prob >= threshold
AND at least one (different) text has negative_prob >= threshold.
This indicates conflicting evidence directions.
A single text cannot trigger mixed on its own (we need disagreement
between at least 2 texts).
"""
if len(per_text_scores) < 2:
return False
max_pos = max(s.positive_prob for s in per_text_scores)
max_neg = max(s.negative_prob for s in per_text_scores)
return max_pos >= MIXED_DISAGREEMENT_THRESHOLD and max_neg >= MIXED_DISAGREEMENT_THRESHOLD
def _argmax_label(pos: float, neg: float, neu: float) -> str:
"""Return the label with the highest probability."""
if pos >= neg and pos >= neu:
return "positive"
elif neg >= pos and neg >= neu:
return "negative"
return "neutral"