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: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,15 @@ Usage notes:
- Keep this file limited to development judgment, workflow constraints, and lessons that future agents could otherwise miss. Link to the other documents instead of duplicating them.
- Update the document that owns a changed decision in the same change as the implementation.
- Use `docs/notes.md` to explain the rationale, trade-offs, and operating boundaries behind key architecture choices when the current contract alone would not make them clear. For accepted design concerns that remain intentionally unresolved, also state the assumptions that make the choice acceptable and concrete triggers for reevaluation; update or remove the note when those assumptions change.
- Keep local audit-risk documents limited to unresolved or actively monitored findings. Remove a finding in the same change that resolves it instead of retaining a completed-history section, and never add the local audit document to version control.

## Engineering judgment

- Design extension boundaries before adding provider-specific behavior. Keep core data platform-neutral and place vendor or delivery syntax in adapters. Prefer composition and thin subclasses to copied request logic or growing conditionals.
- Separate domain reference data from implementation. Geographic bounds, classification tables, matching patterns, and similar values belong in validated data files rather than Python constants.
- Treat privacy as broader than secret scanning. Locations, coordinates, private feed URLs, source content, state, and other contextual identifiers can expose a user even when they are not credentials. Use runtime configuration and public examples in committed code and tests.
- Keep dependencies minimal and justify every third-party package. Use high-level security interfaces for authentication and cryptography; do not implement protocols with low-level primitives when a maintained high-level library exists.
- Before implementing an external protocol, authentication flow, structured-response validator, retry policy, rate limiter, or service client, evaluate the provider's official SDK and mature maintained high-level libraries. Prefer a thin adapter over duplicating wire formats or reusable infrastructure. If a custom implementation is necessary, document the dependency, privacy, observability, or compatibility reason in `docs/notes.md`. Do not replace small domain-specific adapters or suitable standard-library code merely to reduce line count.
- Prefer timezone-aware Pendulum values in Python. Reject ambiguous timestamps, keep timezone assumptions at provider boundaries, and centralize unavoidable provider-specific fallback rules instead of spreading guesses through business logic.
- Do not retain compatibility paths for abandoned internal formats unless the current requirements explicitly require them.
- Keep code comments concise and in English.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ uv run --frozen weather-briefing run briefing

`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`。两套配置互不回退
LLM 调用由 any-llm SDK 承担。`LLM_PROVIDER` 使用 any-llm 的 provider ID,`LLM_MODEL` 是该 provider 接受的模型 ID;凭据、API Base 和其他认证配置直接使用 any-llm 为对应 provider 定义的环境变量。开发环境安装 `any-llm-sdk[all]` 以验证全部 provider,应用的基础运行依赖只安装 SDK 核心包;生产安装可按实际选择合并 extras,例如 `uv pip install 'weather-briefing' 'any-llm-sdk[deepseek,openai]>=1.19,<2'`。为兼容已有 DeepSeek 部署,`DEEPSEEK_MODEL` 可作为 `LLM_MODEL` 的后备,`DEEPSEEK_BASE_URL` 可作为 `DEEPSEEK_API_BASE` 的后备

应用将带时间、级别和 logger 名称的运行日志写入标准错误;INFO 日志记录每个地点的天气 provider 顺序和逻辑降级过程,并为天气、空气质量、地理编码、LLM、RSS、辅助上下文及 Telegram 的每个实际 HTTP 请求记录 provider、operation、方法、成功或失败、耗时和 HTTP 状态或异常类型,因此可从容器日志还原外部 API 调用历史。RSS 重试与 Telegram 分片分别按实际请求次数记录。常规 INFO 日志及仅由 `DEBUG=true` 启用的非敏感诊断不记录坐标、标题、正文、URL、token、chat ID、请求 endpoint 或异常消息;DEBUG 元数据覆盖从 RSS 清洗、权威预报转发和平台渲染到 Telegram 分片接受状态的链路。若仍需排查平台渲染或分片内容,可在不重启 daemon 的情况下临时记录完整渲染正文:

Expand Down
6 changes: 5 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

核心编排不判断具体 RSS 域名、地区、模型厂商、空气质量服务或投递平台。特殊全文转发规则来自私密 JSON 配置中的 `verbatim_title_patterns`;可选 `location_ids` 决定来源适用于哪些关注地点,空数组表示全部地点。

