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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ uv lock --check
uv sync --frozen
cp env.example .env
cp locations.example.json locations.json
uv run --frozen weather-briefing run hourly
uv run --frozen weather-briefing run briefing
```

`env.example` 将必填项、条件必填项和选填项分别写在注释中,所有凭据和投递标识均为无效占位值。复制 `locations.example.json` 为被 Git 忽略的 `locations.json` 后可配置多个地点;示例使用北京市西城区中南海的公开坐标。每项必须有稳定 `id` 和 `name`,`latitude` 与 `longitude` 可同时删除,此时程序用 Open-Meteo Geocoding 解析并把结果缓存到 `state/`。
Expand All @@ -58,14 +58,18 @@ QWeather 使用 Ed25519 JWT 认证。将控制台中的项目 ID、JWT 凭据 ID
Telegram 投递 provider 组合平台专用 HTML renderer 与 Bot API publisher,负责渲染粗体章节、预警和可点击来源链接并完成投递。日常简报限制为一条不超过 Telegram 4096 可见字符上限的消息;权威预报清洗正文单独完整发送。需要回放历史数据进行隔离测试时,可覆盖业务时间和状态数据库:

```bash
BRIEFING_STATE_PATH=state/replay.sqlite3 weather-briefing run daily --at 2026-07-11T08:00:00+08:00
BRIEFING_STATE_PATH=state/replay.sqlite3 weather-briefing run forecast --at 2026-07-11T08:00:00+08:00
```

## 调度

项目提供单一 OCI 镜像,不需要 Docker Compose。构建并运行常驻调度器:

日报默认在 `BRIEFING_TIMEZONE` 的 08:00 运行,可用 `GREETING_HOUR` 和 `GREETING_MINUTE` 调整。小时简报默认在 09:00–23:00 的整点运行;`BRIEFING_CRON` 是 APScheduler 的小时字段表达式,例如 `9-23`、`8,12,16` 或 `*/2`。启动常驻调度器时传入 `daemon --run-now` 可在建立定时任务前立即运行一次小时简报。
`run forecast` 生成当日预报,默认由 daemon 在 `BRIEFING_TIMEZONE` 的 08:00 调度,可用 `GREETING_HOUR` 和 `GREETING_MINUTE` 调整。`run briefing` 生成增量简报,daemon 默认在 09:00–23:00 的整点运行;`BRIEFING_CRON` 是 APScheduler 的小时字段表达式,例如 `9-23`、`8,12,16` 或 `*/2`。

已有 daemon 运行时,可从另一个进程执行 `weather-briefing run forecast --run-now` 或 `weather-briefing run briefing --run-now`。两种命令都不创建第二个 scheduler,忽略调度窗口,执行一次后退出;briefing 还会汇总并强制投递此前因不值得打扰而积压的信息。如果当天尚未成功投递 briefing,daemon 会在最后一个 `BRIEFING_CRON` 小时强制投递;Telegram 使用无声消息,避免在较晚时段打扰。

`run forecast --date YYYY-MM-DD` 可查看当地今天或指定未来日期(例如后天)的 forecast,也可以与 `--run-now` 组合。目标日期只影响要查询和总结的预报日期,实际运行时间、状态写入时间和历史窗口仍使用当前时间。`--at` 只在测试历史回放时覆盖实际运行时间。

```bash
cp env.example .env
Expand All @@ -84,8 +88,8 @@ docker run -d --name weather-briefing --restart unless-stopped \
也可在持久主机上使用 cron:

```cron
0 8 * * * cd /srv/weather-briefing && .venv/bin/weather-briefing run daily
0 9-23 * * * cd /srv/weather-briefing && .venv/bin/weather-briefing run hourly
0 8 * * * cd /srv/weather-briefing && .venv/bin/weather-briefing run forecast
0 9-23 * * * cd /srv/weather-briefing && .venv/bin/weather-briefing run briefing
```

## 安全
Expand Down
16 changes: 12 additions & 4 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@

