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,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"
|
||||
Reference in New Issue
Block a user