`OpenAICompatibleChatCompletionsProvider` 实现唯一的 OpenAI-compatible 请求逻辑,统一调用 `/chat/completions`。`DeepSeekProvider` 仅继承该实现并预置官方 Base URL,不覆盖请求或解析行为,因此部署只需提供 API Key 和模型名;OpenAI 或其他兼容服务使用通用 provider 并显式配置 Base URL。核心编排只依赖 `LLMProvider` 协议;未来接入 Anthropic 等非兼容 endpoint 时新增协议实现,不修改简报 service。原样转发规则与次日预报上下文规则分别由 `verbatim_title_patterns` 和 `forecast_title_patterns` 配置,避免来源特定标题进入代码。
`AnyLLMStructuredProvider` 是应用与 any-llm SDK 之间的薄适配器,只负责固定严格结构化输出 schema 并把 SDK 响应转换为应用字典;模型厂商的认证、API Base、请求协议和参数差异由 any-llm 及其官方 SDK 处理。`LLM_PROVIDER` 直接使用 SDK 声明的 provider ID,配置层只验证该 ID,不维护厂商分支或复制 provider 环境变量。DeepSeek 已投入使用的 `DEEPSEEK_MODEL` 与 `DEEPSEEK_BASE_URL` 只在配置边界作为通用 `LLM_MODEL` 与 SDK `DEEPSEEK_API_BASE` 的后备。

开发依赖安装 `any-llm-sdk[all]`,运行依赖只声明 SDK 核心包;部署者把选定的 any-llm extras 作为独立、锁定的安装要求与应用一起安装。这样仓库可以测试所有 provider 的装载边界,而生产镜像不必携带未使用厂商的 SDK。CLI 仅向继承 any-llm `BaseOpenAIProvider` 的 provider 注入现有 `LoggedAsyncClient` 并关闭 SDK 内部重试,因为该家族明确接受 OpenAI SDK 的 `http_client` 和 `max_retries`;其他官方 SDK 的客户端参数并不统一,应用不猜测或传入不兼容参数,其重试及实际 HTTP 状态遵循对应 SDK。service 的 `LLM_MAX_ATTEMPTS` 始终是外层请求与输出修复次数。
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`LLMStructuredOutput` 以 Pydantic 声明所有必填字段、类型、非空文本、来源数组和 advice topic,同时作为 any-llm 的 `response_format` 与 service 接收 mock/provider 输出后的复验 schema。厂商 SDK 的结构化输出能力负责生成和初次解析,本地 schema 防止测试替身或 provider 兼容差异绕过契约;来源 ID 是否属于本轮输入仍是运行时领域校验。核心编排只依赖领域端口 `LLMProvider`,它不负责任何厂商映射或 wire protocol;切换 SDK provider 不修改简报 service。原样转发规则与次日预报上下文规则分别由 `verbatim_title_patterns` 和 `forecast_title_patterns` 配置,避免来源特定标题进入代码。

## 正文清洗与全文转发

Expand Down
32 changes: 32 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

本文补充 [design.md](design.md),解释关键架构选择背后的理由、权衡和适用边界,尤其是仅从当前功能契约中不容易看出的运行规模或环境假设。它不是待办清单,也不复述正式设计;每条备忘应帮助维护者理解为什么采用当前方案,以及什么变化会使这个理由不再成立。涉及有意暂缓的有效设计问题时,还需要明确成立条件和重新评估触发点;假设变化时,应同步更新实现、正式设计文档和本文件。

## 审查时应保留的显式边界

### 配置与启动边界

DeepSeek 是唯一保留旧环境变量别名的 LLM provider:`DEEPSEEK_MODEL` 后备到 `LLM_MODEL`,`DEEPSEEK_BASE_URL` 后备到 any-llm 使用的 `DEEPSEEK_API_BASE`。这是已投入部署的输入兼容,不是厂商映射;新增 provider 应直接使用 any-llm 的 provider ID 和环境变量,不得把这一分支扩展成通用 provider 配置注册表。

日志初始化分为两个阶段。`main()` 在读取 Settings 前以非 DEBUG 级别建立基础 handler,保证参数或配置解析失败仍有统一日志;配置成功后再用实际 `DEBUG` 值更新级别。除非启动错误改由更外层的统一运行时接管,否则不应把两次调用合并。

### 领域模型与平台适配边界

`Article.id` 是带 Feed 身份的文章级稳定 ID,用于去重和模型引用;`Article.source_id` 是 Feed 配置 ID。同一内容出现在不同 Feed 时保留不同文章 ID,以维持来源隔离和可追溯性。只有在产品明确引入跨来源 canonical identity、并定义转载和更新版本的合并规则后,才应重新评估这一选择。