RSS source 在构造 `Article` 前完成清洗。匹配全文转发规则但清洗后正文为空的条目不进入本轮文章集合,因此不会生成只有标题的消息,也不会被状态存储标记为已处理;来源后续返回有效正文时,同一条目仍可重新采集。非全文转发条目保持现有行为,由后续摘要流程决定是否使用。

默认 08:00 的日报与 09:00–23:00 的小时简报共用同一个 LLM 调用和结构化结果校验路径,两类调度均可由运行时环境调整。日报额外加载昨日权威预报、空气质量和生活指数并要求生成生活建议;小时简报只处理增量信息且禁止重复建议。
默认 08:00 的 `run forecast` 与 09:00–23:00 的 `run briefing` 共用同一个 LLM 调用和结构化结果校验路径,两类调度均可由运行时环境调整。forecast 额外加载昨日权威预报、空气质量和生活指数并要求生成生活建议;briefing 只处理增量信息且禁止重复建议。

## 调度与一次性运行

`daemon` 只负责建立 forecast 和 briefing 两类 APScheduler 任务并保持常驻,不接受立即运行参数。已有 daemon 时,运维从另一个进程执行 `run forecast --run-now` 或 `run briefing --run-now`;两条路径都复用对应的普通编排、忽略调度窗口、执行一次后退出,不创建第二个 scheduler。briefing 路径还显式覆盖 `should_publish=false`,投递积压文章与最新 API 上下文。

`run forecast --date YYYY-MM-DD` 将当地今天或未来目标日期作为独立参数传入 service、天气 provider 和 LLM payload,不修改当前运行时间。QWeather 从 3 日预报中选择目标日期;若目标超出响应范围则按 provider 组合规则降级。QWeather 的 1 日生活指数只在目标为响应中的首个预报日时请求,未来日期不依赖不会使用的指数接口。Open-Meteo 使用相同的 `start_date` 与 `end_date` 精确请求目标日。这样查看后天或其他未来日期不会把 SQLite 处理时间、RSS 健康状态或历史窗口写到未来。`--date` 只属于 forecast,且与仅供测试历史回放的运行时间覆盖参数 `--at` 互斥。

CLI 根据 `BRIEFING_CRON` 判断当天最后一个 briefing 小时,并在对应地点的 SQLite 状态中查询当地零点至当前时刻是否已有成功发布的 briefing。只有当天尚未发布时,service 才执行最后窗口兜底;LLM 仍正常总结与返回 `should_publish`,结果为 false 时覆盖为发送,并把 `silent=true` 沿 `BriefingService -> DeliveryProvider -> Publisher` 传递。Telegram publisher 将它映射为 Bot API 的 `disable_notification=true`;本来就值得立即投递的 true 结果保持正常通知,stdout 等其他 publisher 可以忽略静默提示。手动 `--run-now` 是用户主动触发,也不使用无声投递。

## 天气、空气质量与生活指数上下文

Expand Down Expand Up @@ -50,7 +58,7 @@ SQLite 保存:

- 文章 ID、来源、发布时间、标题、链接、正文及首次处理时间;
- 尚未被成功发布的文章;
- 已发布简报及类别
- 已发布简报、类别及发布时间
- LLM 返回的有效预警及最后确认时间;
- 每个 RSS 源最后见到文章的时间;
- 连续任务失败次数。
Expand All @@ -62,7 +70,7 @@ SQLite 保存:

应用只接受 Pendulum 的时区感知 `DateTime`,绝不把缺少明确时区的信息解释为时间点,也不依赖进程或服务器的本地时区。内存中的时间保留其明确时区,Pendulum 直接按绝对时间比较,不为比较提前转换。

