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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Usage notes:
- Keep current requirements and architecture in `docs/`. Describe the current contract directly rather than narrating superseded designs.
- 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.

## Engineering judgment

Expand Down
35 changes: 35 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 架构备忘

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

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

### 当前选择

`SQLiteStateStore`、`SQLiteRuntimeDiagnostics` 和 `CachedLocationResolver` 继续使用同步 SQLite 或文件系统调用。它们直接运行在异步任务所在的事件循环线程中,不使用 `asyncio.to_thread`,也不引入异步 SQLite 依赖。

这个选择接受了短时间阻塞事件循环的代价。当前任务按小时级频率调度,每次运行按顺序处理地点;状态历史受保留窗口限制,定位缓存也只保存少量配置地点。正常情况下,本地 SQLite 事务和缓存文件读写远短于天气、RSS、地理编码和 LLM 网络请求,因此额外的线程或依赖不会改善用户可感知的主要延迟。

### 成立条件

- 状态数据库和定位缓存位于低延迟的本地磁盘或等效持久卷,而不是高延迟网络文件系统。
- 单次运行仍按顺序处理地点,调度任务维持低频;应用不承担高并发请求服务的职责。
- 历史保留窗口继续限制数据库规模,写事务保持短小。
- 没有观测到持续的事件循环停顿、SQLite 锁等待或任务错过调度窗口。

手动一次性运行可以与 daemon 访问同一状态文件,因此 SQLite 锁竞争仍可能发生。当前实现依赖短事务和 SQLite 自身的进程间锁,而不是承诺并发写入吞吐量。

### 重新评估触发点

出现以下任一情况时,应测量事件循环延迟和数据库操作耗时,并重新设计持久化边界:

- 地点开始并行处理,或 forecast、briefing、手动运行之间出现常态化并发;
- 调度频率、地点数量、历史保留量或单次写入量显著增加;
- 状态文件迁移到网络存储,或监控显示文件 I/O 延迟不可忽略;
- 出现可复现的调度漂移、消息投递延迟、数据库锁超时或其他事件循环阻塞证据。

### 未来改造约束

不能只把现有方法直接包进 `asyncio.to_thread`。`sqlite3` 连接默认具有线程亲和性,连接的创建、使用和关闭必须位于兼容的线程边界;并发写操作还需要保持现有事务原子性和确定顺序。定位缓存的临时文件替换同样需要防止并发写入互相覆盖。

若触发改造,优先把同步持久化封装到专用串行 worker 或重新定义状态存储 adapter,并用实际延迟数据判断是否值得引入新依赖。改造必须保留每轮成功记录与历史清理的事务边界、多地点状态隔离、缓存原子替换以及现有错误语义。
49 changes: 49 additions & 0 deletions tests/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,35 @@ def test_rejects_warning_entry_not_a_dict() -> None:
)


@pytest.mark.parametrize("field", ("id", "title", "status", "detail"))
@pytest.mark.parametrize("value", (None, "", " ", 42))
def test_rejects_warning_without_required_text(field: str, value: object) -> None:
warning = {
"id": "w1",
"title": "Warning",
"status": "active",
"detail": "Details",
"source_ids": ["source"],
}
warning[field] = value
payload = {
"headline": "Briefing",
"headline_source_ids": ["source"],
"conclusions": [],
"active_warnings": [warning],
"resolved_warning_ids": [],
"advice": [],
"disaster_tracking": [],
}

with pytest.raises(LLMError, match=rf"active_warnings entries: {field} must be a non-empty string"):
parse_result(
payload,
pendulum.datetime(2026, 7, 13, 9, tz="Asia/Shanghai"),
{"source"},
)


def test_rejects_warning_without_source_ids() -> None:
payload = {
"headline": "Briefing",
Expand Down Expand Up @@ -261,6 +290,26 @@ def test_rejects_warning_with_unknown_source_id() -> None:
)


