"""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"