`feedparser` 已将 RSS/Atom 的 `published_parsed` 和 `updated_parsed` 归一化为 UTC 时间元组,RSS source 只把该结果构造为 UTC `DateTime`。判断文章是否属于当地今天或昨天时,以配置的 IANA 时区构造日期起止边界,再直接与 UTC 发布时间比较,不逐条转换文章时间。调度窗口和消息展示使用地点时区。`--at` 必须带 `Z` 或明确 UTC 偏移,解析为确定时间点后只在调度边界转换到地点时区。
`feedparser` 已将 RSS/Atom 的 `published_parsed` 和 `updated_parsed` 归一化为 UTC 时间元组,RSS source 只把该结果构造为 UTC `DateTime`。判断文章是否属于当地今天或昨天时,以配置的 IANA 时区构造日期起止边界,再直接与 UTC 发布时间比较,不逐条转换文章时间。调度窗口和消息展示使用地点时区。测试历史回放使用的 `--at` 必须带 `Z` 或明确 UTC 偏移,解析为确定时间点后只在调度边界转换到地点时区;未来 forecast 的 `--date` 保持独立,不能污染该运行时间

天气与空气质量 adapter 在响应边界把可用时间规范化为时区感知值。`parse_datetime_with_default_timezone` 集中承担供应商适配:响应自带偏移时优先使用;QWeather 的无偏移时间按其服务契约采用 `Asia/Shanghai`;AQICN 优先使用响应中的 `time.tz`,否则采用所查询地区的时区;Open-Meteo 的本地时间使用同一响应中的 IANA `timezone` 解析。若只有固定偏移而没有时区名称,则保留该明确偏移,不猜测 IANA 地区。QWeather 实时空气质量响应没有观测时间,因此该字段仍可为空;没有时间值时不伪造观测时间。

Expand Down Expand Up @@ -102,7 +110,7 @@ RSS 是可选补充源,其失败不影响任务成功率;天气 API 是主

**行为**:终止当前任务,并立即通过投递 provider 发送一次性运维提醒。投递失败时在后续任务失败中继续重试;成功投递后,同一失败周期不再重复告警。成功的任务重置计数和告警状态。RSS 获取失败不计入任务失败计数。

小时 LLM 结果包含布尔字段 `should_publish`。模型比较上次成功发布以来累计的待处理文章、当前及历史 API 快照,仅在降雨、显著天气变化、预警新增或实质变化、灾害动态等值得打扰时设为真。持续生效但内容没有实质变化的预警允许与 false 同时出现,并继续保存在预警状态中。false 结果不投递消息;本轮文章进入独立的待处理集合,当前快照和预警状态照常持久化。后续小时或日报任务把全部待处理文章与新内容一起交给模型,且只有简报成功投递后才把本轮覆盖的待处理文章移入已处理文章集合。这样相邻小时都不显著、但从上次投递起已累积成显著变化的内容仍能触发一次合并简报。
briefing LLM 结果包含布尔字段 `should_publish`。模型比较上次成功发布以来累计的待处理文章、当前及历史 API 快照,仅在降雨、显著天气变化、预警新增或实质变化、灾害动态等值得打扰时设为真。持续生效但内容没有实质变化的预警允许与 false 同时出现,并继续保存在预警状态中。false 结果不投递消息;本轮文章进入独立的待处理集合,当前快照和预警状态照常持久化。后续 briefing 或 forecast 任务把全部待处理文章与新内容一起交给模型,且只有简报成功投递后才把本轮覆盖的待处理文章移入已处理文章集合。这样相邻小时都不显著、但从上次投递起已累积成显著变化的内容仍能触发一次合并简报。输入保留文章发布时间、API 内容中的观测时间,并把历史简报的类别、正文和发布时间组成结构化记录;系统提示要求模型在合并历史时淘汰被较新资料取代的气温、降水、风力、空气质量和短时预报,只保留当前仍有效的信息

CLI 在读取运行配置前以 INFO 幂等配置单个标准错误 handler,配置成功后再按 `DEBUG` 更新级别,避免配置错误绕过统一格式、daemon 每轮任务重复追加 handler 或向 root logger 重复传播。默认记录生命周期、文章数量、陈旧来源和失败信息。天气 provider 在组合边界由日志装饰器包装:先记录每个地点的有效 provider 顺序,再为每次逻辑调用记录 provider、结果、耗时、实际 source ID 与观测时间;主来源失败时记录经过收敛的阶段、HTTP 状态或异常类型,随后才由 fallback 组合继续。可选空气质量调用失败单独记录,不把可用天气结果改为失败。

