|
21 | 21 | _VALID_SENTIMENT_LABELS = {"positive", "negative", "neutral"} |
22 | 22 |
|
23 | 23 |
|
| 24 | +def _format_published_at(value: Any) -> str: |
| 25 | + if value is None: |
| 26 | + return "Unknown" |
| 27 | + if hasattr(value, "isoformat"): |
| 28 | + return value.isoformat() |
| 29 | + return str(value) |
| 30 | + |
| 31 | + |
24 | 32 | def _build_articles_text(articles: list[dict[str, Any]], max_length: int = 15000) -> str: |
25 | 33 | parts = [] |
26 | 34 | for idx, article in enumerate(articles, 1): |
27 | 35 | title = article.get("title") or "No title" |
28 | 36 | author = article.get("author") or "Unknown" |
| 37 | + source = article.get("source_name") or "Unknown" |
| 38 | + published_at = _format_published_at(article.get("published_at")) |
29 | 39 | description = article.get("description") or "No description" |
30 | 40 | url = article.get("url") or "No URL" |
31 | 41 | snippet = description[:500] + ("..." if len(description) > 500 else "") |
32 | 42 | parts.append( |
33 | 43 | f" Article {idx}:\n" |
34 | 44 | f" Title: {title}\n" |
| 45 | + f" Source: {source}\n" |
35 | 46 | f" Author: {author}\n" |
| 47 | + f" Published: {published_at}\n" |
36 | 48 | f" URL: {url}\n" |
37 | 49 | f" Description: {snippet}\n" |
38 | 50 | ) |
@@ -81,27 +93,29 @@ def _detect_data_quality_warnings( |
81 | 93 | def _normalize_sentiment( |
82 | 94 | raw: dict[str, Any], |
83 | 95 | ) -> tuple[SentimentDistribution, str, float]: |
84 | | - distribution = SentimentDistribution( |
85 | | - positive=float(raw.get("positive", 0) or 0), |
86 | | - negative=float(raw.get("negative", 0) or 0), |
87 | | - neutral=float(raw.get("neutral", 0) or 0), |
88 | | - ) |
89 | | - total = distribution.positive + distribution.negative + distribution.neutral |
| 96 | + # Operate on plain floats first — the schema enforces le=100, but the |
| 97 | + # whole point of this function is to rescue out-of-range model output |
| 98 | + # by renormalizing. |
| 99 | + pos = max(float(raw.get("positive", 0) or 0), 0.0) |
| 100 | + neg = max(float(raw.get("negative", 0) or 0), 0.0) |
| 101 | + neu = max(float(raw.get("neutral", 0) or 0), 0.0) |
| 102 | + |
| 103 | + total = pos + neg + neu |
90 | 104 | if total <= 0: |
91 | | - distribution = SentimentDistribution(positive=0, negative=0, neutral=100) |
| 105 | + pos, neg, neu = 0.0, 0.0, 100.0 |
92 | 106 | total = 100.0 |
| 107 | + |
93 | 108 | if not (99 <= total <= 101): |
94 | | - distribution = SentimentDistribution( |
95 | | - positive=round(distribution.positive / total * 100, 2), |
96 | | - negative=round(distribution.negative / total * 100, 2), |
97 | | - neutral=round(distribution.neutral / total * 100, 2), |
98 | | - ) |
| 109 | + pos = pos / total * 100 |
| 110 | + neg = neg / total * 100 |
| 111 | + neu = neu / total * 100 |
| 112 | + |
| 113 | + pos = min(round(pos, 2), 100.0) |
| 114 | + neg = min(round(neg, 2), 100.0) |
| 115 | + neu = min(round(neu, 2), 100.0) |
99 | 116 |
|
100 | | - pairs = { |
101 | | - "positive": distribution.positive, |
102 | | - "negative": distribution.negative, |
103 | | - "neutral": distribution.neutral, |
104 | | - } |
| 117 | + distribution = SentimentDistribution(positive=pos, negative=neg, neutral=neu) |
| 118 | + pairs = {"positive": pos, "negative": neg, "neutral": neu} |
105 | 119 | label = max(pairs, key=lambda key: pairs[key]) |
106 | 120 | score = round(pairs[label], 2) |
107 | 121 | return distribution, label, score |
@@ -134,8 +148,17 @@ def _build_highlight(raw: Any, articles: list[dict[str, Any]]) -> HighlightArtic |
134 | 148 | reason="Selected by fallback (model returned no highlight)", |
135 | 149 | ) |
136 | 150 |
|
137 | | - url = str(raw.get("url") or fallback["url"]) |
138 | | - matched = next((a for a in articles if a.get("url") == url), fallback) |
| 151 | + raw_url = str(raw.get("url") or "").strip() |
| 152 | + matched = next((a for a in articles if a.get("url") == raw_url), None) |
| 153 | + |
| 154 | + if matched is None: |
| 155 | + # Model invented a URL not present in the input. Fall back to the first |
| 156 | + # real article so downstream consumers always see a valid URL. |
| 157 | + url = fallback["url"] |
| 158 | + matched = fallback |
| 159 | + else: |
| 160 | + url = raw_url |
| 161 | + |
139 | 162 | return HighlightArticle( |
140 | 163 | url=url, |
141 | 164 | title=str(raw.get("title") or matched.get("title") or fallback["title"]), |
@@ -171,6 +194,7 @@ def _fallback_summary( |
171 | 194 | reason="First article (AI disabled)", |
172 | 195 | ), |
173 | 196 | data_quality_warnings=warnings, |
| 197 | + model_provider=settings.ai_provider, |
174 | 198 | prompt_version=settings.ai_prompt_version, |
175 | 199 | ) |
176 | 200 |
|
@@ -207,10 +231,10 @@ def generate_news_summary( |
207 | 231 | payload.get("sentiment_distribution") or {} |
208 | 232 | ) |
209 | 233 |
|
210 | | - raw_label = str(payload.get("sentiment_label") or "").strip().lower() |
211 | | - sentiment_label = raw_label if raw_label in _VALID_SENTIMENT_LABELS else computed_label |
212 | | - if sentiment_label != computed_label: |
213 | | - sentiment_label = computed_label |
| 234 | + # The model can disagree with its own distribution (e.g. claim "positive" |
| 235 | + # but emit a distribution dominated by "neutral"). Trust the distribution, |
| 236 | + # not the label. |
| 237 | + sentiment_label = computed_label |
214 | 238 |
|
215 | 239 | raw_score = payload.get("sentiment_score") |
216 | 240 | try: |
@@ -238,7 +262,7 @@ def generate_news_summary( |
238 | 262 | main_topics=main_topics, |
239 | 263 | highlight=highlight, |
240 | 264 | data_quality_warnings=warnings, |
241 | | - model_provider="mistral", |
| 265 | + model_provider=settings.ai_provider, |
242 | 266 | model_name=completion.model_name, |
243 | 267 | prompt_version=settings.ai_prompt_version, |
244 | 268 | usage=TokenUsage( |
|
0 commit comments