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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ 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 重试等诊断信息。
应用将带时间、级别和 logger 名称的运行日志写入标准错误;设置 `DEBUG=true` 可输出 RSS 获取、LLM 重试,以及从 RSS 清洗、权威预报转发和平台渲染到 Telegram 分片接受状态的非敏感诊断信息。该链路只记录来源、发布时间、字符数和分片状态,不记录标题、正文、URL、token、chat ID 或请求 endpoint。若仍需排查平台渲染或分片内容,可在不重启 daemon 的情况下临时记录完整渲染正文:

```bash
weather-briefing diagnostics rendered-text enable --for 15m
weather-briefing diagnostics rendered-text status
weather-briefing diagnostics rendered-text disable
```

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

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

Expand Down
4 changes: 3 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ RSS 是可选补充源,其失败不影响任务成功率;天气 API 是主

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

CLI 在读取运行配置前以 INFO 幂等配置单个标准错误 handler,配置成功后再按 `DEBUG` 更新级别,避免配置错误绕过统一格式、daemon 每轮任务重复追加 handler 或向 root logger 重复传播。默认记录生命周期、文章数量、陈旧来源和失败信息;`DEBUG` 启用 RSS 获取及 LLM 重试诊断。业务层只给异常追加失败计数等上下文,完整堆栈由 CLI 入口或 APScheduler 单点记录。
CLI 在读取运行配置前以 INFO 幂等配置单个标准错误 handler,配置成功后再按 `DEBUG` 更新级别,避免配置错误绕过统一格式、daemon 每轮任务重复追加 handler 或向 root logger 重复传播。默认记录生命周期、文章数量、陈旧来源和失败信息;`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
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +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 生成的完整消息及平台分片。诊断状态初始化或读取失败不得阻断正常投递。

## 投递假设

Expand Down
85 changes: 81 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import base64
import logging
import sqlite3
from collections.abc import AsyncIterator
from dataclasses import replace
from pathlib import Path
Expand All @@ -22,6 +23,7 @@
_in_schedule,
_llm_provider,
_location_state_path,
_manage_rendered_text_diagnostics,
_parse_run_time,
_precision_reduction_notice,
_qweather_is_configured,
Expand Down Expand Up @@ -197,6 +199,69 @@ def test_version_flag() -> None:
parser.parse_args(["--version"])


def test_rendered_text_diagnostics_parser_accepts_bounded_duration() -> None:
args = build_parser().parse_args(["diagnostics", "rendered-text", "enable", "--for", "15m"])

assert args.diagnostics_action == "enable"
assert args.duration_seconds == 900


@pytest.mark.parametrize("duration", ("0m", "15", "25h"))
def test_rendered_text_diagnostics_parser_rejects_invalid_duration(duration: str) -> None:
with pytest.raises(SystemExit):
build_parser().parse_args(["diagnostics", "rendered-text", "enable", "--for", duration])


def test_main_manages_rendered_text_diagnostics_without_loading_service_settings(
monkeypatch,
tmp_path: Path,
capsys,
) -> None:
state_path = tmp_path / "state.sqlite3"
monkeypatch.setenv("BRIEFING_STATE_PATH", str(state_path))
monkeypatch.setattr("weather_briefing.cli.load_dotenv", lambda *, override: True)
monkeypatch.setattr("weather_briefing.cli._configure_logging", lambda *, debug: None)

monkeypatch.setattr(
"sys.argv",
["weather-briefing", "diagnostics", "rendered-text", "enable", "--for", "15m"],
)
main()
assert "enabled until" in capsys.readouterr().out

monkeypatch.setattr("sys.argv", ["weather-briefing", "diagnostics", "rendered-text", "status"])
main()
assert "is enabled until" in capsys.readouterr().out

monkeypatch.setattr("sys.argv", ["weather-briefing", "diagnostics", "rendered-text", "disable"])
main()
assert capsys.readouterr().out.strip() == "Rendered text diagnostic logging disabled"

monkeypatch.setattr("sys.argv", ["weather-briefing", "diagnostics", "rendered-text", "status"])
main()
assert capsys.readouterr().out.strip() == "Rendered text diagnostic logging is disabled"


@pytest.mark.parametrize(
("action", "duration", "message"),
(
("enable", None, "require a duration"),
("unsupported", None, "Unsupported rendered text diagnostics action"),
),
)
def test_rendered_text_diagnostics_reject_invalid_internal_requests(
action: str,
duration: int | None,
message: str,
monkeypatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("BRIEFING_STATE_PATH", str(tmp_path / "state.sqlite3"))

with pytest.raises(ValueError, match=message):
_manage_rendered_text_diagnostics(action, duration)


def test_main_loads_dotenv_with_supported_arguments(monkeypatch) -> None:
calls: list[bool] = []

Expand Down Expand Up @@ -417,7 +482,7 @@ async def test_run_skips_and_logs_when_enforce_window_outside_schedule(monkeypat
logging.root.setLevel(original_root_level)


async def test_run_logs_start_resolve_and_publish(monkeypatch, capsys) -> None:
async def test_run_continues_when_runtime_diagnostics_are_unavailable(monkeypatch, capsys) -> None:
from types import SimpleNamespace
from unittest.mock import patch

Expand All @@ -434,7 +499,11 @@ async def test_run_logs_start_resolve_and_publish(monkeypatch, capsys) -> None:

monkeypatch.setattr("weather_briefing.cli._parse_run_time", lambda v, t: now)
monkeypatch.setattr("weather_briefing.cli._in_schedule", lambda k, n, s: True)
monkeypatch.setattr("weather_briefing.cli._delivery_provider", lambda s, c: None)

def delivery_without_diagnostics(s: object, c: object, diagnostics: object) -> None:
assert diagnostics is None

monkeypatch.setattr("weather_briefing.cli._delivery_provider", delivery_without_diagnostics)
monkeypatch.setattr("weather_briefing.cli._llm_provider", lambda s, c: None)
monkeypatch.setattr("weather_briefing.cli._weather_context_provider", lambda s, c, loc: None)

Expand All @@ -453,6 +522,11 @@ def __exit__(self, *args: object) -> None:

monkeypatch.setattr("weather_briefing.cli.SQLiteStateStore", lambda p: FakeState())

def unavailable_diagnostics(path: Path) -> None:
raise sqlite3.OperationalError("database is locked")

monkeypatch.setattr("weather_briefing.cli.SQLiteRuntimeDiagnostics", unavailable_diagnostics)

async def fake_service_run(kind: str, n: object) -> str:
return "published body"

Expand All @@ -472,6 +546,7 @@ async def fake_service_run(kind: str, n: object) -> str:

stderr = capsys.readouterr().err
assert "Starting hourly briefing run" in stderr
assert "Runtime diagnostics unavailable; continuing without sensitive rendered text logging" in stderr
assert "Resolving 1 location(s)" in stderr
assert "Processing location test (Test City)" in stderr
assert "briefing published (14 characters)" in stderr
Expand Down Expand Up @@ -517,7 +592,7 @@ async def publish_alert(self, title: str, body: str) -> None:

monkeypatch.setattr("weather_briefing.cli._parse_run_time", lambda v, t: now)
monkeypatch.setattr("weather_briefing.cli._in_schedule", lambda k, n, s: True)
monkeypatch.setattr("weather_briefing.cli._delivery_provider", lambda s, c: AlertDelivery())
monkeypatch.setattr("weather_briefing.cli._delivery_provider", lambda s, c, d: AlertDelivery())
monkeypatch.setattr("weather_briefing.cli._llm_provider", lambda s, c: None)
monkeypatch.setattr("weather_briefing.cli._weather_context_provider", lambda s, c, loc: None)

Expand All @@ -535,6 +610,7 @@ def __exit__(self, *args: object) -> None:
pass

monkeypatch.setattr("weather_briefing.cli.SQLiteStateStore", lambda p: FakeState())
monkeypatch.setattr("weather_briefing.cli.SQLiteRuntimeDiagnostics", lambda p: FakeState())

async def fake_service_run(kind: str, n: object) -> str:
return "published body"
Expand Down Expand Up @@ -581,7 +657,7 @@ async def test_run_logs_skipped_when_no_content(monkeypatch, capsys) -> None:

monkeypatch.setattr("weather_briefing.cli._parse_run_time", lambda v, t: now)
monkeypatch.setattr("weather_briefing.cli._in_schedule", lambda k, n, s: True)
monkeypatch.setattr("weather_briefing.cli._delivery_provider", lambda s, c: None)
monkeypatch.setattr("weather_briefing.cli._delivery_provider", lambda s, c, d: None)
monkeypatch.setattr("weather_briefing.cli._llm_provider", lambda s, c: None)
monkeypatch.setattr("weather_briefing.cli._weather_context_provider", lambda s, c, loc: None)

Expand All @@ -599,6 +675,7 @@ def __exit__(self, *args: object) -> None:
pass

monkeypatch.setattr("weather_briefing.cli.SQLiteStateStore", lambda p: FakeState())
monkeypatch.setattr("weather_briefing.cli.SQLiteRuntimeDiagnostics", lambda p: FakeState())

async def fake_service_run(kind: str, n: object) -> str | None:
return None
Expand Down
96 changes: 92 additions & 4 deletions tests/test_publishers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ async def publish(self, message: RenderedMessage, *, single_message: bool = Fals
pass


class EnabledDiagnostics:
def rendered_text_logging_enabled(self) -> bool:
return True


class FailingDiagnostics:
def rendered_text_logging_enabled(self) -> bool:
raise RuntimeError("diagnostic state unavailable")


class CountingDiagnostics:
def __init__(self) -> None:
self.checks = 0

def rendered_text_logging_enabled(self) -> bool:
self.checks += 1
return True


class RecordingPublisher:
def __init__(self) -> None:
self.messages: list[RenderedMessage] = []

async def publish(self, message: RenderedMessage, *, single_message: bool = False) -> None:
self.messages.append(message)


def test_delivery_provider_applies_platform_limit_without_leaking_it_into_config() -> None:
unrestricted = DeliveryProvider(PlainTextRenderer(), NoopPublisher())
telegram_like = DeliveryProvider(PlainTextRenderer(), NoopPublisher(), 4096)
Expand All @@ -33,21 +60,82 @@ def test_split_message_prefers_line_boundary() -> None:
assert _split_message("first line\nsecond line", 12) == ("first line", "second line")


async def test_telegram_publisher_uses_runtime_values() -> None:
async def test_telegram_publisher_uses_runtime_values(caplog) -> None:
requests: list[httpx.Request] = []

def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"ok": True})

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
publisher = TelegramPublisher(client, "runtime-token", "runtime-chat")
await publisher.publish(RenderedMessage("<b>Title</b>\n\nBody", 11))
with caplog.at_level("DEBUG", logger="weather_briefing.publishers"):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
publisher = TelegramPublisher(client, "runtime-token", "runtime-chat", EnabledDiagnostics())
await publisher.publish(RenderedMessage("<b>Title</b>\n\nBody", 11))

assert requests[0].url.path == "/botruntime-token/sendMessage"
payload = json.loads(requests[0].content)
assert payload["chat_id"] == "runtime-chat"
assert payload["parse_mode"] == "HTML"
assert "Telegram delivery prepared: visible_characters=11 payload_characters=18 chunks=1" in caplog.text
assert "Telegram chunk accepted: index=1/1 payload_characters=18" in caplog.text
assert (
"Sensitive rendered text diagnostic: stage=telegram-chunk-1-of-1 body='<b>Title</b>\\n\\nBody'"
) in caplog.text
assert "runtime-token" not in caplog.text
assert "runtime-chat" not in caplog.text


async def test_telegram_checks_runtime_diagnostics_once_for_multiple_chunks(caplog) -> None:
diagnostics = CountingDiagnostics()
body = "x" * (TelegramPublisher.MAX_MESSAGE_LENGTH + 1)

with caplog.at_level("DEBUG", logger="weather_briefing.publishers"):
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200))) as client:
publisher = TelegramPublisher(client, "runtime-token", "runtime-chat", diagnostics)
await publisher.publish(RenderedMessage(body, len(body)))

assert diagnostics.checks == 1
assert caplog.text.count("Sensitive rendered text diagnostic: stage=telegram-chunk-") == 2


async def test_rendered_text_is_not_logged_without_runtime_diagnostics(caplog) -> None:
delivery = DeliveryProvider(PlainTextRenderer(), NoopPublisher())

with caplog.at_level("DEBUG", logger="weather_briefing.publishers"):
await delivery.publish_alert("Private diagnostic title", "Private diagnostic body")

assert "Private diagnostic" not in caplog.text


async def test_delivery_logs_rendered_text_when_runtime_diagnostics_are_enabled(caplog) -> None:
delivery = DeliveryProvider(PlainTextRenderer(), NoopPublisher(), diagnostics=EnabledDiagnostics())

with caplog.at_level("DEBUG", logger="weather_briefing.publishers"):
await delivery.publish_alert("Diagnostic title", "Diagnostic body")

assert "Sensitive rendered text diagnostic: stage=alert body='Diagnostic title\\n\\nDiagnostic body'" in caplog.text


async def test_runtime_diagnostics_are_checked_without_debug_logging(caplog) -> None:
diagnostics = CountingDiagnostics()
delivery = DeliveryProvider(PlainTextRenderer(), NoopPublisher(), diagnostics=diagnostics)

with caplog.at_level("INFO", logger="weather_briefing.publishers"):
await delivery.publish_alert("Private diagnostic title", "Private diagnostic body")

assert diagnostics.checks == 1
assert "Private diagnostic" not in caplog.text


async def test_runtime_diagnostic_failure_does_not_block_delivery(caplog) -> None:
publisher = RecordingPublisher()
delivery = DeliveryProvider(PlainTextRenderer(), publisher, diagnostics=FailingDiagnostics())

with caplog.at_level("DEBUG", logger="weather_briefing.publishers"):
await delivery.publish_alert("Diagnostic title", "Diagnostic body")

assert publisher.messages == [RenderedMessage("Diagnostic title\n\nDiagnostic body", 33)]
assert "Rendered text diagnostic state check failed" in caplog.text


async def test_telegram_error_does_not_expose_token() -> None:
Expand Down
Loading
Loading