Expand Down
12 changes: 7 additions & 5 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,23 @@
3. 小时简报不重复生活建议;需要投递时,当前生效气象预警必须单独成篇。预警仅仅持续生效且内容没有实质变化,不构成新的投递理由。
4. 跟踪可能影响关注地区的台风、海啸、地震等灾害,说明位置和未来运动或传播方向。
5. 每条结论附输入来源链接,便于核验。
6. 日报和小时简报都必须调用配置的 LLM provider;差异只体现在输入上下文和输出约束,不能用静态模板代替小时总结。
6. forecast 和 briefing 都必须调用配置的 LLM provider;差异只体现在输入上下文和输出约束,不能用静态模板代替增量总结。
7. CLI 使用 `run forecast` 生成当日预报、使用 `run briefing` 生成增量简报。两者都必须支持 `--run-now`,作为独立的一次性进程忽略调度窗口且不得启动 daemon;briefing 的 run-now 还必须强制投递上次成功发送以来因不值得打扰而积压的信息。forecast 必须支持用 `--date YYYY-MM-DD` 指定当地今天或未来目标日期(例如后天),并让天气 provider 与 LLM 只查询和总结该日期;目标日期不得改变实际运行、状态写入或历史查询时间。`--at` 只在测试历史回放时覆盖实际运行时间。
8. 当天尚未成功投递 briefing 时,到达最后一个配置发送小时必须无条件发送;仅当该兜底覆盖了 LLM 的不发送判断时,Telegram 才通过 Bot API 的无声消息参数投递。正常判断为需要发送的 briefing 与手动 `--run-now` 保持有声投递。

## 采集与记忆

