Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ SQLite 没有原生日期时间类型,状态存储需要直接对 TEXT 做范

## 预警记忆

每次总结把当前有效预警及其历史来源文章交给模型。模型必须返回结构化 `active_warnings`:明确解除的预警不再返回,仍有效的预警继续返回。只有引用本轮新文章或实时 API 时才刷新确认时间;纯历史延续不会刷新。超过 `WARNING_RETENTION_HOURS` 后仍无新证据的记录自动失效,既容忍短暂信息空窗,也避免预警永久残留。
每次总结把当前有效预警及其历史来源文章交给模型,并单独提供当前允许解除的预警 ID。模型必须返回结构化 `active_warnings`:明确解除的预警不再返回,仍有效的预警继续返回;`resolved_warning_ids` 只能复制当前允许列表中的值。核心编排在持久化前过滤不在该列表中的值,并只记录不同未知 ID 的数量。只有引用本轮新文章或实时 API 时才刷新确认时间;纯历史延续不会刷新。超过 `WARNING_RETENTION_HOURS` 后仍无新证据的记录自动失效,既容忍短暂信息空窗,也避免预警永久残留。

## 来源引用

Expand Down
2 changes: 2 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ HTTPX `request.extensions` 是可扩展输入,不保证只由 `api_client.py`

只有取得非空模型正文,但 schema、来源引用或领域规则不合法时,service 才携带上一版输出进入契约修复。timeout、认证失败、限流,以及响应缺少 choices、message,或同时缺少有效 parsed 结果和非空 content 时,都没有可修复的输出,必须终止本轮调用,不能用“上一版 JSON 无效”的提示再次请求;已经取得正文的输出错误仍保留定向修复。

预警解除是例外的可降级记账字段。应用把当前有效预警 ID 作为 `resolved_warning_ids` 的允许列表交给模型,但历史文章和简报仍可能使模型生成已过期或虚构的 ID。未知 ID 不对应任何可删除状态,也不进入用户可见正文,因此核心编排在持久化前过滤它们,而不是为无效果的状态操作消耗契约修复重试并阻断整份简报。取舍是该字段的模型契约违规不会单独使任务失败;为保留可观测性,常规日志只记录不同未知 ID 的数量,不记录可能来自私密内容的具体值。若解除字段开始影响正文、触发外部副作用,或未知 ID 持续出现并掩盖真实状态错配,应重新改为严格失败,并先把预警身份收归确定性的 provider 或应用边界。

### 成立条件与重新评估触发点

- 厂商 SDK 默认 timeout 与重试不会让任务持续时间或费用不可接受;若出现任务错过调度窗口、重试风暴或费用放大,应优先通过该 SDK 支持的配置面调整,而不是重新增加应用通用重试器。
Expand Down
2 changes: 1 addition & 1 deletion docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
2. 持久记录已处理文章,后续任务不重复总结。briefing 任务评估为不值得推送时,本轮尚未发布的文章必须作为待处理内容持久化,后续任务继续将其与新文章及此后的 API 快照一起总结;只有某轮简报成功投递后,所覆盖的待处理文章才能标记为已处理。待处理内容不得因为跨小时或跨日而丢失。
3. 新总结同时利用此前已发送简报、权威预报原文与尚在生效的预警。
4. 标题包含可配置的权威预报关键词时,先清除 HTML 标签、脚本、评论、弹窗等页面噪声和独立时间戳,再完整转发正文语义内容,不截断、不总结;其内容进入第二天预报上下文。来源特有的署名等清理规则由私密运行配置提供。若清洗后正文为空,不得投递标题空壳,也不得将该条目标记为已处理,以便来源后续修正内容时重新采集。
5. 预警不能因一小时未被再次提及就自动消失;只有明确解除/降级信息或超过可配置保留窗口后才更新状态。
5. 预警不能因一小时未被再次提及就自动消失;只有明确解除/降级信息或超过可配置保留窗口后才更新状态。LLM 只能用当前有效预警的既有 ID 表示解除;未知解除 ID 不得修改状态,也不得阻断本轮总结和投递。
6. 允许接入天气或灾害服务 API 作为辅助上下文。
7. forecast 和 briefing 都通过可替换且可组合的天气 provider 获取 API 更新。未显式指定顺序时,中国大陆位置默认以 QWeather 为主要来源、Open-Meteo 为备用;其他地区默认只使用 Open-Meteo。运行环境可以显式指定任意天气 provider 顺序,首项即主要来源,后续项为备用;主来源请求或响应校验失败时按顺序降级。
8. 天气 provider 返回预报、可用的生活指数及可选空气质量。显式查询目标日期时,provider 支持的生活指数、空气质量和过敏原预报必须选择同一目标日期,不得以当日观测或其他日期建议代替;空气质量资料的时间必须明确标为预报时段,不得误称观测时间。provider 不支持目标日期或目标日期超出其预报范围时允许明确缺失。非显式日期的常规预报若最终天气结果没有空气质量,则使用可选的 AQICN 配置补充;未配置 AQICN 且天气来源也未提供空气质量时,任务必须以明确配置错误失败并提醒用户。AQICN 只提供当前空气质量,不参与目标日期补充或天气 provider 降级。
Expand Down
13 changes: 9 additions & 4 deletions tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -954,7 +954,7 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic
assert publisher.messages == []