LLM schema 到 `BriefingResult` 的转换保持为领域模型之外的 `parse_result()`。领域 dataclass 不依赖 Pydantic 或 any-llm,provider 也不拥有来源 ID 归属规则。将转换放入任一对象都会让平台 SDK 或不可信输入校验进入错误的层级。

Telegram HTML 与纯文本 renderer 保留少量显式重复,publisher 数量只有两个时也保留清晰分支。平台 escaping、链接、attribution、可见长度和必填配置不同;在出现第三个共享相同骨架的平台前,不用 template method 或 registry 把差异隐藏进回调和条件分支。

### 错误、隐私与可选诊断边界

异常链是否保留取决于边界。CLI 先用 `logger.exception()` 保存 traceback,再以 `SystemExit(1) from None` 避免终端重复输出;RSS 最终错误隐藏可能包含私有 Feed URL 的 `HTTPError` 链,而统一 HTTP 客户端仍记录安全的异常类型和状态。不能把 `from None` 机械视为调试信息丢失,也不能在可能泄漏私密 endpoint 的路径统一恢复异常链。

渲染正文诊断是可选能力,其状态读取失败不得影响消息投递,因此 `_rendered_text_logging_enabled()` 有意捕获任意 `Exception`、记录 traceback 并按关闭处理。业务请求路径不享有这个例外;例如 LLM adapter 只转换 any-llm 的公开异常,适配器编程错误应原样暴露。

HTTPX `request.extensions` 是可扩展输入,不保证只由 `api_client.py` 写入。日志分类必须继续验证 tuple 形状、字符串类型和安全 label,防止 SDK、transport 或未来调用方把不可信对象或文本带入日志。

### 异步、文件与参考数据边界

Nominatim 限速使用 `await asyncio.sleep()`,它只挂起当前协程并把控制权交回事件循环,不是同步阻塞。改用其他异步 sleep API 不会改善当前 asyncio 应用的并发语义;只有运行时迁移到其他异步后端时才需要调整。

定位缓存先写固定临时文件再原子替换。替换失败会向上传播且旧缓存保持完整;遗留临时文件会被下次写入覆盖。只有观测到权限或磁盘错误造成不可接受的残留时,才需要增加清理分支。

大陆行政区排除规则在载入参考数据和比较 API 返回值时都使用 `casefold()`,不存在两边归一化不一致。空气质量分级数量很小且最后一档无上限,线性扫描比为 `bisect` 拆分阈值与结果更直接。provider UTC offset 接受整分钟的 ±14 小时范围,这是合法时区边界,不应收窄。

## 异步编排中的同步本地持久化

### 当前选择
Expand Down
4 changes: 2 additions & 2 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@

## 架构与隐私

