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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ uv run --frozen weather-briefing run hourly

`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`。两套配置互不回退。

应用将带时间、级别和 logger 名称的运行日志写入标准错误;设置 `DEBUG=true` 可输出 RSS 获取、LLM 重试,以及从 RSS 清洗、权威预报转发和平台渲染到 Telegram 分片接受状态的非敏感诊断信息。该链路只记录来源、发布时间、字符数和分片状态,不记录标题、正文、URL、token、chat ID 或请求 endpoint。若仍需排查平台渲染或分片内容,可在不重启 daemon 的情况下临时记录完整渲染正文:
应用将带时间、级别和 logger 名称的运行日志写入标准错误;INFO 日志记录每个地点的天气 provider 顺序,以及每次天气 API 尝试、成功、失败、耗时、实际来源和安全的失败原因,因此发生自动降级时可从容器日志还原调用历史。设置 `DEBUG=true` 可输出 RSS 获取、LLM 重试,以及从 RSS 清洗、权威预报转发和平台渲染到 Telegram 分片接受状态的非敏感诊断信息。该链路不记录坐标、标题、正文、URL、token、chat ID 或请求 endpoint。若仍需排查平台渲染或分片内容,可在不重启 daemon 的情况下临时记录完整渲染正文:

```bash
weather-briefing diagnostics rendered-text enable --for 15m
Expand Down
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ RSS 是可选补充源,其失败不影响任务成功率;天气 API 是主

小时 LLM 结果包含布尔字段 `should_publish`。模型比较当前及历史 API 快照,仅在降雨、显著天气变化、预警或灾害动态值得打扰时设为真;活动预警不允许与 false 同时出现。false 结果不投递消息,但当前快照、文章去重和预警状态仍持久化。

CLI 在读取运行配置前以 INFO 幂等配置单个标准错误 handler,配置成功后再按 `DEBUG` 更新级别,避免配置错误绕过统一格式、daemon 每轮任务重复追加 handler 或向 root logger 重复传播。默认记录生命周期、文章数量、陈旧来源和失败信息`DEBUG` 启用 RSS 获取及 LLM 重试诊断,并以非敏感元数据串联 RSS 清洗后的来源、发布时间、正文长度和权威预报标记,权威预报投递前后的来源、发布时间和正文长度,renderer 的可见与 payload 长度,以及 Telegram 分片总数、逐片长度和平台接受状态。这条链路不记录标题、正文、URL、token、chat ID 或请求 endpoint。业务层只给异常追加失败计数等上下文,完整堆栈由 CLI 入口或 APScheduler 单点记录。
CLI 在读取运行配置前以 INFO 幂等配置单个标准错误 handler,配置成功后再按 `DEBUG` 更新级别,避免配置错误绕过统一格式、daemon 每轮任务重复追加 handler 或向 root logger 重复传播。默认记录生命周期、文章数量、陈旧来源和失败信息。天气 provider 在组合边界由日志装饰器包装:先记录每个地点的有效 provider 顺序,再为每次逻辑调用记录 provider、结果、耗时、实际 source ID 与观测时间;主来源失败时记录经过收敛的阶段、HTTP 状态或异常类型,随后才由 fallback 组合继续。可选空气质量调用失败单独记录,不把可用天气结果改为失败。`DEBUG` 启用 RSS 获取及 LLM 重试诊断,并以非敏感元数据串联 RSS 清洗后的来源、发布时间、正文长度和权威预报标记,权威预报投递前后的来源、发布时间和正文长度,renderer 的可见与 payload 长度,以及 Telegram 分片总数、逐片长度和平台接受状态。这条链路不记录坐标、标题、正文、URL、token、chat ID 或请求 endpoint。业务层只给异常追加失败计数等上下文,完整堆栈由 CLI 入口或 APScheduler 单点记录。

完整渲染文本诊断采用第二道运行时开关,避免仅凭长期 `DEBUG` 配置泄露正文。`diagnostics rendered-text enable --for <duration>`、`status` 和 `disable` 直接读写 `BRIEFING_STATE_PATH` 中带 UTC 过期时间的 SQLite 状态;每次投递在记录前重新读取,因此另一个 CLI 进程的修改无需重启 daemon 即可生效。开关最长 24 小时,首次观察到过期状态时删除记录并发出警告。`DeliveryProvider` 记录 renderer 的完整输出,Telegram publisher 另记实际发送的每个分片,以区分渲染与传输边界;两处都只在 DEBUG 与开关同时有效时输出,并且不记录 token、chat ID 或请求 endpoint。诊断状态后端是可选能力:初始化或状态检查失败只记录警告并视为关闭,不改变消息投递。

Expand Down
2 changes: 1 addition & 1 deletion docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
7. 配置项应经过类型、范围和必填校验;provider、source 和状态存储保持明确扩展边界,避免厂商逻辑进入核心编排。
8. DeepSeek 使用 `DEEPSEEK_*` 配置,通用 OpenAI-compatible provider 使用 `LLM_*` 配置;RSS 只从命名 JSON 文件读取。SQLite 没有原生日期时间类型,应用仅在持久化边界将时区感知时间转换为固定宽度 UTC 文本,使文本字典序等同绝对时间顺序。
9. 地理范围、空气质量分级与健康提示、正文清洗默认规则、provider 默认顺序及厂商指数代码等纯领域数据应存放在独立数据文件中,由实现代码加载并校验。
10. DEBUG 日志应以非敏感元数据覆盖 RSS 清洗、权威预报转发、平台渲染和分片投递边界,使运维可以根据来源、发布时间、字符数、分片数和平台接受状态定位内容丢失阶段,而无需记录标题、正文、URL、投递凭据、接收方标识或私有 endpoint。完整渲染正文属于敏感诊断数据,默认不得记录;运行时可以通过 CLI 临时启用、查询或关闭该行为,无需重启 daemon。启用时长必须为正且不超过 24 小时,到期自动失效。只有 DEBUG 日志级别与临时开关同时有效时才输出正文,并覆盖投递 provider 生成的完整消息及平台分片。诊断状态初始化或读取失败不得阻断正常投递。
10. INFO 日志必须记录每个地点的有效天气 provider 顺序,并为每次天气 API 逻辑调用记录 provider、成功或失败、耗时、成功时的实际来源与观测时间,以及失败时不包含请求内容的阶段、HTTP 状态或异常类型,使自动降级可追溯。可选空气质量失败也必须记录。日志不得包含坐标、凭据、私有 endpoint 或响应正文。DEBUG 日志应以非敏感元数据覆盖 RSS 清洗、权威预报转发、平台渲染和分片投递边界,使运维可以根据来源、发布时间、字符数、分片数和平台接受状态定位内容丢失阶段,而无需记录标题、正文、URL、投递凭据、接收方标识或私有 endpoint。完整渲染正文属于敏感诊断数据,默认不得记录;运行时可以通过 CLI 临时启用、查询或关闭该行为,无需重启 daemon。启用时长必须为正且不超过 24 小时,到期自动失效。只有 DEBUG 日志级别与临时开关同时有效时才输出正文,并覆盖投递 provider 生成的完整消息及平台分片。诊断状态初始化或读取失败不得阻断正常投递。

## 投递假设

Expand Down
7 changes: 5 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@ async def test_single_provider_bypasses_fallback(self, async_client: httpx.Async
provider = _weather_context_provider(settings, async_client, location)
assert provider is not None

async def test_qweather_configured(self, async_client: httpx.AsyncClient) -> None:
async def test_qweather_configured(self, async_client: httpx.AsyncClient, caplog) -> None:
key = b"fake-private-key-content"
settings = _make_fake_settings(
weather_providers=("qweather",),
Expand All @@ -815,8 +815,11 @@ async def test_qweather_configured(self, async_client: httpx.AsyncClient) -> Non
qweather_base_url="https://qweather.example.invalid",
)
location = ResolvedLocation("test", "Test", 39.9, 116.3, "CN", "Beijing", "Asia/Shanghai", True)
provider = _weather_context_provider(settings, async_client, location)
with caplog.at_level("INFO", logger="weather_briefing"):
provider = _weather_context_provider(settings, async_client, location)
assert provider is not None
assert "Weather provider order providers=qweather" in caplog.text
assert "location=test" not in caplog.text


async def test_no_weather_provider_available(monkeypatch, async_client: httpx.AsyncClient) -> None:
Expand Down
108 changes: 94 additions & 14 deletions tests/test_weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from weather_briefing.weather_context import (
AirQualitySupplementingWeatherProvider,
FallbackWeatherContextProvider,
LoggedWeatherContextProvider,
OpenMeteoProvider,
QWeatherJWTAuthenticator,
QWeatherProvider,
Expand Down Expand Up @@ -267,6 +268,56 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh
assert await provider.fetch(1, 2) == expected


async def test_weather_provider_logs_failed_fallback_and_successful_call(caplog) -> None:
expected = WeatherContextSnapshot(
source_id="weather:fallback",
source_name="Fallback weather",
source_url="https://example.invalid/weather",
observed_at=pendulum.datetime(2026, 7, 13, 8, tz="UTC"),
weather_forecast=("forecast",),
)

class FailingProvider:
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot:
raise WeatherContextError("QWeather weather forecast failed: HTTP 401")

class SuccessfulProvider:
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot:
return expected

provider = FallbackWeatherContextProvider(
LoggedWeatherContextProvider("qweather", FailingProvider()),
LoggedWeatherContextProvider("open-meteo", SuccessfulProvider()),
)

with caplog.at_level("INFO", logger="weather_briefing.weather_context"):
assert await provider.fetch(1, 2) == expected

assert "Weather API call started provider=qweather" in caplog.text
assert "Weather API call failed provider=qweather" in caplog.text
assert "reason=QWeather weather forecast failed: HTTP 401" in caplog.text
assert "Weather API call succeeded provider=open-meteo" in caplog.text
assert "source_id=weather:fallback" in caplog.text


async def test_weather_provider_logs_unexpected_error_type_without_message(caplog) -> None:
class UnexpectedFailureProvider:
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot:
raise RuntimeError("sensitive upstream detail")

provider = LoggedWeatherContextProvider("test-provider", UnexpectedFailureProvider())

with (
caplog.at_level("WARNING", logger="weather_briefing.weather_context"),
pytest.raises(RuntimeError, match="sensitive upstream detail"),
):
await provider.fetch(1, 2)

assert "Weather API call failed provider=test-provider" in caplog.text
assert "reason=RuntimeError" in caplog.text
assert "sensitive upstream detail" not in caplog.text


async def test_missing_weather_air_quality_requires_optional_aqicn_configuration() -> None:
snapshot = WeatherContextSnapshot(
source_id="weather:test",
Expand Down Expand Up @@ -399,14 +450,39 @@ def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"Unexpected request: {request.url}")

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
with pytest.raises(WeatherContextError, match="non-success weather status"):
with pytest.raises(WeatherContextError, match="non-success weather status code=400"):
await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
base_url="https://api.example.invalid",
).fetch(1, 2)


async def test_qweather_does_not_log_untrusted_api_status(caplog) -> None:
untrusted_status = "400\nforged-log-entry"

def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"code": untrusted_status})

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
provider = LoggedWeatherContextProvider(
"qweather",
QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
base_url="https://api.example.invalid",
),
)
with (
caplog.at_level("WARNING", logger="weather_briefing.weather_context"),
pytest.raises(WeatherContextError, match="non-success weather status code=invalid"),
):
await provider.fetch(1, 2)

assert untrusted_status not in caplog.text
assert "reason=QWeather returned a non-success weather status code=invalid" in caplog.text


async def test_qweather_rejects_empty_daily_forecast() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v7/weather/3d":
Expand All @@ -425,7 +501,7 @@ def handler(request: httpx.Request) -> httpx.Response:
).fetch(1, 2)


async def test_qweather_air_quality_failure_is_silent() -> None:
async def test_qweather_air_quality_failure_is_logged_without_failing_weather(caplog) -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v7/weather/3d":
return _qweather_weather_response(fx_link="https://www.qweather.com/")
Expand All @@ -434,13 +510,15 @@ def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500)

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
snapshot = await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
base_url="https://api.example.invalid",
).fetch(1, 2)
with caplog.at_level("WARNING", logger="weather_briefing.weather_context"):
snapshot = await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
base_url="https://api.example.invalid",
).fetch(1, 2)

assert snapshot.air_quality is None
assert "provider=qweather operation=air-quality reason=HTTP 500" in caplog.text


async def test_open_meteo_rejects_empty_forecast() -> None:
Expand All @@ -460,7 +538,7 @@ async def test_open_meteo_rejects_empty_forecast() -> None:
await OpenMeteoProvider(client).fetch(1, 2)


async def test_open_meteo_air_quality_failure_is_silent() -> None:
async def test_open_meteo_air_quality_failure_is_logged_without_failing_weather(caplog) -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v1/forecast":
return httpx.Response(
Expand All @@ -487,9 +565,11 @@ def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500)

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
snapshot = await OpenMeteoProvider(client).fetch(1, 2)
with caplog.at_level("WARNING", logger="weather_briefing.weather_context"):
snapshot = await OpenMeteoProvider(client).fetch(1, 2)

assert snapshot.air_quality is None
assert "provider=open-meteo operation=air-quality reason=HTTP 500" in caplog.text


async def test_fallback_weather_provider_requires_at_least_one_provider() -> None:
Expand Down Expand Up @@ -580,7 +660,7 @@ def handler(request: httpx.Request) -> httpx.Response:

async def test_qweather_rejects_http_error() -> None:
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) as client:
with pytest.raises(WeatherContextError, match="request or response validation failed"):
with pytest.raises(WeatherContextError, match="weather forecast failed: HTTP 500"):
await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
Expand All @@ -594,7 +674,7 @@ def authorization_header(self) -> str:
raise jwt.PyJWTError("test jwt failure")

async with httpx.AsyncClient() as client:
with pytest.raises(WeatherContextError, match="request or response validation failed"):
with pytest.raises(WeatherContextError, match="authentication failed: PyJWTError"):
await QWeatherProvider(
client,
authenticator=FailingAuthenticator(),
Expand Down Expand Up @@ -638,7 +718,7 @@ def handler(request: httpx.Request) -> httpx.Response:

async def test_open_meteo_rejects_http_error() -> None:
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) as client:
with pytest.raises(WeatherContextError, match="request or response validation failed"):
with pytest.raises(WeatherContextError, match="weather forecast failed: HTTP 500"):
await OpenMeteoProvider(client).fetch(1, 2)


Expand Down Expand Up @@ -797,7 +877,7 @@ def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"Unexpected request: {request.url}")

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
with pytest.raises(WeatherContextError, match="request or response validation failed"):
with pytest.raises(WeatherContextError, match="lifestyle indices failed: ValueError"):
await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
Expand All @@ -822,7 +902,7 @@ def handler(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"Unexpected request: {request.url}")

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
with pytest.raises(WeatherContextError, match="request or response validation failed"):
with pytest.raises(WeatherContextError, match="weather forecast failed: ValueError"):
await QWeatherProvider(
client,
authenticator=StaticAuthenticator(),
Expand Down
7 changes: 6 additions & 1 deletion weather_briefing/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from .weather_context import (
AirQualitySupplementingWeatherProvider,
FallbackWeatherContextProvider,
LoggedWeatherContextProvider,
OpenMeteoProvider,
QWeatherJWTAuthenticator,
QWeatherProvider,
Expand Down Expand Up @@ -280,9 +281,13 @@ def _weather_context_provider(
if settings.weather_providers is not None:
raise ValueError("Explicit QWeather provider is missing JWT configuration")
continue
providers.append(_build_weather_provider(name, settings, client))
providers.append(LoggedWeatherContextProvider(name, _build_weather_provider(name, settings, client)))
if not providers:
raise ValueError("No configured weather provider is available")
_LOGGER.info(
"Weather provider order providers=%s",
",".join(name for name in names if name != "qweather" or _qweather_is_configured(settings)),
)
weather_provider: WeatherContextProvider = (
providers[0] if len(providers) == 1 else FallbackWeatherContextProvider(*providers)
)
Expand Down
Loading
Loading