"""Tests for company-specific sentiment analysis. Validates: - Evidence grouping by company (including relations) - FinBERT adapter returns valid probability distributions - Mixed sentiment detection from evidence-group disagreement - Non-mixed when evidence agrees - Probability distributions sum to ~1.0 - Calibration passthrough - SentimentScorer integration - TextSentiment per-text scoring - Aggregation module """ from __future__ import annotations import pytest from services.intelligence_pipeline_v3.sentiment.aggregation import ( MIXED_DISAGREEMENT_THRESHOLD, aggregate_evidence_sentiments, ) from services.intelligence_pipeline_v3.sentiment.calibrator import SentimentCalibrator from services.intelligence_pipeline_v3.sentiment.evidence_groups import build_evidence_groups from services.intelligence_pipeline_v3.sentiment.finbert_adapter import FinBERTAdapter from services.intelligence_pipeline_v3.sentiment.mixed_sentiment import ( DISAGREEMENT_THRESHOLD, compute_mixed_sentiment, ) from services.intelligence_pipeline_v3.sentiment.models import ( CompanySentimentResult, EvidenceGroup, SentimentBatchResult, TextSentiment, ) from services.intelligence_pipeline_v3.sentiment.sentiment_scorer import ( SentimentScorer, ) class TestEvidenceGroups: """Test evidence grouping by company.""" def test_single_company_single_evidence(self): entities = [{"company_id": "AAPL", "evidence_id": "ev1"}] evidence_spans = {"ev1": "Apple reported strong earnings."} groups = build_evidence_groups(entities, evidence_spans) assert "AAPL" in groups assert groups["AAPL"].company_id == "AAPL" assert groups["AAPL"].evidence_ids == ["ev1"] assert groups["AAPL"].texts == ["Apple reported strong earnings."] def test_single_company_multiple_evidence(self): entities = [ {"company_id": "AAPL", "evidence_id": "ev1"}, {"company_id": "AAPL", "evidence_id": "ev2"}, ] evidence_spans = { "ev1": "Apple beat expectations.", "ev2": "iPhone sales surged.", } groups = build_evidence_groups(entities, evidence_spans) assert "AAPL" in groups assert len(groups["AAPL"].evidence_ids) == 2 assert "ev1" in groups["AAPL"].evidence_ids assert "ev2" in groups["AAPL"].evidence_ids def test_multiple_companies(self): entities = [ {"company_id": "AAPL", "evidence_id": "ev1"}, {"company_id": "GOOGL", "evidence_id": "ev2"}, ] evidence_spans = { "ev1": "Apple gained market share.", "ev2": "Google's ad revenue declined.", } groups = build_evidence_groups(entities, evidence_spans) assert len(groups) == 2 assert "AAPL" in groups assert "GOOGL" in groups def test_shared_evidence_across_companies(self): """A span mentioning multiple companies should appear in both groups.""" entities = [ {"company_id": "AAPL", "evidence_id": "ev1"}, {"company_id": "GOOGL", "evidence_id": "ev1"}, ] evidence_spans = {"ev1": "Apple and Google both reported growth."} groups = build_evidence_groups(entities, evidence_spans) assert "AAPL" in groups assert "GOOGL" in groups assert "ev1" in groups["AAPL"].evidence_ids assert "ev1" in groups["GOOGL"].evidence_ids def test_entities_without_company_id_skipped(self): entities = [ {"company_id": None, "evidence_id": "ev1"}, {"company_id": "AAPL", "evidence_id": "ev2"}, ] evidence_spans = { "ev1": "Some generic text.", "ev2": "Apple expanded.", } groups = build_evidence_groups(entities, evidence_spans) assert len(groups) == 1 assert "AAPL" in groups def test_missing_evidence_span_excluded(self): """Entity referencing non-existent evidence span is excluded.""" entities = [{"company_id": "AAPL", "evidence_id": "ev_missing"}] evidence_spans = {"ev1": "Some text."} groups = build_evidence_groups(entities, evidence_spans) assert len(groups) == 0 def test_empty_inputs(self): groups = build_evidence_groups([], {}) assert len(groups) == 0 def test_deduplicates_evidence_ids_per_company(self): """Same evidence_id referenced twice for same company shouldn't duplicate.""" entities = [ {"company_id": "AAPL", "evidence_id": "ev1"}, {"company_id": "AAPL", "evidence_id": "ev1"}, ] evidence_spans = {"ev1": "Apple news."} groups = build_evidence_groups(entities, evidence_spans) assert groups["AAPL"].evidence_ids == ["ev1"] assert len(groups["AAPL"].texts) == 1 def test_relations_add_evidence_to_company(self): """Relations parameter links additional evidence to companies.""" entities = [{"company_id": "AAPL", "evidence_id": "ev1"}] relations = [ {"company_id": "AAPL", "evidence_id": "ev2", "relation_type": "directly_affects"}, ] evidence_spans = { "ev1": "Apple reported earnings.", "ev2": "iPhone demand surged globally.", } groups = build_evidence_groups(entities, evidence_spans, relations=relations) assert "AAPL" in groups assert "ev1" in groups["AAPL"].evidence_ids assert "ev2" in groups["AAPL"].evidence_ids assert len(groups["AAPL"].evidence_ids) == 2 def test_relations_create_new_company_group(self): """Relations can create groups for companies not in entities.""" entities = [{"company_id": "AAPL", "evidence_id": "ev1"}] relations = [ {"company_id": "GOOGL", "evidence_id": "ev2", "relation_type": "inferred_exposure"}, ] evidence_spans = { "ev1": "Apple expanded.", "ev2": "Google was affected.", } groups = build_evidence_groups(entities, evidence_spans, relations=relations) assert "AAPL" in groups assert "GOOGL" in groups assert groups["GOOGL"].evidence_ids == ["ev2"] def test_relations_none_skipped(self): """None relations parameter is handled gracefully.""" entities = [{"company_id": "AAPL", "evidence_id": "ev1"}] evidence_spans = {"ev1": "Apple news."} groups = build_evidence_groups(entities, evidence_spans, relations=None) assert "AAPL" in groups assert groups["AAPL"].evidence_ids == ["ev1"] class TestFinBERTAdapter: """Test FinBERT adapter returns valid probability distributions.""" def setup_method(self): self.adapter = FinBERTAdapter(test_mode=True) def test_model_version_exposed(self): assert self.adapter.model_version == "ProsusAI/finbert@v1.0" assert self.adapter.model_name == "ProsusAI/finbert" def test_empty_input(self): result = self.adapter.classify([]) assert result == [] def test_positive_text(self): result = self.adapter.classify(["Company reported strong profit growth."]) assert len(result) == 1 pos, neg, neu = result[0] assert pos > neg assert pos > neu assert abs(pos + neg + neu - 1.0) < 1e-6 def test_negative_text(self): result = self.adapter.classify(["Revenue declined sharply amid weak demand."]) assert len(result) == 1 pos, neg, neu = result[0] assert neg > pos assert neg > neu def test_neutral_text(self): result = self.adapter.classify(["The company held its annual general meeting today."]) assert len(result) == 1 pos, neg, neu = result[0] assert neu > pos assert neu > neg def test_mixed_keywords_text(self): result = self.adapter.classify(["Revenue growth was strong but the decline in margins hurt"]) assert len(result) == 1 pos, neg, neu = result[0] assert pos >= 0.3 assert neg >= 0.3 def test_batch_classification(self): texts = [ "Earnings beat expectations.", "Stock plunged on weak results.", "Board met to discuss routine matters.", ] results = self.adapter.classify(texts) assert len(results) == 3 assert results[0][0] > results[0][1] assert results[1][1] > results[1][0] assert results[2][2] > results[2][0] assert results[2][2] > results[2][1] def test_probabilities_sum_to_one(self): texts = ["Strong growth.", "Major loss.", "Neutral report."] results = self.adapter.classify(texts) for pos, neg, neu in results: assert abs(pos + neg + neu - 1.0) < 1e-6 assert pos >= 0.0 assert neg >= 0.0 assert neu >= 0.0 class TestAggregation: """Test aggregate_evidence_sentiments from the aggregation module.""" def test_single_positive_text(self): scores = [TextSentiment(evidence_id="ev1", positive_prob=0.8, negative_prob=0.1, neutral_prob=0.1)] result = aggregate_evidence_sentiments("AAPL", scores, "test_model") assert result.label == "positive" assert result.company_id == "AAPL" assert result.is_mixed is False assert len(result.per_text_scores) == 1 assert result.per_text_scores[0].evidence_id == "ev1" def test_single_negative_text(self): scores = [TextSentiment(evidence_id="ev1", positive_prob=0.1, negative_prob=0.8, neutral_prob=0.1)] result = aggregate_evidence_sentiments("GOOGL", scores, "test_model") assert result.label == "negative" assert result.is_mixed is False def test_mixed_from_disagreeing_texts(self): """Two texts: one positive, one negative -> mixed.""" scores = [ TextSentiment(evidence_id="ev1", positive_prob=0.75, negative_prob=0.10, neutral_prob=0.15), TextSentiment(evidence_id="ev2", positive_prob=0.10, negative_prob=0.75, neutral_prob=0.15), ] result = aggregate_evidence_sentiments("TSLA", scores, "test_model") assert result.label == "mixed" assert result.is_mixed is True assert result.positive_prob > 0.3 assert result.negative_prob > 0.3 def test_not_mixed_when_agreement(self): """Two positive texts should not trigger mixed.""" scores = [ TextSentiment(evidence_id="ev1", positive_prob=0.70, negative_prob=0.15, neutral_prob=0.15), TextSentiment(evidence_id="ev2", positive_prob=0.65, negative_prob=0.20, neutral_prob=0.15), ] result = aggregate_evidence_sentiments("AAPL", scores, "test_model") assert result.label == "positive" assert result.is_mixed is False def test_empty_scores_neutral(self): result = aggregate_evidence_sentiments("X", [], "test_model") assert result.label == "neutral" assert result.neutral_prob == 1.0 assert result.is_mixed is False def test_probabilities_sum_to_one(self): scores = [ TextSentiment(evidence_id="ev1", positive_prob=0.60, negative_prob=0.25, neutral_prob=0.15), TextSentiment(evidence_id="ev2", positive_prob=0.30, negative_prob=0.50, neutral_prob=0.20), TextSentiment(evidence_id="ev3", positive_prob=0.10, negative_prob=0.10, neutral_prob=0.80), ] result = aggregate_evidence_sentiments("X", scores, "test_model") total = result.positive_prob + result.negative_prob + result.neutral_prob assert abs(total - 1.0) < 1e-4 def test_evidence_ids_preserved(self): scores = [ TextSentiment(evidence_id="ev1", positive_prob=0.5, negative_prob=0.3, neutral_prob=0.2), TextSentiment(evidence_id="ev2", positive_prob=0.4, negative_prob=0.4, neutral_prob=0.2), ] result = aggregate_evidence_sentiments("AAPL", scores, "model_v1") assert result.evidence_ids == ["ev1", "ev2"] assert result.model_version == "model_v1" assert result.calibration_version == "uncalibrated" def test_disagreement_threshold_boundary(self): """Both max pos and max neg must be >= threshold for mixed.""" threshold = MIXED_DISAGREEMENT_THRESHOLD scores = [ TextSentiment(evidence_id="ev1", positive_prob=threshold, negative_prob=0.05, neutral_prob=1.0 - threshold - 0.05), TextSentiment(evidence_id="ev2", positive_prob=0.05, negative_prob=threshold, neutral_prob=1.0 - threshold - 0.05), ] result = aggregate_evidence_sentiments("X", scores, "test") assert result.is_mixed is True assert result.label == "mixed" def test_below_disagreement_threshold_not_mixed(self): """Below threshold should not be mixed.""" threshold = MIXED_DISAGREEMENT_THRESHOLD scores = [ TextSentiment(evidence_id="ev1", positive_prob=threshold - 0.01, negative_prob=0.05, neutral_prob=1.0 - (threshold - 0.01) - 0.05), TextSentiment(evidence_id="ev2", positive_prob=0.05, negative_prob=threshold - 0.01, neutral_prob=1.0 - (threshold - 0.01) - 0.05), ] result = aggregate_evidence_sentiments("X", scores, "test") assert result.is_mixed is False class TestMixedSentiment: """Test mixed sentiment detection from evidence-group disagreement (legacy API).""" def test_single_positive_group(self): result = compute_mixed_sentiment( company_id="AAPL", group_results=[(0.75, 0.10, 0.15)], evidence_ids=["ev1"], model_version="ProsusAI/finbert@v1.0", ) assert result.label == "positive" assert result.company_id == "AAPL" assert result.positive_prob > result.negative_prob assert result.is_mixed is False def test_single_negative_group(self): result = compute_mixed_sentiment( company_id="GOOGL", group_results=[(0.10, 0.75, 0.15)], evidence_ids=["ev1"], model_version="ProsusAI/finbert@v1.0", ) assert result.label == "negative" assert result.negative_prob > result.positive_prob def test_single_neutral_group(self): result = compute_mixed_sentiment( company_id="MSFT", group_results=[(0.15, 0.15, 0.70)], evidence_ids=["ev1"], model_version="ProsusAI/finbert@v1.0", ) assert result.label == "neutral" assert result.neutral_prob > result.positive_prob assert result.neutral_prob > result.negative_prob def test_mixed_from_disagreeing_groups(self): """Two groups: one positive, one negative -> mixed.""" group_results = [ (0.75, 0.10, 0.15), (0.10, 0.75, 0.15), ] result = compute_mixed_sentiment( company_id="TSLA", group_results=group_results, evidence_ids=["ev1", "ev2"], model_version="ProsusAI/finbert@v1.0", ) assert result.label == "mixed" assert result.is_mixed is True assert result.positive_prob > 0.3 assert result.negative_prob > 0.3 def test_no_mixed_when_agreement(self): """Two positive groups should not trigger mixed.""" group_results = [ (0.70, 0.15, 0.15), (0.65, 0.20, 0.15), ] result = compute_mixed_sentiment( company_id="AAPL", group_results=group_results, evidence_ids=["ev1", "ev2"], model_version="ProsusAI/finbert@v1.0", ) assert result.label == "positive" assert result.is_mixed is False def test_disagreement_threshold_boundary(self): """Both max pos and max neg must be >= threshold for mixed.""" group_results = [ (DISAGREEMENT_THRESHOLD, 0.05, 0.65), (0.05, DISAGREEMENT_THRESHOLD, 0.65), ] result = compute_mixed_sentiment( company_id="X", group_results=group_results, evidence_ids=["ev1", "ev2"], model_version="test", ) assert result.label == "mixed" assert result.is_mixed is True def test_below_disagreement_threshold(self): """Below threshold should not be mixed.""" group_results = [ (DISAGREEMENT_THRESHOLD - 0.01, 0.05, 0.66), (0.05, DISAGREEMENT_THRESHOLD - 0.01, 0.66), ] result = compute_mixed_sentiment( company_id="X", group_results=group_results, evidence_ids=["ev1", "ev2"], model_version="test", ) assert result.label == "neutral" assert result.is_mixed is False def test_empty_group_results(self): result = compute_mixed_sentiment( company_id="AAPL", group_results=[], evidence_ids=[], model_version="test", ) assert result.label == "neutral" assert result.neutral_prob == 1.0 assert result.is_mixed is False def test_probabilities_sum_to_one(self): group_results = [ (0.60, 0.25, 0.15), (0.30, 0.50, 0.20), (0.10, 0.10, 0.80), ] result = compute_mixed_sentiment( company_id="X", group_results=group_results, evidence_ids=["a", "b", "c"], model_version="test", ) total = result.positive_prob + result.negative_prob + result.neutral_prob assert abs(total - 1.0) < 1e-4 def test_per_text_scores_preserved(self): """Legacy API now populates per_text_scores for provenance.""" group_results = [(0.75, 0.10, 0.15), (0.20, 0.60, 0.20)] result = compute_mixed_sentiment( company_id="X", group_results=group_results, evidence_ids=["ev1", "ev2"], model_version="test", ) assert len(result.per_text_scores) == 2 assert result.per_text_scores[0].evidence_id == "ev1" assert result.per_text_scores[1].evidence_id == "ev2" class TestSentimentScorer: """Test SentimentScorer integration (end-to-end scoring).""" @pytest.mark.asyncio async def test_score_positive_evidence(self): scorer = SentimentScorer() group = EvidenceGroup( company_id="AAPL", evidence_ids=["ev1"], texts=["Apple reported strong profit growth."], ) result = await scorer.score(group) assert result.company_id == "AAPL" assert result.label == "positive" assert result.positive_prob > result.negative_prob assert len(result.per_text_scores) == 1 assert result.per_text_scores[0].evidence_id == "ev1" assert result.model_version == "ProsusAI/finbert@v1.0" assert result.calibration_version == "uncalibrated" @pytest.mark.asyncio async def test_score_negative_evidence(self): scorer = SentimentScorer() group = EvidenceGroup( company_id="GOOGL", evidence_ids=["ev1"], texts=["Google experienced a sharp decline in revenue."], ) result = await scorer.score(group) assert result.label == "negative" assert result.negative_prob > result.positive_prob @pytest.mark.asyncio async def test_score_mixed_evidence(self): """Multiple texts with opposing sentiment triggers mixed.""" scorer = SentimentScorer() group = EvidenceGroup( company_id="TSLA", evidence_ids=["ev1", "ev2"], texts=[ "Tesla revenue growth exceeded expectations.", "Tesla faces major decline in margins and weak demand.", ], ) result = await scorer.score(group) assert result.label == "mixed" assert result.is_mixed is True assert len(result.per_text_scores) == 2 @pytest.mark.asyncio async def test_score_batch(self): scorer = SentimentScorer() groups = { "AAPL": EvidenceGroup( company_id="AAPL", evidence_ids=["ev1"], texts=["Apple beat earnings estimates."], ), "GOOGL": EvidenceGroup( company_id="GOOGL", evidence_ids=["ev2"], texts=["Google saw weak ad revenue and decline in users"], ), } batch_result = await scorer.score_batch(groups) assert len(batch_result.results) == 2 assert batch_result.model_version == "ProsusAI/finbert@v1.0" assert batch_result.processing_time_ms >= 0 labels = {r.company_id: r.label for r in batch_result.results} assert labels["AAPL"] == "positive" assert labels["GOOGL"] == "negative" @pytest.mark.asyncio async def test_score_probability_distributions_sum_to_one(self): scorer = SentimentScorer() group = EvidenceGroup( company_id="X", evidence_ids=["ev1", "ev2", "ev3"], texts=["Profit rose.", "Demand weakened.", "Board meeting held."], ) result = await scorer.score(group) # Overall probabilities sum to 1 total = result.positive_prob + result.negative_prob + result.neutral_prob assert abs(total - 1.0) < 1e-4 # Per-text probabilities also sum to 1 for ts in result.per_text_scores: text_total = ts.positive_prob + ts.negative_prob + ts.neutral_prob assert abs(text_total - 1.0) < 1e-6 @pytest.mark.asyncio async def test_custom_model_protocol(self): """SentimentScorer works with any model implementing SentimentModel.""" class MockModel: @property def model_version(self) -> str: return "mock@v1" def classify(self, texts: list[str]) -> list[tuple[float, float, float]]: return [(0.5, 0.3, 0.2)] * len(texts) scorer = SentimentScorer(model=MockModel()) group = EvidenceGroup( company_id="X", evidence_ids=["ev1"], texts=["Any text."], ) result = await scorer.score(group) assert result.model_version == "mock@v1" assert result.positive_prob > 0.4 class TestMultiCompanyOpposingSentiments: """Test that opposing sentiments for different companies produce separate records.""" def test_separate_records_for_opposing_companies(self): """Article with positive Apple news and negative Google news.""" entities = [ {"company_id": "AAPL", "evidence_id": "ev1"}, {"company_id": "GOOGL", "evidence_id": "ev2"}, ] evidence_spans = { "ev1": "Apple reported record profit growth.", "ev2": "Google faces a major decline in ad revenue.", } groups = build_evidence_groups(entities, evidence_spans) adapter = FinBERTAdapter(test_mode=True) results: list[CompanySentimentResult] = [] for company_id, group in groups.items(): probs = adapter.classify(group.texts) result = compute_mixed_sentiment( company_id=company_id, group_results=probs, evidence_ids=group.evidence_ids, model_version=adapter.model_version, ) results.append(result) assert len(results) == 2 company_labels = {r.company_id: r.label for r in results} assert company_labels["AAPL"] == "positive" assert company_labels["GOOGL"] == "negative" class TestCalibrator: """Test sentiment probability calibration passthrough and fitting.""" def test_uncalibrated_passthrough(self): """Unfitted calibrator should pass through raw probabilities.""" cal = SentimentCalibrator(method="isotonic") assert not cal.is_fitted assert cal.calibration_version == "uncalibrated" raw = [0.6, 0.3, 0.1] result = cal.calibrate(raw) assert result == raw def test_isotonic_fit_and_calibrate(self): """Fitted isotonic calibrator should transform probabilities.""" cal = SentimentCalibrator(method="isotonic") raw_probs = [ [0.8, 0.1, 0.1], [0.7, 0.2, 0.1], [0.1, 0.8, 0.1], [0.2, 0.7, 0.1], [0.1, 0.1, 0.8], [0.1, 0.2, 0.7], [0.9, 0.05, 0.05], [0.05, 0.9, 0.05], [0.05, 0.05, 0.9], [0.6, 0.3, 0.1], ] true_labels = [0, 0, 1, 1, 2, 2, 0, 1, 2, 0] cal.fit(raw_probs, true_labels, version="gold_v1") assert cal.is_fitted assert cal.calibration_version == "gold_v1" result = cal.calibrate([0.7, 0.2, 0.1]) assert len(result) == 3 assert all(0.0 <= p <= 1.0 for p in result) assert abs(sum(result) - 1.0) < 1e-6 def test_calibration_preserves_ordering(self): """Higher raw probabilities should map to higher calibrated values.""" cal = SentimentCalibrator(method="isotonic") raw_probs = [ [0.9, 0.05, 0.05], [0.8, 0.1, 0.1], [0.7, 0.15, 0.15], [0.6, 0.2, 0.2], [0.3, 0.6, 0.1], [0.2, 0.7, 0.1], [0.1, 0.8, 0.1], [0.1, 0.1, 0.8], [0.15, 0.15, 0.7], [0.2, 0.2, 0.6], ] true_labels = [0, 0, 0, 0, 1, 1, 1, 2, 2, 2] cal.fit(raw_probs, true_labels, version="test_v1") low_pos = cal.calibrate([0.3, 0.5, 0.2]) high_pos = cal.calibrate([0.8, 0.1, 0.1]) assert high_pos[0] >= low_pos[0] def test_platt_calibration(self): """Platt scaling should also produce valid probabilities.""" cal = SentimentCalibrator(method="platt") raw_probs = [ [0.8, 0.1, 0.1], [0.7, 0.2, 0.1], [0.1, 0.8, 0.1], [0.2, 0.7, 0.1], [0.1, 0.1, 0.8], [0.1, 0.2, 0.7], [0.9, 0.05, 0.05], [0.05, 0.9, 0.05], [0.05, 0.05, 0.9], [0.6, 0.3, 0.1], ] true_labels = [0, 0, 1, 1, 2, 2, 0, 1, 2, 0] cal.fit(raw_probs, true_labels, version="platt_v1") assert cal.is_fitted result = cal.calibrate([0.6, 0.3, 0.1]) assert len(result) == 3 assert all(0.0 <= p <= 1.0 for p in result) assert abs(sum(result) - 1.0) < 1e-6 def test_batch_calibrate(self): """Batch calibration should produce consistent results.""" cal = SentimentCalibrator(method="isotonic") raw_probs = [ [0.9, 0.05, 0.05], [0.1, 0.8, 0.1], [0.1, 0.1, 0.8], [0.7, 0.2, 0.1], [0.2, 0.7, 0.1], [0.2, 0.1, 0.7], ] true_labels = [0, 1, 2, 0, 1, 2] cal.fit(raw_probs, true_labels, version="batch_v1") batch = [[0.7, 0.2, 0.1], [0.2, 0.7, 0.1]] results = cal.calibrate_batch(batch) assert len(results) == 2 for r in results: assert abs(sum(r) - 1.0) < 1e-6 def test_fit_validation_errors(self): cal = SentimentCalibrator() with pytest.raises(ValueError): cal.fit([], []) with pytest.raises(ValueError): cal.fit([[0.5, 0.3, 0.2]], [0, 1]) # Length mismatch class TestModels: """Test data model validation.""" def test_evidence_group_requires_non_empty_ids(self): with pytest.raises(ValueError): EvidenceGroup(company_id="AAPL", evidence_ids=[], texts=["test"]) def test_evidence_group_requires_non_empty_texts(self): with pytest.raises(ValueError): EvidenceGroup(company_id="AAPL", evidence_ids=["ev1"], texts=[]) def test_company_sentiment_result_valid_labels(self): for label in ("positive", "negative", "neutral", "mixed"): result = CompanySentimentResult( company_id="X", label=label, positive_prob=0.33, negative_prob=0.33, neutral_prob=0.34, evidence_ids=["ev1"], model_version="test", ) assert result.label == label def test_company_sentiment_result_invalid_label(self): with pytest.raises(ValueError): CompanySentimentResult( company_id="X", label="very_positive", positive_prob=0.8, negative_prob=0.1, neutral_prob=0.1, evidence_ids=["ev1"], model_version="test", ) def test_sentiment_batch_result(self): result = SentimentBatchResult( results=[ CompanySentimentResult( company_id="AAPL", label="positive", positive_prob=0.8, negative_prob=0.1, neutral_prob=0.1, evidence_ids=["ev1"], model_version="test", ) ], model_version="test", processing_time_ms=150, ) assert len(result.results) == 1 assert result.processing_time_ms == 150 def test_text_sentiment_model(self): ts = TextSentiment( evidence_id="ev1", positive_prob=0.7, negative_prob=0.2, neutral_prob=0.1, ) assert ts.evidence_id == "ev1" assert ts.dominant_label == "positive" assert abs(ts.positive_prob + ts.negative_prob + ts.neutral_prob - 1.0) < 1e-6 def test_text_sentiment_dominant_negative(self): ts = TextSentiment(evidence_id="ev1", positive_prob=0.1, negative_prob=0.7, neutral_prob=0.2) assert ts.dominant_label == "negative" def test_text_sentiment_dominant_neutral(self): ts = TextSentiment(evidence_id="ev1", positive_prob=0.1, negative_prob=0.2, neutral_prob=0.7) assert ts.dominant_label == "neutral" def test_company_sentiment_result_is_mixed_field(self): result = CompanySentimentResult( company_id="X", label="mixed", positive_prob=0.4, negative_prob=0.4, neutral_prob=0.2, evidence_ids=["ev1", "ev2"], is_mixed=True, model_version="test", ) assert result.is_mixed is True