async def test_unknown_resolved_warning_id_triggers_contract_retry(tmp_path: Path) -> None:
async def test_unknown_resolved_warning_id_is_ignored(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
timezone = pendulum.timezone("Asia/Shanghai")
now = pendulum.datetime(2026, 7, 13, 9, tz=timezone)
article = Article(
Expand Down Expand Up @@ -982,12 +982,14 @@ def __init__(self) -> None:

async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]:
self.attempts += 1
assert "input.allowed_resolved_warning_ids" in system_prompt
assert payload["allowed_resolved_warning_ids"] == [warning.id]
return {
"headline": "Warning update",
"headline_source_ids": [article.id],
"conclusions": [],
"active_warnings": [],
"resolved_warning_ids": ["invented-warning" if self.attempts == 1 else warning.id],
"resolved_warning_ids": ["invented-warning", "invented-warning", warning.id],
"advice": [],
"disaster_tracking": [],
"should_publish": False,
Expand All @@ -1010,11 +1012,13 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic
delivery,
)

assert await service.run("briefing", now.add(hours=1)) is None
with caplog.at_level("WARNING", logger="weather_briefing.service"):
assert await service.run("briefing", now.add(hours=1)) is None
assert state.active_warnings(now.add(hours=1), 12) == ()

assert llm.attempts == 2
assert llm.attempts == 1
assert publisher.messages == []
assert "Ignoring 1 distinct resolved warning ID(s) that are not currently active" in caplog.text


async def test_unpublished_article_is_included_until_a_later_briefing_is_published(
Expand Down Expand Up @@ -1543,6 +1547,7 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic
assert ("previous_invalid_response" in payload) is not self._fail_before_response
allowed_source_ids = payload["allowed_source_ids"]
assert isinstance(allowed_source_ids, list)
assert payload["allowed_resolved_warning_ids"] == []
source_id = str(allowed_source_ids[0])
return {
"headline": "Briefing",
Expand Down
3 changes: 2 additions & 1 deletion weather_briefing/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
不同来源对同一时段的天气现象有冲突时,不得拼接成无争议的单一结论;应明确说明差异,
并优先采用可识别的当地权威气象机构最新信息,同时引用存在冲突的来源。
当前预警必须单独表达。历史有效预警在没有明确解除或降级证据时应继续保留。
只有资料明确说明解除时,才把其 id 放入 resolved_warning_ids。
只有资料明确说明解除时,才把其 id 放入 resolved_warning_ids;其中每个值都必须原样复制自
input.allowed_resolved_warning_ids。没有匹配值时返回空数组,不得自行创建预警 id。
灾害动态只保留明确影响 input.location_scope 的台风、海啸、地震等自然灾害,并说明当前位置和预计方向。
以 location_scope.full_name 中的完整地点名为地域判断主依据,按当地行政区划语义识别其上级范围;
administrative_area 和 country_code 只是可选定位提示,字段缺失表示未知,不得自行猜测。
Expand Down
23 changes: 16 additions & 7 deletions weather_briefing/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import logging
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Literal, Protocol

import pendulum
Expand Down Expand Up @@ -374,6 +374,8 @@ async def _run(
historical_context_payload,
active_warnings,
)
active_warning_ids = {warning.id for warning in active_warnings}
payload["allowed_resolved_warning_ids"] = sorted(active_warning_ids)
briefing_limit = self._delivery.briefing_limit(self._settings.briefing_max_characters)
payload["output_constraints"] = {"briefing_max_characters": briefing_limit}
required_advice_topics = _required_advice_topics(kind, context)
Expand All @@ -385,14 +387,8 @@ async def _run(
)
allergen_source_ids = {document.id for document in context if document.has_allergen_information}
valid_source_ids = {article.id for article in source_articles} | {document.id for document in reference_context}
active_warning_ids = {warning.id for warning in active_warnings}

def validate_result(candidate: BriefingResult) -> None:
unknown_resolved_warning_ids = set(candidate.resolved_warning_ids) - active_warning_ids
if unknown_resolved_warning_ids:
raise LLMError(
f"resolved_warning_ids contains unknown warning IDs: {sorted(unknown_resolved_warning_ids)}"
)
candidate_message = self._delivery.render_briefing(candidate, source_articles, reference_context)
if kind == "briefing" and candidate.advice:
raise LLMError("briefing must not repeat lifestyle advice")
Expand All @@ -413,6 +409,18 @@ def validate_result(candidate: BriefingResult) -> None:
)

result = await self._summarize(payload, now, valid_source_ids, validator=validate_result)
unknown_resolved_warning_ids = set(result.resolved_warning_ids) - active_warning_ids
if unknown_resolved_warning_ids:
_LOGGER.warning(
"Ignoring %d distinct resolved warning ID(s) that are not currently active",
len(unknown_resolved_warning_ids),
)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
result = replace(
result,
resolved_warning_ids=tuple(
warning_id for warning_id in result.resolved_warning_ids if warning_id in active_warning_ids
),
)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment on lines +412 to +423

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

1. Raw_payload diverges after filtering 🐞 Bug ⚙ Maintainability

After filtering out unknown resolved warning IDs, the service updates only
BriefingResult.resolved_warning_ids but leaves BriefingResult.raw_payload containing the original
(unfiltered) resolved_warning_ids. This makes raw_payload inconsistent with the persisted/effective
result and unsafe to treat as the canonical post-normalization representation.
Agent Prompt
## Issue description
`BriefingService._run()` filters unknown `resolved_warning_ids` using `dataclasses.replace`, but only updates the typed `resolved_warning_ids` field. `BriefingResult.raw_payload` (which is populated from the original structured LLM response) remains unchanged and can therefore disagree with `result.resolved_warning_ids` after normalization.

## Issue Context
`raw_payload` is part of `BriefingResult` and currently represents the exact parsed model output. Once the service applies post-processing, the object contains two conflicting representations of `resolved_warning_ids`.

## Fix Focus Areas
- weather_briefing/service.py[411-423]
- weather_briefing/llm.py[283-331]
- weather_briefing/models.py[205-218]

## Suggested fix
When filtering unknown IDs, also update `raw_payload["resolved_warning_ids"]` to match the filtered tuple (or explicitly document/rename `raw_payload` to indicate it is intentionally unnormalized and must not be used as a post-processed representation).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

message = self._delivery.render_briefing(
result,
source_articles,
Expand Down Expand Up @@ -547,6 +555,7 @@ async def _summarize(
repair_payload: dict[str, object] = {
"original_input": payload,
"allowed_source_ids": sorted(valid_source_ids),
"allowed_resolved_warning_ids": payload["allowed_resolved_warning_ids"],
}
if raw_result is not None:
repair_payload["previous_invalid_response"] = raw_result
Expand Down