Skip to content

Commit 22c5774

Browse files
committed
claude review changes
1 parent e051b6b commit 22c5774

13 files changed

Lines changed: 580 additions & 70 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ MISTRAL_API_URL=https://api.mistral.ai/v1/chat/completions
1616
MISTRAL_MODEL=mistral-large-latest
1717
AI_SUMMARY_ENABLED=true
1818
AI_PROMPT_VERSION=v1
19+
AI_PROVIDER=mistral
20+
AI_REQUEST_TIMEOUT_SECONDS=60
21+
AI_REPORT_MAX_ARTICLES=50
1922

2023
REQUEST_TIMEOUT_SECONDS=15
2124
REQUEST_MAX_RETRIES=3

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ cp .env.example .env
7474
- `MISTRAL_MODEL` (default `mistral-large-latest`)
7575
- `AI_SUMMARY_ENABLED` (default `true`) — set to `false` to skip the AI step entirely
7676
- `AI_PROMPT_VERSION` (default `v1`) — stored alongside each report for prompt-version tracking
77+
- `AI_PROVIDER` (default `mistral`) — provider label written to `request_ai_report.model_provider`
78+
- `AI_REQUEST_TIMEOUT_SECONDS` (default `60`) — HTTP timeout for the AI provider call
79+
- `AI_REPORT_MAX_ARTICLES` (default `50`) — hard cap on articles passed to the model per report
7780

7881
**Reliability and limits**
7982
- `REQUEST_TIMEOUT_SECONDS`, `REQUEST_MAX_RETRIES`, `REQUEST_BACKOFF_FACTOR`, `REQUEST_MAX_BACKOFF_SECONDS`
@@ -144,16 +147,18 @@ pip-audit
144147
After `run_pipeline_for_web_user` finishes loading and persists `request_stats`, it calls `_generate_and_store_ai_summary`, which:
145148

146149
1. Reads the loaded pocket from DB: `articles JOIN user_news WHERE search_request_id = ?` — the canonical, deduplicated set the user actually got.
147-
2. Calls Mistral (`response_format: json_object`) with the system prompt in `src/ai/prompts.py`.
148-
3. Validates and normalizes the JSON response (sentiment percentages re-normalized to sum to 100; `sentiment_label` forced to match the dominant key in the distribution; `highlight.url` falls back to a real article URL if the model invented one).
149-
4. Upserts a row in `request_ai_report` keyed by `search_request_id`.
150+
2. Truncates the article list to `AI_REPORT_MAX_ARTICLES` (default 50) before calling the model.
151+
3. Calls Mistral (`response_format: json_object`) with the system prompt in `src/ai/prompts.py`.
152+
4. Validates and normalizes the JSON response (sentiment percentages re-normalized to sum to 100; `sentiment_label` forced to match the dominant key in the distribution; `highlight.url` replaced with a real article URL if the model invented one).
153+
5. Upserts a row in `request_ai_report` keyed by `search_request_id` with `status='success'`.
150154

151155
The website reads the result by querying `request_ai_report` once `search_requests.status = 'success'`.
152156

153-
The AI step is **best-effort**: any Mistral or DB error is logged but does not fail the search request — articles are still loaded and the request is still marked `success`. To skip AI entirely, set `AI_SUMMARY_ENABLED=false`.
157+
The AI step is **best-effort**: any Mistral or DB error is logged but does not fail the search request — articles are still loaded and the request is still marked `success`. On AI failure (Mistral error, validation error, or empty article pocket) the pipeline upserts a `request_ai_report` row with `status='failed'` and the error message in `error_text`, so the frontend can distinguish "AI failed" from "AI not yet generated." To skip AI entirely, set `AI_SUMMARY_ENABLED=false`.
154158

155159
Stored summary fields:
156-
- `news_count` — articles linked to this request
160+
- `status` (`success` | `failed`) and `error_text` (populated only when `status='failed'`)
161+
- `news_count` — articles linked to this request (capped at `AI_REPORT_MAX_ARTICLES`)
157162
- `summary` — 1-2 sentence neutral overview
158163
- `main_conclusions` (JSONB) — exactly 3 short conclusions
159164
- `sentiment_label` (`positive` | `negative` | `neutral`) and `sentiment_score` — dominant sentiment and its percentage

config/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ class Settings:
120120
mistral_model: str
121121
ai_summary_enabled: bool
122122
ai_prompt_version: str
123+
ai_provider: str
124+
ai_request_timeout_seconds: float
125+
ai_report_max_articles: int
123126

