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.
137 lines
4.3 KiB
Python
137 lines
4.3 KiB
Python
"""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"
|