@pytest.mark.parametrize("headline", (None, "", " ", 42))
def test_rejects_result_without_headline_text(headline: object) -> None:
payload = {
"headline": headline,
"headline_source_ids": ["source"],
"conclusions": [],
"active_warnings": [],
"resolved_warning_ids": [],
"advice": [],
"disaster_tracking": [],
}

with pytest.raises(LLMError, match="headline must be a non-empty string"):
parse_result(
payload,
pendulum.datetime(2026, 7, 13, 9, tz="Asia/Shanghai"),
{"source"},
)


@pytest.mark.parametrize("resolved_warning_ids", [None, "warning", [1], [""]])
def test_rejects_malformed_resolved_warning_ids(resolved_warning_ids: object) -> None:
payload = {
Expand Down
17 changes: 12 additions & 5 deletions tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1135,16 +1135,17 @@ async def test_stale_feed_triggers_ops_alert(tmp_path: Path) -> None:


class FailingOnceLLM:
def __init__(self, *, fail_before_response: bool = False) -> None:
def __init__(self, *, fail_before_response: bool = False, omit_headline: bool = False) -> None:
self.attempts = 0
self._fail_before_response = fail_before_response
self._omit_headline = omit_headline

async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]:
self.attempts += 1
if self.attempts == 1:
if self._fail_before_response:
raise LLMError("request failed before a response was available")
return {
invalid_result = {
"headline": "Briefing",
"headline_source_ids": ["invented"],
"conclusions": [{"text": "Claim", "source_ids": ["invented"]}],
Expand All @@ -1153,6 +1154,9 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic
"advice": [],
"disaster_tracking": [],
}
if self._omit_headline:
del invalid_result["headline"]
return invalid_result
assert ("previous_invalid_response" in payload) is not self._fail_before_response
allowed_source_ids = payload["allowed_source_ids"]
assert isinstance(allowed_source_ids, list)
Expand All @@ -1168,8 +1172,11 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic
}


@pytest.mark.parametrize("fail_before_response", (False, True))
async def test_llm_retry_on_validation_failure(tmp_path: Path, fail_before_response: bool) -> None:
@pytest.mark.parametrize(
("fail_before_response", "omit_headline"),
((False, False), (True, False), (False, True)),
)
async def test_llm_retry_on_validation_failure(tmp_path: Path, fail_before_response: bool, omit_headline: bool) -> None:
timezone = pendulum.timezone("Asia/Shanghai")
now = pendulum.datetime(2026, 7, 13, 9, tz=timezone)
article = Article(
Expand All @@ -1192,7 +1199,7 @@ async def test_llm_retry_on_validation_failure(tmp_path: Path, fail_before_respo
briefing_max_characters=3500,
llm_max_attempts=2,
)
llm = FailingOnceLLM(fail_before_response=fail_before_response)
llm = FailingOnceLLM(fail_before_response=fail_before_response, omit_headline=omit_headline)
publisher = RecordingPublisher()
delivery = DeliveryProvider(PlainTextRenderer(), publisher)

Expand Down
58 changes: 58 additions & 0 deletions tests/test_weather_context.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64
from typing import TypeGuard

import httpx
import jwt
Expand All @@ -25,6 +26,10 @@ def authorization_header(self) -> str:
return "Bearer runtime-token"


def _is_string_keyed_dict(value: object) -> TypeGuard[dict[str, object]]:
return isinstance(value, dict) and all(isinstance(key, str) for key in value)


_QWEATHER_DAILY_ITEM = {
"fxDate": "2026-07-13",
"textDay": "晴",
Expand Down Expand Up @@ -747,6 +752,59 @@ async def test_open_meteo_rejects_empty_forecast() -> None:
await OpenMeteoProvider(client).fetch(1, 2)


@pytest.mark.parametrize(
"missing_field",
(
"time",
"weather_code",
"temperature_2m_max",
"temperature_2m_min",
"apparent_temperature_max",
"apparent_temperature_min",
"precipitation_sum",
"precipitation_probability_max",
"wind_speed_10m_max",
"wind_gusts_10m_max",
"wind_direction_10m_dominant",
"uv_index_max",
),
)
async def test_open_meteo_forecast_identifies_missing_required_field(missing_field: str) -> None:
response = _open_meteo_weather_response()
daily = response["daily"]
assert _is_string_keyed_dict(daily)
del daily[missing_field]

async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=response))) as client:
with pytest.raises(
WeatherContextError,
match=f"weather forecast parsing failed: daily forecast missing required field: {missing_field}",
):
await OpenMeteoProvider(client).fetch(1, 2)