124127
request_timeout_seconds: float
125128
request_max_retries: int
@@ -177,6 +180,9 @@ def build_settings() -> Settings:
177180
mistral_model=_get_env_str("MISTRAL_MODEL", "mistral-large-latest"),
178181
ai_summary_enabled=_get_env_bool("AI_SUMMARY_ENABLED", True),
179182
ai_prompt_version=_get_env_str("AI_PROMPT_VERSION", "v1"),
183+
ai_provider=_get_env_str("AI_PROVIDER", "mistral"),
184+
ai_request_timeout_seconds=_get_env_float("AI_REQUEST_TIMEOUT_SECONDS", 60.0, min_value=1.0),
185+
ai_report_max_articles=_get_env_int("AI_REPORT_MAX_ARTICLES", 50, min_value=1),
180186
request_timeout_seconds=_get_env_float("REQUEST_TIMEOUT_SECONDS", 15.0, min_value=1.0),
181187
request_max_retries=_get_env_int("REQUEST_MAX_RETRIES", 3, min_value=0),
182188
request_backoff_factor=_get_env_float("REQUEST_BACKOFF_FACTOR", 1.0, min_value=0.0),

requirements.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,4 @@
22
psycopg2-binary>=2.9,<3.0
33
requests==2.32.5
44
cryptography>=42.0,<46.0
5-
pydantic>=2.0,<3.0
6-
mistralai>=1.0,<2.0
5+
pydantic>=2.0,<3.0

src/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
table_exists,
2323
)
2424
from .extract import make_extract_debug, make_extract_web
25-
from .load import load_ai_report, load_news, load_request_stats, load_web_pipeline
25+
from .load import load_ai_report, load_failed_ai_report, load_news, load_request_stats, load_web_pipeline
2626
from .pipeline import run_debug_pipeline, run_pipeline_for_web_user
2727
from .transform import transform_article_debug, transform_article_web
2828
from .worker import run_worker_loop
@@ -47,6 +47,7 @@
4747
"get_cursor",
4848
"init_database",
4949
"load_ai_report",
50+
"load_failed_ai_report",
5051
"load_news",
5152
"load_request_stats",
5253
"load_web_pipeline",

src/ai/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def generate_summary(
8484
self.api_url,
8585
headers=headers,
8686
json=payload,
87-
timeout=settings.request_timeout_seconds * 4,
87+
timeout=settings.ai_request_timeout_seconds,
8888
)
8989
except requests.exceptions.Timeout as exc:
9090
raise MistralClientError("Mistral API request timed out") from exc

src/ai/prompts.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,44 @@
22

33
from __future__ import annotations
44

5-
SYSTEM_PROMPT = """Вы — эксперт в анализе новостей. Проанализируйте предоставленный набор новостных статей и верните один структурированный JSON-объект — без текста и разметки Markdown.
5+
SYSTEM_PROMPT = """Вы — аналитик новостей. Проанализируйте предоставленный набор новостных статей и верните один структурированный JSON-объект — без окружающего текста и Markdown.
66
7-
Требуемая структура JSON (все ключи должны присутствовать):
7+
Требуемая структура JSON (все ключи должны присутствовать, точно так, как указано ниже):
88
{
9-
"summary": "1-2 предложения, нейтральный обзор всего набора статей",
10-
"main_conclusions": ["вывод 1", "вывод 2", "вывод 3"],
11-
12-
"sentiment_label": "позитивный" | "негативный" | "нейтральный",
13-
"оценка_настроения": <число 0-100, процент статей, соответствующих метке_настроения>,
14-
"распределение_настроения": {"положительный": <число>, "отрицательный": <число>, "нейтральный": <число>},
15-
"основные_темы": ["тема 1", "тема 2", "тема 3"],
16-
"выделено": {
17-
"url": "<url самой важной статьи>",
18-
"title": "<title>",
19-
"author": "<автор или null>",
20-
"описание": "<краткое описание или null>",
21-
"причина": "<одно короткое предложение о том, почему была выбрана эта статья>"
22-
23-
},
24-
"предупреждения_о_качестве_данных": ["предупреждение 1", ...]
9+
"summary": "1-2 sentences, neutral overview of the entire article set",
10+
"main_conclusions": ["conclusion 1", "conclusion 2", "conclusion 3"],
11+
"sentiment_label": "positive" | "negative" | "neutral",
12+
"sentiment_score": <number 0-100, percentage of articles matching sentiment_label>,
13+
"sentiment_distribution": {"positive": <number>, "negative": <number>, "neutral": <number>},
14+
"main_topics": ["topic 1", "topic 2", "topic 3"],
15+
"highlight": {
16+
"url": "<url of the most important article>",
17+
"title": "<title>",
18+
"author": "<author or null>",
19+
"description": "<brief description or null>",
20+
"reason": "<one short sentence about why this article was selected>"
21+
},
22+
"data_quality_warnings": ["warning 1", ...]
2523
}
2624
2725
Правила:
28-
1. Основные_выводы ДОЛЖНЫ содержать ровно 3 пункта, каждый из которых представляет собой краткое предложение.
26+
1. Ключи JSON ДОЛЖНЫ быть на английском языке точно так, как показано выше. Значения могут быть на другом языке (см. правило 9).
2927
30-
2. Проценты sentiment_distribution ДОЛЖНЫ в сумме равняться 100.
31-
3. sentiment_label ДОЛЖЕН быть доминирующим ключом в sentiment_distribution; sentiment_score ДОЛЖЕН равняться значению этого ключа.
28+
2. main_conclusions ДОЛЖЕН содержать ровно 3 элемента, каждый из которых представляет собой короткое предложение.
3229
33-
4. main_topics ДОЛЖЕН содержать от 1 до 3 элементов, упорядоченных по важности. Используйте меньшее количество, если темы нечетко различаются.
30+
3. Проценты sentiment_distribution ДОЛЖНЫ в сумме составлять 100.
31+
4. sentiment_label ДОЛЖЕН быть доминирующим ключом в sentiment_distribution; sentiment_score ДОЛЖЕН равняться значению этого ключа.
3432
35-
5. highlight.url ДОЛЖЕН быть одним из URL-адресов из входных статей.
33+
5. main_topics ДОЛЖЕН содержать от 1 до 3 элементов, упорядоченных по важности. Используйте меньшее количество, если темы нечетко различаются.
3634
37-
6. data_quality_warnings — это список (возможно, пустой) кратких замечаний о входных данных (отсутствующие поля, дубликаты, подозрительный контент и т. д.).
35+
6. highlight.url ДОЛЖЕН быть одним из URL-адресов из входных статей.
3836
39-
7. Отвечайте на том же языке, что и большинство статей. Если не уверены, используйте русский.
37+
7. data_quality_warnings — это (возможно, пустой) список кратких примечаний к входным данным (отсутствующие поля, дубликаты, подозрительный контент, анализ, основанный только на заголовке/описании и т. д.).
4038
41-
8. Будьте объективны и основывайтесь на фактах. Выводите ТОЛЬКО объект JSON."""
39+
8. Не выдумывайте факты, выходящие за рамки того, что указано в заголовках и описаниях статей, которые вам предоставлены. Если чего-то нет во входных данных, не делайте выводов. Анализ основан исключительно на полях заголовка и описания — обратите внимание на это ограничение в data_quality_warnings, если это необходимо.
40+
9. Текстовые значения (summary, main_conclusions, main_topics, highlight.reason, data_quality_warnings) следует писать на том же языке, что и большинство статей. Если не уверены, используйте русский. Ключи остаются на английском языке независимо от языка.
41+
42+
10. Будьте объективны и основывайтесь на фактах. Выводите ТОЛЬКО объект JSON."""
4243

