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,148 @@
|
||||
"""Mixed sentiment detection from evidence-group disagreement.
|
||||
|
||||
When evidence groups for the same company disagree (some positive,
|
||||
some negative), the resulting label is "mixed" rather than an
|
||||
unconstrained model output. This follows the design requirement that
|
||||
mixed sentiment comes from conflicting supported evidence, not from
|
||||
a fourth softmax label.
|
||||
|
||||
NOTE: This module provides the legacy compute_mixed_sentiment function
|
||||
for backward compatibility. New code should prefer
|
||||
aggregation.aggregate_evidence_sentiments which accepts TextSentiment
|
||||
objects directly.
|
||||
"""
|
||||
|
||||
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 groups to signal disagreement
|
||||
DISAGREEMENT_THRESHOLD = 0.3
|
||||
|
||||
|
||||
def compute_mixed_sentiment(
|
||||
company_id: str,
|
||||
group_results: list[tuple[float, float, float]],
|
||||
evidence_ids: list[str],
|
||||
model_version: str,
|
||||
calibration_version: str = "uncalibrated",
|
||||
) -> CompanySentimentResult:
|
||||
"""Compute company sentiment from multiple evidence group results.
|
||||
|
||||
Applies disagreement detection: if evidence groups for the same
|
||||
company show both positive and negative signals above the threshold,
|
||||
the label is "mixed".
|
||||
|
||||
Parameters
|
||||
----------
|
||||
company_id
|
||||
Resolved company identifier.
|
||||
group_results
|
||||
List of (positive_prob, negative_prob, neutral_prob) tuples,
|
||||
one per evidence group for this company.
|
||||
evidence_ids
|
||||
All evidence span IDs contributing to this result.
|
||||
model_version
|
||||
Sentiment model version string.
|
||||
calibration_version
|
||||
Calibration artifact version.
|
||||
|
||||
Returns
|
||||
-------
|
||||
CompanySentimentResult
|
||||
Aggregated sentiment result with disagreement-based mixed detection.
|
||||
"""
|
||||
if not group_results:
|
||||
# No evidence — neutral by default
|
||||
return CompanySentimentResult(
|
||||
company_id=company_id,
|
||||
label="neutral",
|
||||
positive_prob=0.0,
|
||||
negative_prob=0.0,
|
||||
neutral_prob=1.0,
|
||||
evidence_ids=evidence_ids,
|
||||
is_mixed=False,
|
||||
per_text_scores=[],
|
||||
model_version=model_version,
|
||||
calibration_version=calibration_version,
|
||||
)
|
||||
|
||||
# Check for disagreement across groups
|
||||
is_mixed = _detect_disagreement(group_results)
|
||||
|
||||
# Compute weighted average of group probabilities
|
||||
n = len(group_results)
|
||||
avg_pos = sum(r[0] for r in group_results) / n
|
||||
avg_neg = sum(r[1] for r in group_results) / n
|
||||
avg_neu = sum(r[2] for r in group_results) / 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)
|
||||
|
||||
# Build per-text scores from tuples for provenance
|
||||
per_text_scores: list[TextSentiment] = []
|
||||
for i, (pos, neg, neu) in enumerate(group_results):
|
||||
eid = evidence_ids[i] if i < len(evidence_ids) else f"unknown_{i}"
|
||||
per_text_scores.append(
|
||||
TextSentiment(
|
||||
evidence_id=eid,
|
||||
positive_prob=pos,
|
||||
negative_prob=neg,
|
||||
neutral_prob=neu,
|
||||
)
|
||||
)
|
||||
|
||||
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_disagreement(group_results: list[tuple[float, float, float]]) -> bool:
|
||||
"""Detect if evidence groups disagree on sentiment direction.
|
||||
|
||||
Disagreement is detected when across all groups, the maximum
|
||||
positive probability is >= threshold AND the maximum negative
|
||||
probability is >= threshold. This means some evidence strongly
|
||||
suggests positive while other evidence strongly suggests negative.
|
||||
"""
|
||||
if len(group_results) < 2:
|
||||
return False
|
||||
|
||||
max_pos = max(r[0] for r in group_results)
|
||||
max_neg = max(r[1] for r in group_results)
|
||||
|
||||
return max_pos >= DISAGREEMENT_THRESHOLD and max_neg >= 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"
|
||||
else:
|
||||
return "neutral"
|
||||
Reference in New Issue
Block a user