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.
149 lines
4.8 KiB
Python
149 lines
4.8 KiB
Python
"""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"
|