Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
83b3d51
refactor: split weather response parsing
IceCodeNew Jul 29, 2026
924d4d6
refactor: split delivery renderers
IceCodeNew Jul 29, 2026
91bc264
feat: add portable notification decision policies
IceCodeNew Jul 29, 2026
28fbbb9
refactor: isolate any-llm transport
IceCodeNew Jul 29, 2026
1512078
refactor: separate weather notification decisions
IceCodeNew Jul 29, 2026
0b87fdf
refactor: split persistence state stores
IceCodeNew Jul 29, 2026
35152c6
refactor: isolate service status decisions
IceCodeNew Jul 29, 2026
c88f4be
refactor: split runtime composition
IceCodeNew Jul 29, 2026
78e32e5
docs: define notification decision boundary
IceCodeNew Jul 29, 2026
cf30596
fix(weather): degrade missing air quality guidance
IceCodeNew Jul 30, 2026
6c95504
fix(weather): localize QWeather AQI standards
IceCodeNew Jul 30, 2026
6d05c87
fix(notifications): protect assessment payload mappings
IceCodeNew Jul 30, 2026
7ec3da9
perf(notifications): avoid rendering skipped briefings
IceCodeNew Jul 30, 2026
18beb0e
fix(prompts): keep publication decisions separate
IceCodeNew Jul 30, 2026
fe341fe
docs(persistence): clarify transaction ownership
IceCodeNew Jul 30, 2026
dd98d98
fix(persistence): validate history timestamps
IceCodeNew Jul 30, 2026
4abf867
fix(diagnostics): validate controls before state access
IceCodeNew Jul 30, 2026
16b33d4
fix(persistence): validate no-op write timestamps
IceCodeNew Jul 30, 2026
0a2fbe8
fix(notifications): bypass unused forced decisions
IceCodeNew Jul 30, 2026
55fcdae
fix(service-status): preserve affected surfaces
IceCodeNew Jul 30, 2026
b27c045
fix(notifications): persist platform-neutral baselines
IceCodeNew Jul 30, 2026
46e84c3
docs: clarify notification comparison state
IceCodeNew Jul 30, 2026
831ac59
test: cover notification state validation
IceCodeNew Jul 30, 2026
e35ef75
fix(service-status): reject unknown stored surfaces
IceCodeNew Jul 30, 2026
9a3042f
fix(notifications): validate registered policy kinds
IceCodeNew Jul 30, 2026
b7d1cdf
fix(persistence): reject non-text JSON state
IceCodeNew Jul 30, 2026
f52fc08
test: exclude unreachable policy dispatch
IceCodeNew Jul 30, 2026
5d30583
fix(persistence): validate stored warning payloads
IceCodeNew Jul 30, 2026
e194dd3
fix(persistence): enforce warning identity consistency
IceCodeNew Jul 30, 2026
afabe77
fix(notifications): validate policy decisions
IceCodeNew Jul 30, 2026
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
32 changes: 19 additions & 13 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
地点解析
-> 天气与可选信息源
-> 清洗、筛选和历史状态
-> 大语言模型
-> 结构化简报
-> 大语言模型生成结构化候选消息
-> 按消息类型判断是否值得立即通知
-> 平台渲染与投递
```

Expand All @@ -21,20 +21,22 @@
- `WeatherContextProvider`:提供完整天气信息;
- `ContextCapabilityProvider`:补充空气质量、过敏原、预警或短时预报;
- `LLMProvider`:把上下文转换为平台无关的结构化结果;
- `NotificationDecisionProvider`:把带消息类型的候选交给对应通知策略;
- `DeliveryProvider`:渲染并发送结果;
- `SQLiteStateStore`:保存文章、简报、预警和运行状态。

包职责如下:

- `config` 在环境变量和私密文件边界完成解析与校验;
- `geocoding` 包含定位协议、候选匹配、外部服务适配器和缓存解析器;
- `weather` 包含平台无关协议、各天气服务适配器、能力组合和来源文档转换;
- `llm` 只包含模型协议、结构化 schema、any-llm 兼容适配器和结果解析,不依赖天气领域;
- `weather` 包含平台无关协议、各天气服务的请求适配器、独立响应解析、能力组合和来源文档转换;
- `llm` 只包含模型协议、结构化 schema、any-llm 兼容适配器、传输细节和结果解析,不选择消息类型或通知提示词;
- `notification_decision` 保存可独立迁移的判断契约、按类型分派的策略服务,以及每种消息自己的提示词;
- `delivery` 分离平台无关投递协议、渲染器和具体平台适配器;
- `application` 保存历史上下文预算、模型输入构造和输出契约修复等应用策略
- `composition` 负责根据配置组装外部服务,`cli` 只负责命令分派、运行生命周期和调度
- `persistence` 把 schema、固定格式序列化和运行时诊断与事务存储分开,业务结果仍由单个 `SQLiteStateStore` 原子提交;
- `data` 保存随程序发布的提示词、端点、分类和本地化资源,读取与领域校验由使用这些资源的功能模块负责
- `application` 保存历史上下文预算、模型输入构造、消息类型专属判断输入和输出契约修复等应用策略
- `composition` 按天气、投递、LLM 和通知策略分别组装外部服务;`cli` 只负责命令生命周期,参数解析、调度规则和运行时诊断各有独立模块
- `persistence` 按文章与简报、天气上下文、预警、健康状态和服务状态拆分操作,跨领域业务结果仍由单个 `SQLiteStateStore` 原子提交;
- `data` 保存随程序发布的内容生成提示词、端点、分类和本地化资源;通知判断提示词由对应的 `notification_decision` 功能包持有

包的 `__init__` 只导出有意支持的功能接口,测试直接引用行为的所有者模块。

Expand Down Expand Up @@ -163,23 +165,27 @@ Open-Meteo 的逐小时空气质量和花粉预报按目标日峰值生成生活

`ServiceStatusProvider` 与 `WeatherContextProvider` 同级,返回平台无关的 `ServiceStatusSnapshot`。每个厂商适配器在独立模块中;当前注册 DeepSeek、OpenAI、Anthropic 和 Kimi。运行时通过 `SERVICE_STATUS_PROVIDERS` 选择,默认启用全部,空值关闭。

四个 provider 都读取官方状态页的事件 feed。feed 的稳定事件标识与内容修订共同形成 revision ID,能够覆盖故障进展和明确恢复,又避免每五分钟下载完整历史 API。适配器从官方事件标题和受影响组件保守分类为 `web`、`api` 或 `other`;未知范围不做推断。状态页失败属于可选来源失败,不阻断其他状态源或天气任务。
四个 provider 都读取官方状态页的事件 feed。feed 的稳定事件标识、正文状态和受影响范围共同形成 revision ID,能够覆盖故障进展、影响范围变化和明确恢复,又避免每五分钟下载完整历史 API。适配器从官方事件标题和受影响组件保守分类为 `web`、`api` 或 `other`;未知范围不做推断。成功处理的状态、正文和受影响范围一同进入下一修订的通知判断。状态页失败属于可选来源失败,不阻断其他状态源或天气任务。

服务状态使用 `SERVICE_STATUS_CRON` 独立调度,默认 `*/5 * * * *`。调度器可以同时注册天气和服务状态任务;没有地点文件但显式配置了状态来源时只注册服务状态任务。两类任务只共享进程级状态锁,避免并发写入持久化状态。首次读取的已恢复历史只建立基线;新的官方消息先进入独立的通知价值判断,值得打扰用户时才投递。成功投递或明确判定无需通知后才记录 handled revision,因此投递失败可以重试。

通知价值判断是独立于采集、内容生成、翻译和投递的应用契约;天气简报与服务状态使用同一策略资源,但分别提供各自的当前事实和历史。服务状态没有预定义故障或恢复文本,标题和正文只来自官方消息。消息为英语或 `SERVICE_STATUS_LANGUAGE` 指定语言时原样转发,语言不匹配时只请求忠实翻译,失败时回退官方原文。`SERVICE_STATUS_PUBLISHERS` 接受逗号分隔的一个或多个平台,未配置时回退天气的单值 `PUBLISHER`;每个 revision 分平台记录投递结果,部分平台失败后的重试不会向已成功的平台重复发送。服务状态不进入天气 `SourceDocument`、历史预算或简报结构化输出。
通知价值判断独立于采集、内容生成、翻译和投递。调用方先构造 `NotificationAssessment(kind, payload)`,`NotificationDecisionService` 再按 `kind` 分派到一个明确注册的策略。天气简报和服务状态各自拥有判断输入、提示词和策略注册;策略协议也允许未来消息类型使用不依赖 LLM 的实现。模型适配器只执行策略给出的提示词与载荷,不知道消息类型,也不决定选用哪个提示词。天气预报和手动要求立即发送的可听简报固定投递,不调用通知价值判断;最后时段的兜底简报仍调用策略,以决定正常提醒还是无声投递。

成功投递天气简报时,状态库在同一事务中分别保存平台渲染后的历史正文和已通过验证的平台无关候选载荷。下一次天气通知判断比较平台无关候选,不从 Telegram、Bark 或其他渲染结果反推事实;迁移前没有候选载荷的旧记录回退到历史正文。

服务状态没有预定义故障或恢复文本,标题和正文只来自官方消息。消息为英语或 `SERVICE_STATUS_LANGUAGE` 指定语言时原样转发,语言不匹配时只请求忠实翻译,失败时回退官方原文。`SERVICE_STATUS_PUBLISHERS` 接受逗号分隔的一个或多个平台,未配置时回退天气的单值 `PUBLISHER`;每个 revision 分平台记录投递结果,部分平台失败后的重试不会向已成功的平台重复发送。服务状态不进入天气 `SourceDocument`、历史预算或简报结构化输出。

## 大语言模型

`AnyLLMStructuredProvider` 是 any-llm SDK 的薄适配器。模型厂商的认证、API Base、请求格式、超时和网络重试由 any-llm 及厂商 SDK 处理。
`AnyLLMStructuredProvider` 是 any-llm SDK 的薄适配器。它分别实现内容生成、通知布尔判断和服务状态翻译的窄结构化调用;JSON Object 请求拼装、异常归一化和 SDK 资源关闭由独立传输模块负责。模型厂商的认证、API Base、超时和网络重试由 any-llm 及厂商 SDK 处理。

`LLM_PROVIDER` 使用 any-llm 的 provider ID,`LLM_MODEL` 使用对应模型 ID。已部署的 DeepSeek 旧变量只在配置入口作为通用变量的后备。

`LLM_EXTRA_HEADERS` 和 `LLM_FALLBACK_EXTRA_HEADERS` 在配置入口解析为不可变的 HTTP header 映射,再由 any-llm 适配器作为 provider client 的 `default_headers` 构造参数传入。配置入口拒绝锁定 SDK 版本中不接受这个参数的 provider;未配置 header 时不传入该参数。

开发环境安装 `any-llm-sdk[all]`,用于验证所有 completion provider 的装载边界。基础运行依赖只包含 SDK 核心包。官方镜像额外安装 DeepSeek、OpenAI 和 OpenRouter 所需组件。

所有受支持 provider 统一请求 `json_object`,并把 Pydantic JSON Schema 加入最后一条用户消息,避免 OpenAI-compatible 端点只实现 JSON Mode 而拒绝 OpenAI `json_schema`。返回后再用同一 Pydantic 模型严格复验;应用还会检查来源 ID、必填建议、预警 ID 和章节间重复等领域规则。兼容性数据维护与锁定 any-llm SDK 对齐的不支持 JSON Object provider 黑名单,配置入口与 adapter factory 都拒绝黑名单内 provider,不为其他请求格式增加独立分支。
所有受支持 provider 统一请求 `json_object`,并把当前调用的 Pydantic JSON Schema 加入最后一条用户消息,避免 OpenAI-compatible 端点只实现 JSON Mode 而拒绝 OpenAI `json_schema`。内容生成 schema 不包含通知决定;候选消息生成并通过来源、建议、预警和渲染长度等领域验证后,天气通知策略才用只包含 `should_notify` 的独立 schema 判断。服务状态通知也使用这个窄输出 schema,但提示词和输入与天气完全分离。兼容性数据维护与锁定 any-llm SDK 对齐的不支持 JSON Object provider 黑名单,配置入口与 adapter factory 都拒绝黑名单内 provider,不为其他请求格式增加独立分支。

`LLM_MAX_ATTEMPTS` 只修复已经返回但不符合输出契约的正文。认证失败、限流、超时或空响应不进入契约修复。

Expand All @@ -195,7 +201,7 @@ CLI 负责关闭自己创建的模型服务对象及其网络资源。测试或

`run forecast --date YYYY-MM-DD` 把当地目标日期传给天气服务和模型。该参数不改变实际运行时间、状态写入时间或历史窗口。测试历史回放使用 `--at`,不能与 `--date` 混用。

每天最后一个 briefing 时段会查询当天是否已经成功发送过变化提醒。尚未发送时,即使模型认为无需提醒,也会投递一条无声消息。手动运行不使用无声投递。
每天最后一个 briefing 时段会查询当天是否已经成功发送过变化提醒。尚未发送时,即使天气通知策略判定无需提醒,也会投递一条无声消息。手动运行不使用无声投递。

模型返回平台无关的 `BriefingResult`。Telegram 和纯文本渲染器分别负责标题、链接、转义和长度限制。Bark 使用紧凑纯文本渲染器,将带来源编号的 headline 放入通知标题,正文不再重复标题,并以短编号关联末尾的来源名称表;来源 URL 省略,但仍保留逐项引用校验。

Expand Down
23 changes: 9 additions & 14 deletions tests/test_any_llm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
create_any_llm_provider,
)
from weather_briefing.llm.schema import NotificationDecisionOutput, ServiceStatusTranslationOutput
from weather_briefing.notifications import NotificationDecision
from weather_briefing.notification_decision import NotificationDecision


class _CompletionCall(TypedDict):
Expand Down Expand Up @@ -87,23 +87,23 @@ def _provider_status_error(response: httpx.Response) -> Exception:

async def test_service_status_llm_is_created_only_on_first_operation() -> None:
provider = AsyncMock()
provider.assess_notification.return_value = NotificationDecision(True)
provider.decide_notification.return_value = NotificationDecision(True)
provider.translate_service_status.return_value = ("Translated", "Translated body")
factory = AsyncMock(return_value=provider)
lazy = LazyServiceStatusLLM(factory)

await lazy.aclose()
factory.assert_not_called()

assert await lazy.assess_notification({"current": {}}) == NotificationDecision(True)
assert await lazy.decide_notification("notification prompt", {"current": {}}) == NotificationDecision(True)
assert await lazy.translate_service_status("Title", "Body", "en") == (
"Translated",
"Translated body",
)
await lazy.aclose()

factory.assert_awaited_once_with()
provider.assess_notification.assert_awaited_once_with({"current": {}})
provider.decide_notification.assert_awaited_once_with("notification prompt", {"current": {}})
provider.translate_service_status.assert_awaited_once_with("Title", "Body", "en")
provider.aclose.assert_awaited_once()

Expand All @@ -117,7 +117,6 @@ async def test_any_llm_provider_uses_json_object_with_the_strict_schema() -> Non
"resolved_warning_ids": [],
"advice": [],
"disaster_tracking": [],
"should_publish": True,
}
client = _CompletionClientStub(
SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=json.dumps(model_result)))])
Expand Down Expand Up @@ -236,11 +235,11 @@ async def test_any_llm_provider_assesses_notification_value_with_a_narrow_schema
max_output_tokens=4096,
)

result = await provider.assess_notification(
result = await provider.decide_notification(
"Decide whether this status change merits a notification.",
{
"notification_kind": "service_status",
"current": {"status": "monitoring"},
}
},
)

assert not result.should_notify
Expand Down Expand Up @@ -285,8 +284,8 @@ async def test_any_llm_provider_assesses_notification_value_with_a_narrow_schema
(
"openai",
_openai_bad_request,
"assess_notification",
({"current": {"status": "operational"}},),
"decide_notification",
("notification prompt", {"current": {"status": "operational"}}),
"LLM notification decision request failed",
),
(
Expand Down Expand Up @@ -388,7 +387,6 @@ async def test_provider_native_request_error_switches_to_fallback(monkeypatch) -
"resolved_warning_ids": [],
"advice": [],
"disaster_tracking": [],
"should_publish": True,
}
fallback_client = _CompletionClientStub(
SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=json.dumps(fallback_result)))])
Expand Down Expand Up @@ -646,7 +644,6 @@ async def test_openai_compatible_providers_send_configured_headers(
"resolved_warning_ids": [],
"advice": [],
"disaster_tracking": [],
"should_publish": True,
}

def handler(request: httpx.Request) -> httpx.Response:
Expand Down Expand Up @@ -722,7 +719,6 @@ async def test_openai_compatible_provider_sends_json_object_with_the_application
"resolved_warning_ids": [],
"advice": [],
"disaster_tracking": [],
"should_publish": True,
}

def handler(request: httpx.Request) -> httpx.Response:
Expand Down Expand Up @@ -792,7 +788,6 @@ async def test_any_llm_deepseek_uses_injected_logged_http_client(caplog) -> None
"resolved_warning_ids": [],
"advice": [],
"disaster_tracking": [],
"should_publish": True,
}

def handler(request: httpx.Request) -> httpx.Response:
Expand Down
Loading