1. OpenAI-compatible Chat Completions provider 承担唯一的兼容协议实现。DeepSeek provider 只允许作为继承该实现、预置 Base URL 的薄配置类,不复制请求逻辑;其他兼容服务通过 API Key、模型和 Base URL 配置切换。未来非兼容协议通过 `LLMProvider` 扩展。日常开发测试使用 mock,正式端到端测试才调用真实模型。
1. LLM 请求协议、厂商认证、API Base 和参数差异由 any-llm SDK 及其官方 provider SDK 承担;应用支持 SDK 声明且实现 completion 的全部 provider,只保留实现 `LLMProvider` 的薄适配器、严格结构化结果 schema、运行时来源校验和非敏感请求日志分类,不得自行拼装厂商 wire protocol。日常开发测试使用 mock,正式端到端测试才调用真实模型。
2. QWeather 使用官方推荐的 JWT 身份认证:运行时以 Base64 环境变量承载的 Ed25519 私钥 PEM 签发短期 Token,项目 ID、凭据 ID、API Host 和有效期均从环境变量注入。不得使用长期 API KEY 作为默认认证方式。
3. LLM provider、RSS source、正文 cleaner、天气与空气质量上下文服务、状态存储和投递 provider 均为可替换边界。LLM 输出必须先转换为平台无关的结构化结果,再由投递 provider 使用平台专用模板生成并发送消息;Telegram HTML 等平台语法不得进入 LLM 或核心编排。
4. 公开仓库不得包含凭据、真实关注地区、真实 RSS URL、经纬度、Webhook、地理编码缓存、历史简报或运行状态。仓库只维护无效或公开地点的结构示例。
5. 提交前运行通用秘密扫描,并对项目特有的隐私配置进行检查。
6. 需求变更应同步更新本文档。
7. 配置项应经过类型、范围和必填校验;provider、source 和状态存储保持明确扩展边界,避免厂商逻辑进入核心编排。
8. DeepSeek 使用 `DEEPSEEK_*` 配置,通用 OpenAI-compatible provider 使用 `LLM_*` 配置;RSS 只从命名 JSON 文件读取。SQLite 没有原生日期时间类型,应用仅在持久化边界将时区感知时间转换为固定宽度 UTC 文本,使文本字典序等同绝对时间顺序。
8. `LLM_PROVIDER` 使用 any-llm provider ID,`LLM_MODEL` 使用对应模型 ID,凭据与 API Base 使用 SDK 为该 provider 定义的环境变量。应用仅兼容已投入使用的 `DEEPSEEK_MODEL` 与 `DEEPSEEK_BASE_URL`。开发依赖安装 `any-llm-sdk[all]`;运行依赖只安装 SDK 核心包,由部署者显式、可复现地安装实际 provider extras。RSS 只从命名 JSON 文件读取。SQLite 没有原生日期时间类型,应用仅在持久化边界将时区感知时间转换为固定宽度 UTC 文本,使文本字典序等同绝对时间顺序。
9. 地理范围、空气质量分级与健康提示、正文清洗默认规则、provider 默认顺序及厂商指数代码等纯领域数据应存放在独立数据文件中,由实现代码加载并校验。
10. INFO 日志必须记录每个地点的有效天气 provider 顺序,并为每次天气 API 逻辑调用记录 provider、成功或失败、耗时、成功时的实际来源与观测时间,以及失败时不包含请求内容的阶段、HTTP 状态或异常类型,使自动降级可追溯。所有实际外部 HTTP 请求,包括天气、空气质量、地理编码、LLM、RSS、辅助上下文和 Telegram 投递,还必须统一记录静态 provider、operation、方法、成功或失败、耗时,以及 HTTP 状态或异常类型;重试和分片分别按实际请求次数记录。常规 INFO 日志及仅由 DEBUG 级别启用的非敏感诊断不得包含坐标、凭据、标题、正文、URL、接收方标识、私有 endpoint、响应正文或异常消息。DEBUG 非敏感诊断应以元数据覆盖 RSS 清洗、权威预报转发、平台渲染和分片投递边界,使运维可以根据来源、发布时间、字符数、分片数和平台接受状态定位内容丢失阶段。完整渲染正文属于敏感诊断数据,默认不得记录;运行时可以通过 CLI 临时启用、查询或关闭该行为,无需重启 daemon。启用时长必须为正且不超过 24 小时,到期自动失效。只有 DEBUG 日志级别与临时开关同时有效时才输出投递 provider 生成的完整消息及平台分片;这些文本可能包含来源内容、来源 URL、坐标和其他位置上下文,但仍不得包含投递凭据、接收方标识或请求 endpoint。诊断状态初始化或读取失败不得阻断正常投递。

Expand Down
13 changes: 5 additions & 8 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@
# python-dotenv and Docker Compose accept quoted values, but Docker CLI
# can pass the wrapping quotes into the container as literal characters.
#
# Required: select one configured LLM provider.
# Required: select an any-llm provider and model. Provider credentials and API
# bases use the environment names documented by any-llm for that provider.
LLM_PROVIDER=deepseek
# Required when LLM_PROVIDER=deepseek. Base URL is optional.
LLM_MODEL=deepseek-v4-flash
# DeepSeek example. DEEPSEEK_API_BASE is optional.
DEEPSEEK_API_KEY=replace-in-runtime-environment
DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_BASE_URL=
# Required when LLM_PROVIDER=openai-compatible.
LLM_API_KEY=
LLM_MODEL=
LLM_BASE_URL=
DEEPSEEK_API_BASE=
LLM_MAX_OUTPUT_TOKENS=8192
LLM_MAX_ATTEMPTS=3

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,20 @@ readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"any-llm-sdk>=1.19,<2",
"apscheduler>=3.11,<4",
"beautifulsoup4>=4.13,<5",
"feedparser>=6.0.11,<7",
"httpx[socks]>=0.27,<1",
"pendulum>=3.1,<4",
"pydantic>=2.12,<3",
"PyJWT[crypto]>=2.10,<3",
"python-dotenv>=1.1,<2",
]

[dependency-groups]
dev = [
"any-llm-sdk[all]>=1.19,<2",
"anyio>=4.11,<5",
"pytest>=9,<10",
"pytest-cov>=7,<8",
Expand Down
Loading