@pytest.mark.parametrize(
("field", "value", "message"),
(
("daily", [], "daily forecast must be an object"),
("weather_code", 1, "daily forecast field must be an array: weather_code"),
("weather_code", [], "daily forecast field has no value at index 0: weather_code"),
),
)
async def test_open_meteo_forecast_identifies_invalid_field_shape(field: str, value: object, message: str) -> None:
response = _open_meteo_weather_response()
if field == "daily":
response[field] = value
else:
daily = response["daily"]
assert _is_string_keyed_dict(daily)
daily[field] = value

async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=response))) as client:
with pytest.raises(WeatherContextError, match=f"weather forecast parsing failed: {message}"):
await OpenMeteoProvider(client).fetch(1, 2)


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":
Expand Down
16 changes: 11 additions & 5 deletions weather_briefing/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ def sourced_text(value: Mapping[str, Any], key: str) -> str:
raise LLMError(f"{key} entries must contain non-empty text")
return text

def required_text(value: Mapping[str, Any], field: str, *, context: str = "") -> str:
text = value.get(field)
if not isinstance(text, str) or not text.strip():
raise LLMError(f"{context}{field} must be a non-empty string")
return text

def parse_sourced_text_items(key: str) -> tuple[Conclusion, ...]:
values = payload.get(key, [])
if not isinstance(values, list):
Expand Down Expand Up @@ -170,10 +176,10 @@ def advice() -> tuple[Advice, ...]:
raise LLMError("active_warnings entries must be objects")
warnings.append(
Warning(
id=str(value["id"]),
title=str(value["title"]),
status=str(value["status"]),
detail=str(value["detail"]),
id=required_text(value, "id", context="active_warnings entries: "),
title=required_text(value, "title", context="active_warnings entries: "),
status=required_text(value, "status", context="active_warnings entries: "),
detail=required_text(value, "detail", context="active_warnings entries: "),
source_ids=cited_source_ids(value, "source_ids"),
last_confirmed_at=now,
)
Expand All @@ -185,7 +191,7 @@ def advice() -> tuple[Advice, ...]:
parsed_advice = advice()
parsed_disaster_tracking = parse_sourced_text_items("disaster_tracking")
return BriefingResult(
headline=str(payload["headline"]),
headline=required_text(payload, "headline"),
headline_source_ids=cited_source_ids(payload, "headline_source_ids"),
conclusions=parsed_conclusions,
active_warnings=tuple(warnings),
Expand Down
56 changes: 43 additions & 13 deletions weather_briefing/weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ class _QWeatherResponseError(ValueError):
"""Raised for safe, code-defined QWeather response contract errors."""


class _OpenMeteoResponseError(ValueError):
"""Raised for safe, code-defined Open-Meteo response contract errors."""


class WeatherContextProvider(Protocol):
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: ...

Expand Down Expand Up @@ -375,8 +379,10 @@ async def fetch(
)
response.raise_for_status()
payload = response.json()
daily: dict[str, list[object]] = payload["daily"]
times = daily["time"]
daily = payload["daily"]
if not _is_string_keyed_dict(daily):
raise _OpenMeteoResponseError("daily forecast must be an object")
times = _open_meteo_daily_values(daily, "time")
forecast_count = min(2, len(times)) if forecast_date is None else len(times)
weather_forecast = tuple(_format_open_meteo_day(daily, index) for index in range(forecast_count))
if not weather_forecast:
Expand All @@ -389,6 +395,8 @@ async def fetch(
)
except WeatherContextError:
raise
except _OpenMeteoResponseError as exc:
raise WeatherContextError(f"Open-Meteo weather forecast parsing failed: {exc}") from None
except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc:
raise WeatherContextError(f"Open-Meteo weather forecast failed: {_safe_provider_error(exc)}") from None

Expand Down Expand Up @@ -706,6 +714,10 @@ def _is_string_keyed_dict(value: object) -> TypeGuard[dict[str, object]]:
return isinstance(value, dict) and all(isinstance(key, str) for key in value)


def _is_object_list(value: object) -> TypeGuard[list[object]]:
return isinstance(value, list)


def _format_qweather_day(item: object) -> str:
if not _is_string_keyed_dict(item):
raise TypeError("daily forecast entries must be objects")
Expand All @@ -730,16 +742,34 @@ def _format_qweather_day(item: object) -> str:
)


def _format_open_meteo_day(daily: dict[str, list[object]], index: int) -> str:
def _open_meteo_daily_values(daily: dict[str, object], field: str) -> list[object]:
if field not in daily:
raise _OpenMeteoResponseError(f"daily forecast missing required field: {field}")
values = daily[field]
if not _is_object_list(values):
raise _OpenMeteoResponseError(f"daily forecast field must be an array: {field}")
return values


def _open_meteo_daily_value(daily: dict[str, object], field: str, index: int) -> object:
values = _open_meteo_daily_values(daily, field)
if index >= len(values):
raise _OpenMeteoResponseError(f"daily forecast field has no value at index {index}: {field}")
return values[index]


def _format_open_meteo_day(daily: dict[str, object], index: int) -> str:
return (
f"{daily['time'][index]}:WMO天气代码{daily['weather_code'][index]},"
f"{daily['temperature_2m_min'][index]}~{daily['temperature_2m_max'][index]}℃,"
f"体感{daily['apparent_temperature_min'][index]}~"
f"{daily['apparent_temperature_max'][index]}℃,"
f"预计降水{daily['precipitation_sum'][index]}毫米,"
f"最高降水概率{daily['precipitation_probability_max'][index]}%,"
f"最大风速{daily['wind_speed_10m_max'][index]}千米/小时,"
f"最大阵风{daily['wind_gusts_10m_max'][index]}千米/小时,"
f"主导风向{daily['wind_direction_10m_dominant'][index]}°,"
f"最高紫外线指数{daily['uv_index_max'][index]}"
f"{_open_meteo_daily_value(daily, 'time', index)}:"
f"WMO天气代码{_open_meteo_daily_value(daily, 'weather_code', index)},"
f"{_open_meteo_daily_value(daily, 'temperature_2m_min', index)}~"
f"{_open_meteo_daily_value(daily, 'temperature_2m_max', index)}℃,"
f"体感{_open_meteo_daily_value(daily, 'apparent_temperature_min', index)}~"
f"{_open_meteo_daily_value(daily, 'apparent_temperature_max', index)}℃,"
f"预计降水{_open_meteo_daily_value(daily, 'precipitation_sum', index)}毫米,"
f"最高降水概率{_open_meteo_daily_value(daily, 'precipitation_probability_max', index)}%,"
f"最大风速{_open_meteo_daily_value(daily, 'wind_speed_10m_max', index)}千米/小时,"
f"最大阵风{_open_meteo_daily_value(daily, 'wind_gusts_10m_max', index)}千米/小时,"
f"主导风向{_open_meteo_daily_value(daily, 'wind_direction_10m_dominant', index)}°,"
f"最高紫外线指数{_open_meteo_daily_value(daily, 'uv_index_max', index)}"
)