1. 每轮只选择按配置时区判断为当天发布的文章。
2. 持久记录已处理文章,后续任务不重复总结。小时任务评估为不值得推送时,本轮尚未发布的文章必须作为待处理内容持久化,后续任务继续将其与新文章及此后的 API 快照一起总结;只有某轮简报成功投递后,所覆盖的待处理文章才能标记为已处理。待处理内容不得因为跨小时或跨日而丢失。
2. 持久记录已处理文章,后续任务不重复总结。briefing 任务评估为不值得推送时,本轮尚未发布的文章必须作为待处理内容持久化,后续任务继续将其与新文章及此后的 API 快照一起总结;只有某轮简报成功投递后,所覆盖的待处理文章才能标记为已处理。待处理内容不得因为跨小时或跨日而丢失。
3. 新总结同时利用此前已发送简报、权威预报原文与尚在生效的预警。
4. 标题包含可配置的权威预报关键词时,先清除 HTML 标签、脚本、评论、弹窗等页面噪声和独立时间戳,再完整转发正文语义内容,不截断、不总结;其内容进入第二天预报上下文。来源特有的署名等清理规则由私密运行配置提供。若清洗后正文为空,不得投递标题空壳,也不得将该条目标记为已处理,以便来源后续修正内容时重新采集。
5. 预警不能因一小时未被再次提及就自动消失;只有明确解除/降级信息或超过可配置保留窗口后才更新状态。
6. 允许接入天气或灾害服务 API 作为辅助上下文。
7. 日报和小时任务都通过可替换且可组合的天气 provider 获取 API 更新。未显式指定顺序时,中国大陆位置默认以 QWeather 为主要来源、Open-Meteo 为备用;其他地区默认只使用 Open-Meteo。运行环境可以显式指定任意天气 provider 顺序,首项即主要来源,后续项为备用;主来源请求或响应校验失败时按顺序降级。
7. forecast 和 briefing 都通过可替换且可组合的天气 provider 获取 API 更新。未显式指定顺序时,中国大陆位置默认以 QWeather 为主要来源、Open-Meteo 为备用;其他地区默认只使用 Open-Meteo。运行环境可以显式指定任意天气 provider 顺序,首项即主要来源,后续项为备用;主来源请求或响应校验失败时按顺序降级。
8. 天气 provider 返回预报、可用的生活指数及可选空气质量。若最终天气结果没有空气质量,则使用可选的 AQICN 配置补充;未配置 AQICN 且天气来源也未提供空气质量时,任务必须以明确配置错误失败并提醒用户。AQICN 不参与天气 provider 降级。
9. 不换算 AQI 或污染物浓度。每个 AQI 数值必须同时标明来源返回的指数标准;PM2.5 浓度只在来源直接提供原始浓度时展示。QWeather 的运动、穿衣、旅游、舒适度、交通等生活指数作为当日建议的输入。
10. RSS 是可选补充来源,从 `RSS_SOURCES_FILE` 指定的 JSON 文件读取;默认文件名为 `rss-sources.json`。每个来源可通过地点 ID 限定适用范围。文件不存在、为空数组或当前地点没有适用 RSS 时应用仍须正常运行,小时更新完全由天气 API 提供。仓库只提交 `rss-sources.example.json` 结构示例。
11. 小时任务将当前 API 快照、上次成功发布以来积累的未发布内容与持久化历史快照交给 LLM。是否值得打扰必须按上次成功发布后的累计变化评估,而不只比较相邻两次运行。只有即将降雨、显著温度或风力变化、预警新增、升级、降级、解除或内容实质变化,以及灾害动态等值得打扰的变化才发送消息;普通无变化结果和仅仅持续生效但无变化的预警应记录但不投递,并继续进入后续任务的总结范围,直到某轮成功投递。
10. RSS 是可选补充来源,从 `RSS_SOURCES_FILE` 指定的 JSON 文件读取;默认文件名为 `rss-sources.json`。每个来源可通过地点 ID 限定适用范围。文件不存在、为空数组或当前地点没有适用 RSS 时应用仍须正常运行,briefing 更新完全由天气 API 提供。仓库只提交 `rss-sources.example.json` 结构示例。
11. briefing 任务将当前 API 快照、上次成功发布以来积累的未发布内容与持久化历史快照交给 LLM。是否值得打扰必须按上次成功发布后的累计变化评估,而不只比较相邻两次运行。只有即将降雨、显著温度或风力变化、预警新增、升级、降级、解除或内容实质变化,以及灾害动态等值得打扰的变化才发送消息;普通无变化结果和仅仅持续生效但无变化的预警应记录但不投递,并继续进入后续任务的总结范围,直到某轮成功投递。合并积压文章、历史简报和历史 API 快照时,模型必须先判断信息的时效性;气温、降水、风力、空气质量和短时预报等已被较新资料取代的内容不得进入当前简报,始终以时间最新且仍适用于当前时刻的资料为准
12. 用户可以在私密 JSON 文件中配置一个或多个关注地点。每个地点必须提供稳定 ID 和地名,经纬度为可选的一对字段;国家和行政区代码由定位层解析。
13. 地名缺少经纬度时,通过可替换且可组合的 geocoding provider 查询 WGS84 坐标、国家、行政区和时区,并在被 Git 忽略的运行状态目录缓存结果。默认先使用无需 API Key 的 Open-Meteo Geocoding 免费非商业 endpoint,详细地名无法解析时降级到 OpenStreetMap Nominatim。完整地名经过全部 provider 仍无法解析时,应按数据驱动规则逐级降低查询精度,每一级重新经过完整 provider 顺序。首次以较低精度匹配后,必须向投递渠道发送 provider 返回的匹配地名及经纬度,要求用户确认并建议将坐标写入私密地点文件;缓存命中不重复提醒。两个 Base URL、Open-Meteo 可选商业 API Key 及 Nominatim User-Agent 均可配置。
14. 已提供经纬度时不得调用 geocoding API。程序可以用包含海南的中国大陆服务范围四至宽松包围盒,快速排除显然不在中国大陆的坐标;包围盒命中只代表“可能位于”,不是精确国界判断。地名解析结果应优先依据 provider 返回的国家与行政区判定中国大陆分支。
Expand Down
Loading
Loading