"""Pydantic models for company-specific sentiment analysis.""" from __future__ import annotations from pydantic import BaseModel, Field, field_validator class EvidenceGroup(BaseModel): """A group of evidence spans associated with a specific company. A single evidence span can appear in multiple groups when the underlying text mentions multiple companies. """ company_id: str = Field(description="Resolved company identifier") evidence_ids: list[str] = Field(description="IDs of evidence spans in this group") texts: list[str] = Field(description="Text snippets from evidence spans") @field_validator("evidence_ids") @classmethod def evidence_ids_non_empty(cls, v: list[str]) -> list[str]: if not v: raise ValueError("evidence_ids must not be empty") return v @field_validator("texts") @classmethod def texts_non_empty(cls, v: list[str]) -> list[str]: if not v: raise ValueError("texts must not be empty") return v class TextSentiment(BaseModel): """Per-text sentiment probability distribution with evidence linkage. Stores the raw FinBERT output for a single evidence span text, enabling full provenance from probability to source evidence. """ evidence_id: str = Field(description="Evidence span ID this score belongs to") positive_prob: float = Field(ge=0.0, le=1.0, description="Probability of positive sentiment") negative_prob: float = Field(ge=0.0, le=1.0, description="Probability of negative sentiment") neutral_prob: float = Field(ge=0.0, le=1.0, description="Probability of neutral sentiment") @property def dominant_label(self) -> str: """Return the label with highest probability.""" if self.positive_prob >= self.negative_prob and self.positive_prob >= self.neutral_prob: return "positive" elif self.negative_prob >= self.positive_prob and self.negative_prob >= self.neutral_prob: return "negative" return "neutral" class CompanySentimentResult(BaseModel): """Sentiment classification result for a single company. Contains full probability distribution, supporting evidence IDs, per-text scores, and model/calibration versioning for lineage tracking. """ company_id: str = Field(description="Resolved company identifier") label: str = Field(description="Derived label: positive, negative, neutral, or mixed") positive_prob: float = Field(ge=0.0, le=1.0, description="Probability of positive sentiment") negative_prob: float = Field(ge=0.0, le=1.0, description="Probability of negative sentiment") neutral_prob: float = Field(ge=0.0, le=1.0, description="Probability of neutral sentiment") evidence_ids: list[str] = Field(description="All evidence span IDs contributing to this result") is_mixed: bool = Field(default=False, description="Whether mixed sentiment was detected from disagreement") per_text_scores: list[TextSentiment] = Field( default_factory=list, description="Full probability distributions per evidence text", ) model_version: str = Field(description="Sentiment model name and version") calibration_version: str = Field(default="uncalibrated", description="Calibration artifact version") @field_validator("label") @classmethod def label_valid(cls, v: str) -> str: valid_labels = {"positive", "negative", "neutral", "mixed"} if v not in valid_labels: raise ValueError(f"label must be one of {valid_labels}, got '{v}'") return v class SentimentBatchResult(BaseModel): """Result of sentiment classification for a batch of companies.""" results: list[CompanySentimentResult] = Field(description="Per-company sentiment results") model_version: str = Field(description="Sentiment model version used for batch") processing_time_ms: int = Field(ge=0, description="Total processing time in milliseconds")