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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- 标题匹配配置规则的权威预报文章经 HTML 与页面噪声清洗后完整独立转发,并进入后续预报上下文。
- 日报通过可组合 provider 加入 API 天气预报、AQI、指数标准、PM2.5 浓度、生活指数及花粉过敏原信息,并用于穿衣、运动和口罩建议。
- 中国大陆天气默认使用 QWeather、Open-Meteo 的降级顺序,其他地区默认只使用 Open-Meteo;也可通过 `WEATHER_PROVIDERS` 显式指定主要来源和其他备用来源。
- 支持多个关注地点;只给地名时通过 Open-Meteo Geocoding 解析并缓存坐标与国家信息,已有坐标时不发起地理编码请求
- 支持多个关注地点;只给地名时正向解析并缓存坐标与国家信息,只给坐标时反查地点名和行政信息,名称与坐标齐全时不发起定位请求
- 完整地名无法解析时按可配置规则逐级降低查询精度;首次匹配会投递实际匹配地名和坐标,请用户确认并写回私密地点文件。
- 天气来源缺少空气质量时才使用可选 AQICN;两者都无法提供空气质量时给出明确配置错误。
- RSS 来源从可选 `rss-sources.json` 加载;没有该文件时小时任务仍由天气 API 正常运行。
Expand All @@ -35,7 +35,7 @@ cp locations.example.json locations.json
uv run --frozen weather-briefing run briefing
```

`env.example` 将必填项、条件必填项和选填项分别写在注释中,所有凭据和投递标识均为无效占位值。复制 `locations.example.json` 为被 Git 忽略的 `locations.json` 后可配置多个地点;示例使用北京市西城区中南海的公开坐标。每项必须有稳定 `id``name``latitude``longitude` 可同时删除,此时程序用 Open-Meteo Geocoding 解析并把结果缓存到 `state/`。
`env.example` 将必填项、条件必填项和选填项分别写在注释中,所有凭据和投递标识均为无效占位值。复制 `locations.example.json` 为被 Git 忽略的 `locations.json` 后可配置多个地点;示例使用北京市西城区中南海的公开坐标。每项必须有稳定 `id`,并在 `name` 与成对的 `latitude``longitude` 之间至少提供一项:只有名称时程序正向解析并支持降精度回退,只有坐标时通过 Nominatim 反查规范地点名和行政信息,两者都有时不发起定位请求。解析结果缓存到 `state/`。

`LLM_PROVIDER=deepseek` 使用 `DEEPSEEK_API_KEY`、`DEEPSEEK_MODEL` 和可选的 `DEEPSEEK_BASE_URL`;DeepSeek provider 已预置官方 Base URL。`LLM_PROVIDER=openai-compatible` 使用 `LLM_API_KEY`、`LLM_MODEL` 和 `LLM_BASE_URL`。两套配置互不回退。

Expand All @@ -49,7 +49,7 @@ weather-briefing diagnostics rendered-text disable

容器部署通过同一运行实例执行,例如 `docker exec weather-briefing weather-briefing diagnostics rendered-text enable --for 15m`。该开关最长启用 24 小时并自动过期,状态保存在 `BRIEFING_STATE_PATH`。只有同时启用 `DEBUG` 和临时开关时才记录正文;日志包含简报、告警、权威预报以及 Telegram 分片的完整文本,可能暴露来源内容、来源 URL、坐标和其他位置上下文,排障后应立即关闭并妥善保护日志。token、chat ID 和请求 endpoint 不会写入这些诊断日志。

定位层从地名解析国家或行政区代码。Open-Meteo 负责城市/邮编查询,空结果时由 OpenStreetMap Nominatim 解析详细地名;结果会持久缓存。已有坐标时使用中国大陆服务范围四至宽松包围盒作快速可能性判断。省略 `WEATHER_PROVIDERS` 时,中国大陆地点使用 QWeather、Open-Meteo,其他地点只使用 Open-Meteo;显式配置时首项是主要来源,后续项依次作为备用。
定位层把名称或坐标补全为统一地点信息。Open-Meteo 负责城市/邮编正向查询,空结果时由 OpenStreetMap Nominatim 解析详细地名;只有坐标时由 Nominatim 反向查询规范地点名、国家和行政区。名称与坐标齐全时不请求定位服务,并使用中国大陆服务范围四至宽松包围盒作快速可能性判断;所有查询结果都会持久缓存。省略 `WEATHER_PROVIDERS` 时,中国大陆地点使用 QWeather、Open-Meteo,其他地点只使用 Open-Meteo;显式配置时首项是主要来源,后续项依次作为备用。

RSS 为可选补充数据。需要使用时复制 `rss-sources.example.json` 为被 Git 忽略的 `rss-sources.json` 并填写真实来源;其中 `name` 使用公众号、微博账号或发布机构等会显示给用户的公开名称。不创建该文件即可只使用天气 API。

Expand Down
4 changes: 2 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ CLI 根据 `BRIEFING_CRON` 判断当天最后一个 briefing 小时,并在对

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

`LocationSpec` 来自被 Git 忽略的 `BRIEFING_LOCATIONS_FILE`,支持多个地点且强制稳定 `id` 和完整 `name`。`CachedLocationResolver` 对已有经纬度直接构造 `ResolvedLocation`;缺少经纬度时调用 `GeocodingProvider`,并把解析结果写入运行状态目录。正向路径由 `PrecisionReducingGeocodingProvider` 包装 `FallbackGeocodingProvider`,先让 Open-Meteo 和 Nominatim 依次尝试完整名称,全部失败后才按数据驱动规则逐级降低查询精度。
`LocationSpec` 来自被 Git 忽略的 `BRIEFING_LOCATIONS_FILE`,支持多个地点并强制稳定 `id`;用户必须在 `name` 与完整经纬度之间至少提供一项。`CachedLocationResolver` 将三种输入收敛为名称和坐标都完整的 `ResolvedLocation`:只有名称时执行正向解析,只有坐标时执行反向解析,两者都有时直接采用配置值。正向路径由 `PrecisionReducingGeocodingProvider` 包装 `FallbackGeocodingProvider`,先让 Open-Meteo 和 Nominatim 依次尝试完整名称,全部失败后才按数据驱动规则逐级降低查询精度。反向路径通过独立 `ReverseGeocodingProvider` 协议调用 Nominatim `/reverse`,使用 WGS84 坐标取得最接近的 OSM 地址对象、规范展示名、国家代码和行政区;反向结果不经过正向降精度规则。正反向结果都写入被 Git 忽略的定位缓存

service 将用户填写的完整地点名作为 `location_scope.full_name` 交给模型定位结果中已知的行政区和国家代码只作为可选提示,未知字段不写入 payload,也不得由模型猜测。模型以完整地点名为主,按当地行政区划语义识别上级范围,不在核心代码中硬编码省、市、区或国外行政层级的名称与顺序。完整地点或其任一上级行政区受影响时相关,同级或下级的其他地区不相关。经纬度用于天气 API;由于 RSS 正文没有统一结构化灾害边界,灾害地域语义判断留在 LLM 输出契约中,核心编排不加入地区关键词表或供应商专用分支。
service 将最终解析得到的完整地点名作为 `location_scope.full_name` 交给模型;用户提供名称时保留该名称,只有坐标时使用反向解析的规范展示名。定位结果中已知的行政区和国家代码只作为可选提示,未知字段不写入 payload,也不得由模型猜测。模型以完整地点名为主,按当地行政区划语义识别上级范围,不在核心代码中硬编码省、市、区或国外行政层级的名称与顺序。完整地点或其任一上级行政区受影响时相关,同级或下级的其他地区不相关。经纬度用于天气 API;由于 RSS 正文没有统一结构化灾害边界,灾害地域语义判断留在 LLM 输出契约中,核心编排不加入地区关键词表或供应商专用分支。

`PrecisionReducingGeocodingProvider` 包装完整 provider 链:首先用原始地名查询,所有 provider 均失败后才按 `geography.json` 中明确标注为中国大陆地名格式的规则逐级移除门牌、建筑或更细粒度片段,每个候选仍重新经过完整 provider 链。结果同时保留用户原始地名、provider 实际匹配地名及是否降低精度。首次降精度匹配时,CLI 通过当前投递 provider 发送匹配地名、经纬度和写回私密地点文件的建议;缓存结果不重复通知。

Expand Down
6 changes: 3 additions & 3 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
9. 不换算 AQI 或污染物浓度。每个 AQI 数值必须同时标明来源返回的指数标准;PM2.5 只在来源直接提供浓度和单位时展示。QWeather 的运动、穿衣、旅游、舒适度、交通等生活指数作为当日建议的输入。
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 返回的国家与行政区判定中国大陆分支。
12. 用户可以在私密 JSON 文件中配置一个或多个关注地点。每个地点必须提供稳定 ID,并在地名与完整经纬度之间至少提供一项;经纬度只能成对出现。只提供地名时执行正向解析,只提供经纬度时执行反向解析得到规范地点名、国家和行政区;两者都提供时直接使用配置坐标和用户地名,不发起额外定位请求
13. 地名缺少经纬度时,通过可替换且可组合的 geocoding provider 查询 WGS84 坐标、国家、行政区和时区,并在被 Git 忽略的运行状态目录缓存结果。默认先使用无需 API Key 的 Open-Meteo Geocoding 免费非商业 endpoint,详细地名无法解析时降级到 OpenStreetMap Nominatim。完整地名经过全部 provider 仍无法解析时,应按数据驱动规则逐级降低查询精度,每一级重新经过完整 provider 顺序。只有经纬度时,通过独立的 Nominatim reverse-geocoding 查询最接近的 OSM 地址对象,并缓存规范名称和行政信息。首次以较低精度正向匹配后,必须向投递渠道发送 provider 返回的匹配地名及经纬度,要求用户确认并建议将坐标写入私密地点文件;缓存命中不重复提醒。两个 Base URL、Open-Meteo 可选商业 API Key 及 Nominatim User-Agent 均可配置。
14. 地名和经纬度同时提供时不得调用 geocoding API;只有经纬度时只执行反向解析,不得再发起正向解析。程序可以用包含海南的中国大陆服务范围四至宽松包围盒,快速排除显然不在中国大陆的坐标;包围盒命中只代表“可能位于”,不是精确国界判断。地名解析结果应优先依据 provider 返回的国家与行政区判定中国大陆分支。
15. 多地点的文章去重、历史简报、天气快照、预警和任务健康状态必须隔离,某一地点的记忆不得污染其他地点。
16. 应用时间值必须使用 Pendulum 的时区感知类型,绝不接受或处理无明确时区的时间。`feedparser` 归一化后的 RSS UTC 时间保持不变;当地日期筛选以地点的 IANA 时区构造日界线后直接比较时间点,只有调度和用户展示等需要当地语义的边界才转换时区。SQLite 持久化边界将时间转换为固定宽度 UTC 文本,使文本字典序与绝对时间顺序一致。

Expand Down
16 changes: 11 additions & 5 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,23 +142,29 @@ def test_rss_source_treats_null_optional_arrays_as_empty(monkeypatch, tmp_path:
assert feed.location_ids == ()


def test_location_file_supports_multiple_places_and_optional_coordinates(monkeypatch, tmp_path: Path) -> None:
def test_location_file_supports_name_coordinates_or_both(monkeypatch, tmp_path: Path) -> None:
_required_environment(monkeypatch)
location_file = tmp_path / "locations.json"
location_file.write_text(
'[{"id":"beijing","name":"北京市西城区中南海"},'
'{"id":"beijing-fixed","name":"北京市西城区中南海",'
'"latitude":39.911389,"longitude":116.380556}]',
'"latitude":39.911389,"longitude":116.380556},'
'{"id":"coordinates-only","latitude":39.911389,"longitude":116.380556}]',
encoding="utf-8",
)
monkeypatch.setenv("BRIEFING_LOCATIONS_FILE", str(location_file))
monkeypatch.setenv("RSS_SOURCES_FILE", str(tmp_path / "rss-sources.json"))

settings = Settings.from_env()

assert [location.id for location in settings.locations] == ["beijing", "beijing-fixed"]
assert [location.id for location in settings.locations] == [
"beijing",
"beijing-fixed",
"coordinates-only",
]
assert settings.locations[0].latitude is None
assert settings.locations[1].longitude == 116.380556
assert settings.locations[2].name is None


def test_rss_source_location_ids_must_reference_configured_locations(monkeypatch, tmp_path: Path) -> None:
Expand Down Expand Up @@ -419,14 +425,14 @@ def test_duplicate_location_id_raises_error(self, monkeypatch, tmp_path: Path) -
with pytest.raises(ConfigurationError, match="Duplicate location id"):
Settings.from_env()

def test_location_without_name_raises_error(self, monkeypatch, tmp_path: Path) -> None:
def test_location_without_name_or_coordinates_raises_error(self, monkeypatch, tmp_path: Path) -> None:
_required_environment(monkeypatch)
location_file = tmp_path / "locations.json"
location_file.write_text('[{"id":"beijing"}]', encoding="utf-8")
monkeypatch.setenv("BRIEFING_LOCATIONS_FILE", str(location_file))
monkeypatch.setenv("RSS_SOURCES_FILE", str(tmp_path / "rss-sources.json"))

with pytest.raises(ConfigurationError, match="must have a name"):
with pytest.raises(ConfigurationError, match="must provide a name or coordinates"):
Settings.from_env()

def test_mismatched_lat_lon_raises_error(self, monkeypatch, tmp_path: Path) -> None:
Expand Down
Loading