diff --git a/docs/design.md b/docs/design.md index 6911ab95..99c22cfc 100644 --- a/docs/design.md +++ b/docs/design.md @@ -92,7 +92,7 @@ SQLite 没有原生日期时间类型,状态存储需要直接对 TEXT 做范 ## 预警记忆 -每次总结把当前有效预警及其历史来源文章交给模型。模型必须返回结构化 `active_warnings`:明确解除的预警不再返回,仍有效的预警继续返回。只有引用本轮新文章或实时 API 时才刷新确认时间;纯历史延续不会刷新。超过 `WARNING_RETENTION_HOURS` 后仍无新证据的记录自动失效,既容忍短暂信息空窗,也避免预警永久残留。 +每次总结把当前有效预警及其历史来源文章交给模型,并单独提供当前允许解除的预警 ID。模型必须返回结构化 `active_warnings`:明确解除的预警不再返回,仍有效的预警继续返回;`resolved_warning_ids` 只能复制当前允许列表中的值。核心编排在持久化前过滤不在该列表中的值,并只记录不同未知 ID 的数量。只有引用本轮新文章或实时 API 时才刷新确认时间;纯历史延续不会刷新。超过 `WARNING_RETENTION_HOURS` 后仍无新证据的记录自动失效,既容忍短暂信息空窗,也避免预警永久残留。 ## 来源引用 diff --git a/docs/notes.md b/docs/notes.md index d2b976e0..c5f3e1a9 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -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 支持的配置面调整,而不是重新增加应用通用重试器。 diff --git a/docs/requirements.md b/docs/requirements.md index bac00415..5db63d56 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 降级。 diff --git a/tests/test_service.py b/tests/test_service.py index 7047fd19..07afd8c7 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -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( @@ -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, @@ -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( @@ -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", diff --git a/weather_briefing/prompts.py b/weather_briefing/prompts.py index 7be379ae..bdfd84a9 100644 --- a/weather_briefing/prompts.py +++ b/weather_briefing/prompts.py @@ -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 只是可选定位提示,字段缺失表示未知,不得自行猜测。 diff --git a/weather_briefing/service.py b/weather_briefing/service.py index 5ff33325..08aa5b63 100644 --- a/weather_briefing/service.py +++ b/weather_briefing/service.py @@ -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 @@ -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) @@ -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") @@ -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), + ) + result = replace( + result, + resolved_warning_ids=tuple( + warning_id for warning_id in result.resolved_warning_ids if warning_id in active_warning_ids + ), + ) message = self._delivery.render_briefing( result, source_articles, @@ -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