4344
USER_PROMPT_TEMPLATE = """Analyze this pocket of news articles.
4445

src/ai/report.py

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,30 @@
2121
_VALID_SENTIMENT_LABELS = {"positive", "negative", "neutral"}
2222

2323

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+
2432
def _build_articles_text(articles: list[dict[str, Any]], max_length: int = 15000) -> str:
2533
parts = []
2634
for idx, article in enumerate(articles, 1):
2735
title = article.get("title") or "No title"
2836
author = article.get("author") or "Unknown"
37+
source = article.get("source_name") or "Unknown"
38+
published_at = _format_published_at(article.get("published_at"))
2939
description = article.get("description") or "No description"
3040
url = article.get("url") or "No URL"
3141
snippet = description[:500] + ("..." if len(description) > 500 else "")
3242
parts.append(
3343
f" Article {idx}:\n"
3444
f" Title: {title}\n"
45+
f" Source: {source}\n"
3546
f" Author: {author}\n"
47+
f" Published: {published_at}\n"
3648
f" URL: {url}\n"
3749
f" Description: {snippet}\n"
3850
)
@@ -81,27 +93,29 @@ def _detect_data_quality_warnings(
8193
def _normalize_sentiment(
8294
raw: dict[str, Any],
8395
) -> 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
90104
if total <= 0:
91-
distribution = SentimentDistribution(positive=0, negative=0, neutral=100)
105+
pos, neg, neu = 0.0, 0.0, 100.0
92106
total = 100.0
107+
93108
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)
99116

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}
105119
label = max(pairs, key=lambda key: pairs[key])
106120
score = round(pairs[label], 2)
107121
return distribution, label, score
@@ -134,8 +148,17 @@ def _build_highlight(raw: Any, articles: list[dict[str, Any]]) -> HighlightArtic
134148
reason="Selected by fallback (model returned no highlight)",
135149
)
136150

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+
139162
return HighlightArticle(
140163
url=url,
141164
title=str(raw.get("title") or matched.get("title") or fallback["title"]),
@@ -171,6 +194,7 @@ def _fallback_summary(
171194
reason="First article (AI disabled)",
172195
),
173196
data_quality_warnings=warnings,
197+
model_provider=settings.ai_provider,
174198
prompt_version=settings.ai_prompt_version,
175199
)
176200

@@ -207,10 +231,10 @@ def generate_news_summary(
207231
payload.get("sentiment_distribution") or {}
208232
)
209233

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
214238

215239
raw_score = payload.get("sentiment_score")
216240
try:
@@ -238,7 +262,7 @@ def generate_news_summary(
238262
main_topics=main_topics,
239263
highlight=highlight,
240264
data_quality_warnings=warnings,
241-
model_provider="mistral",
265+
model_provider=settings.ai_provider,
242266
model_name=completion.model_name,
243267
prompt_version=settings.ai_prompt_version,
244268
usage=TokenUsage(

0 commit comments

Comments
 (0)