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
4 changes: 3 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ service 将最终解析得到的完整地点名作为 `location_scope.full_name`

`WeatherContextProvider` 以关注地区的经纬度为输入,返回统一的天气上下文快照,核心编排不依赖具体厂商响应结构。快照包含天气预报、可选生活指数、可选空气质量和可选花粉过敏原;独立 `AirQualityProvider` 只承担缺失空气质量时的补充。`AirQualitySnapshot` 用统一生效时间配合 observation/forecast 时间类型表达资料语义,渲染层据此分别标注“观测时间”或“预报时段”,避免把 QWeather 或 Open-Meteo 的目标日期预报描述成既成观测。

能力组合边界由 `capabilities.py` 的 `CapabilityProviderSet` 承担。天气、空气质量、过敏原、生活指数、预警和短时预报属于可独立声明的 capability;现有 QWeather/Open-Meteo 完整上下文 adapter 暂时挂在天气槽位,AQICN 挂在空气质量槽位。这样本地气象机构可以只实现预警或 nowcast,而不必伪装为完整天气 provider;后续能力 provider 不应为填充无关字段而发起额外请求。
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

`QWeatherProvider` 的常规预报读取实时空气质量、今明两日天气和当日生活指数;显式目标日期查询改用 3 日生活指数及 3 日空气质量预报,并按天气预报中的目标日期选择同一天的数据。它提供目标日期天气、温度、风、湿度、预期降水及运动、穿衣、旅游、舒适度和交通指数。空气质量请求失败不会丢弃已经有效的天气结果;常规预报把空气质量留空交给补充层,目标日期查询则保留缺失而不使用当前 AQICN 观测冒充预报。

`OpenMeteoProvider` 使用全球 Weather Forecast API,并尝试从其独立 Air Quality API 获取 U.S. AQI 与 PM2.5 浓度。公开 endpoint 适用于非商业免费使用、要求署名且无 SLA;Base URL 和可选 API Key 可配置,以便切换商业 endpoint。

`FallbackWeatherContextProvider` 按 `WEATHER_PROVIDERS` 顺序组合天气来源,首项为主要来源。未显式配置时,解析为中国大陆的地点选择 `qweather,open-meteo`,其他地点只选择 `open-meteo`。自动顺序中未配置 JWT 的 QWeather 会被跳过;用户显式指定却缺少凭据时给出配置错误。只有天气请求或响应契约失败才切换下一个已配置天气来源。

`AirQualitySupplementingWeatherProvider` 在最终天气快照缺少空气质量时调用可选 `AQICNProvider`。AQICN 只返回空气质量,不会被当成天气来源;它保留美国 EPA AQI 和 PM2.5 单项 AQI,不反向折算 PM2.5 浓度。天气来源与 AQICN 都无法提供空气质量时抛出可操作错误。
`CapabilityProviderSet` 在最终天气快照缺少空气质量时调用可选 `AQICNProvider`。AQICN 只返回空气质量,不会被当成天气来源;它保留美国 EPA AQI 和 PM2.5 单项 AQI,不反向折算 PM2.5 浓度。天气来源与 AQICN 都无法提供空气质量时抛出可操作错误。

天气快照可选包含花粉过敏原信息。QWeather 生活指数请求过敏指数(类型 7),并将目标日期的供应商指数说明直接放入 `lifestyle_advice`。`OpenMeteoProvider` 在同一次空气质量 API 请求中额外请求花粉变量:常规预报使用当前值,显式目标日期使用服务商支持范围内该日逐小时预报的各类型峰值,再按浓度分级(参考数据 `allergen_guidance.json`)转换为独立 `AllergenSnapshot`,供对应日期生活建议参考。Open-Meteo 花粉数据仅在欧洲花粉季可用,来自 CAMS European Air Quality forecast 的 ENSEMBLE 数据;引用该上下文时,渲染层使用文档名称确定性展示 Open-Meteo 与 CAMS ENSEMBLE 署名。数值边界拒绝布尔值、NaN 和正负无穷等非有限输入;缺失或字段无效时不影响天气与空气质量结果。当前实现不把综合过敏指数解释为具体花粉种类,也不推断独立的杨絮、柳絮等飞絮信息。

Expand Down
2 changes: 2 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ DeepSeek 是唯一保留旧环境变量别名的 LLM provider:`DEEPSEEK_MODEL`

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

天气能力先按可独立替换的领域能力建模,但现有 QWeather 和 Open-Meteo adapter 仍各用一次完整请求流程返回天气、空气质量、生活指数或过敏原的组合快照。`CapabilityProviderSet` 因此把完整 adapter 放在天气槽位,只把已有独立 adapter 的 AQICN 放在空气质量槽位;为了拆分类型而重复请求同一厂商会增加延迟、配额消耗和部分失败状态。fallback 的 `weather_metadata` 只声明所有候选 adapter 都支持的能力,避免当前实际选中某一路时 `supports()` 返回无法兑现的结果。若厂商提供可独立调用且有实际替换需求的预警、nowcast 或过敏原接口,应新增对应槽位和薄 adapter;若未来路由需要表达“至少一个候选支持”而不是共同保证,则应另建聚合元数据,不能改变当前 `supports()` 的语义。

`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 或不可信输入校验进入错误的层级。
Expand Down
151 changes: 151 additions & 0 deletions tests/test_capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import pendulum
import pytest

from weather_briefing.air_quality import AirQualityError
from weather_briefing.capabilities import CapabilityName, CapabilityProviderSet, ProviderCapabilities
from weather_briefing.models import AirQualitySnapshot, AirQualityTimeKind, WeatherContextSnapshot
from weather_briefing.weather_context import WeatherContextError


def _weather(*, air_quality: AirQualitySnapshot | None = None) -> WeatherContextSnapshot:
return WeatherContextSnapshot(
source_id="weather:test",
source_name="Test weather",
source_url="https://example.invalid/weather",
observed_at=pendulum.datetime(2026, 7, 20, 8, tz="Asia/Singapore"),
weather_forecast=("forecast",),
air_quality=air_quality,
)


def _air_quality() -> AirQualitySnapshot:
return AirQualitySnapshot(
source_id="air-quality:test",
source_name="Test air",
source_url="https://example.invalid/air",
effective_at=pendulum.datetime(2026, 7, 20, 8, tz="Asia/Singapore"),
time_kind=AirQualityTimeKind.OBSERVATION,
aqi=20,
aqi_display="20",
aqi_standard="Test",
pm25_aqi=None,
pm25_concentration=None,
pm25_unit=None,
category="good",
health_guidance="ok",
)


def _metadata() -> ProviderCapabilities:
return ProviderCapabilities(
provider_id="test",
provider_name="Test",
capabilities=frozenset({CapabilityName.WEATHER}),
)


def test_provider_capability_metadata_reports_support() -> None:
metadata = _metadata()

assert metadata.supports(CapabilityName.WEATHER)
assert not metadata.supports(CapabilityName.ALERTS)


async def test_capability_set_supplements_missing_current_air_quality() -> None:
class Weather:
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot:
return _weather()

class Air:
async def fetch(self, latitude: float, longitude: float, timezone: str) -> AirQualitySnapshot:
assert timezone == "Asia/Singapore"
return _air_quality()

provider = CapabilityProviderSet(
weather=Weather(),
weather_metadata=_metadata(),
air_quality=Air(),
)

snapshot = await provider.fetch(1, 2)

assert snapshot.air_quality == _air_quality()


async def test_capability_set_does_not_supplement_dated_context() -> None:
class Weather:
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot:
raise AssertionError("dated fetch must use fetch_for_date") # pragma: no cover

async def fetch_for_date(
self,
latitude: float,
longitude: float,
forecast_date: pendulum.Date,
) -> WeatherContextSnapshot:
assert forecast_date == pendulum.date(2026, 7, 21)
return _weather()

class FailingAir:
async def fetch(self, latitude: float, longitude: float, timezone: str) -> AirQualitySnapshot:
raise AssertionError("dated contexts must not use current air quality") # pragma: no cover

provider = CapabilityProviderSet(
weather=Weather(),
weather_metadata=_metadata(),
air_quality=FailingAir(),
)

snapshot = await provider.fetch_for_date(1, 2, pendulum.date(2026, 7, 21))

assert snapshot.air_quality is None


async def test_capability_set_requires_an_air_quality_capability_for_current_context() -> None:
class Weather:
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot:
return _weather()

provider = CapabilityProviderSet(weather=Weather(), weather_metadata=_metadata())

with pytest.raises(WeatherContextError, match="configure AQICN_API_TOKEN"):
await provider.fetch(1, 2)


async def test_capability_set_wraps_air_quality_provider_failure() -> None:
class Weather:
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot:
return _weather()

class Air:
async def fetch(self, latitude: float, longitude: float, timezone: str) -> AirQualitySnapshot:
raise AirQualityError("failed")

provider = CapabilityProviderSet(weather=Weather(), weather_metadata=_metadata(), air_quality=Air())

with pytest.raises(WeatherContextError, match="AQICN fallback failed"):
await provider.fetch(1, 2)


async def test_dated_context_requires_provider_support() -> None:
class Weather:
async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot:
return _weather() # pragma: no cover

provider = CapabilityProviderSet(weather=Weather(), weather_metadata=_metadata())

with pytest.raises(WeatherContextError, match="does not support target forecast dates"):
await provider.fetch_for_date(1, 2, pendulum.date(2026, 7, 21))


async def test_dated_context_rejects_non_callable_fetch_method() -> None:
class Weather:
fetch_for_date = 1

async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot:
return _weather() # pragma: no cover

provider = CapabilityProviderSet(weather=Weather(), weather_metadata=_metadata())

with pytest.raises(WeatherContextError, match="does not support target forecast dates"):
await provider.fetch_for_date(1, 2, pendulum.date(2026, 7, 21))
39 changes: 37 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import pytest

import weather_briefing.cli as cli_module
from weather_briefing.capabilities import CapabilityName
from weather_briefing.cli import (
_LOGGER,
_SENSITIVE_SDK_LOGGERS,
Expand All @@ -37,6 +38,7 @@
_precision_reduction_notice,
_qweather_is_configured,
_weather_context_provider,
_weather_provider_metadata,
build_parser,
daemon,
main,
Expand Down Expand Up @@ -1133,7 +1135,9 @@ async def test_qweather_not_configured_skips_when_auto(self, async_client: httpx
)
location = ResolvedLocation("test", "Test", 39.9, 116.3, "CN", "Beijing", "Asia/Shanghai", True)
provider = _weather_context_provider(settings, async_client, location)
assert provider is not None
assert provider.weather_metadata.provider_id == "open-meteo"
assert provider.weather_metadata.supports(CapabilityName.ALLERGEN)
assert not provider.weather_metadata.supports(CapabilityName.LIFESTYLE)

async def test_qweather_explicit_not_configured_raises(self, async_client: httpx.AsyncClient) -> None:
settings = _make_fake_settings(
Expand Down Expand Up @@ -1167,10 +1171,41 @@ async def test_qweather_configured(self, async_client: httpx.AsyncClient, caplog
location = ResolvedLocation("test", "Test", 39.9, 116.3, "CN", "Beijing", "Asia/Shanghai", True)
with caplog.at_level("INFO", logger="weather_briefing"):
provider = _weather_context_provider(settings, async_client, location)
assert provider is not None
assert provider.weather_metadata.provider_id == "qweather"
assert provider.weather_metadata.supports(CapabilityName.LIFESTYLE)
assert not provider.weather_metadata.supports(CapabilityName.ALLERGEN)
assert "Weather provider order providers=qweather" in caplog.text
assert "location=test" not in caplog.text

async def test_fallback_metadata_only_claims_common_capabilities(
self,
async_client: httpx.AsyncClient,
) -> None:
key = b"fake-private-key-content"
settings = _make_fake_settings(
weather_providers=("qweather", "open-meteo"),
qweather_project_id="project",
qweather_credential_id="credential",
qweather_private_key=base64.b64encode(key).decode(),
qweather_base_url="https://qweather.example.invalid",
)
location = ResolvedLocation("test", "Test", 39.9, 116.3, "CN", "Beijing", "Asia/Shanghai", True)

metadata = _weather_context_provider(settings, async_client, location).weather_metadata

assert metadata.provider_id == "weather-composite"
assert metadata.capabilities == frozenset(
{
CapabilityName.WEATHER,
CapabilityName.AIR_QUALITY,
}
)


def test_weather_provider_metadata_rejects_unregistered_provider() -> None:
with pytest.raises(ValueError, match="has no capability metadata"):
_weather_provider_metadata(("unregistered",))


async def test_no_weather_provider_available(monkeypatch, async_client: httpx.AsyncClient) -> None:
monkeypatch.setattr(
Expand Down
114 changes: 114 additions & 0 deletions weather_briefing/capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Composable provider capabilities and location-scoped context assembly."""

from __future__ import annotations

from dataclasses import dataclass, replace
from enum import StrEnum
from typing import Protocol

import pendulum

from .air_quality import AirQualityError, AirQualityProvider
from .models import WeatherContextSnapshot
from .time_utils import datetime_timezone_specifier


class CapabilityName(StrEnum):
"""Identify independently replaceable weather data capabilities."""

WEATHER = "weather"
AIR_QUALITY = "air-quality"
ALLERGEN = "allergen"
LIFESTYLE = "lifestyle"
ALERTS = "alerts"
NOWCAST = "nowcast"


class ContextCapabilityProvider(Protocol):
"""Fetch the normalized weather context supplied by a weather capability."""

async def fetch(
self,
latitude: float,
longitude: float,
) -> WeatherContextSnapshot:
"""Fetch normalized context for a location."""
...


@dataclass(frozen=True, slots=True)
class ProviderCapabilities:
"""Describe the capabilities exposed by one provider adapter."""

provider_id: str
provider_name: str
capabilities: frozenset[CapabilityName]

def supports(self, capability: CapabilityName) -> bool:
"""Return whether this provider exposes a capability."""
return capability in self.capabilities


@dataclass(frozen=True, slots=True)
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
class CapabilityProviderSet:
"""Compose independent weather and air-quality capabilities."""

weather: ContextCapabilityProvider
weather_metadata: ProviderCapabilities
air_quality: AirQualityProvider | None = None
air_quality_metadata: ProviderCapabilities | None = None

async def fetch(
self,
latitude: float,
longitude: float,
*,
forecast_date: pendulum.Date | None = None,
) -> WeatherContextSnapshot:
"""Fetch weather and fill a missing current air-quality capability."""
snapshot = await _fetch_context(self.weather, latitude, longitude, forecast_date)
if snapshot.air_quality is not None or forecast_date is not None:
return snapshot
if self.air_quality is None:
from .weather_context import WeatherContextError

raise WeatherContextError("Weather source did not provide air quality; configure AQICN_API_TOKEN")
try:
air_quality = await self.air_quality.fetch(
latitude,
longitude,
datetime_timezone_specifier(snapshot.observed_at, context="Weather snapshot time"),
)
except AirQualityError:
from .weather_context import WeatherContextError

raise WeatherContextError("Weather source did not provide air quality and AQICN fallback failed") from None
return replace(snapshot, air_quality=air_quality)

async def fetch_for_date(
self,
latitude: float,
longitude: float,
forecast_date: pendulum.Date,
) -> WeatherContextSnapshot:
"""Fetch a dated context through the composed capabilities."""
return await self.fetch(latitude, longitude, forecast_date=forecast_date)


async def _fetch_context(
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

1. Duplicated dated dispatch 🐞 Bug ⚙ Maintainability

CapabilityProviderSet introduces _fetch_context() that re-implements the same “current vs
forecast_date” dispatch already provided by weather_context.fetch_weather_context(), creating two
sources of truth that can drift in behavior and error semantics. This is especially risky because
both paths are exercised in production (service.py uses fetch_weather_context(), while
CapabilityProviderSet uses _fetch_context()).
Agent Prompt
### Issue description
`weather_briefing/capabilities.py` defines `_fetch_context()` to route between `fetch()` and `fetch_for_date()`, but the repo already has `weather_briefing/weather_context.py:fetch_weather_context()` that does the same routing. Keeping both increases the chance of future drift (e.g., one adds new validation or different error messages while the other doesn’t).

### Issue Context
- `CapabilityProviderSet.fetch()` currently calls `_fetch_context()`.
- `BriefingService` still calls `fetch_weather_context()` directly.

### Fix Focus Areas
- Prefer a single shared dispatch implementation (either call `fetch_weather_context()` from `capabilities.py`, or move dispatch into a shared helper used by both).
- If the callable-guard behavior is desired, consider adding it to the shared helper so both code paths behave consistently.

- weather_briefing/capabilities.py[61-114]
- weather_briefing/weather_context.py[665-676]
- weather_briefing/service.py[331-338]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

provider: ContextCapabilityProvider,
latitude: float,
longitude: float,
forecast_date: pendulum.Date | None,
) -> WeatherContextSnapshot:
"""Call providers that support either current or dated context."""
if forecast_date is None:
return await provider.fetch(latitude, longitude)
from .weather_context import DatedWeatherContextProvider, WeatherContextError

if not isinstance(provider, DatedWeatherContextProvider):
raise WeatherContextError(f"{type(provider).__name__} does not support target forecast dates")
fetch_for_date = provider.fetch_for_date
if not callable(fetch_for_date):
raise WeatherContextError(f"{type(provider).__name__} does not support target forecast dates")
return await fetch_for_date(latitude, longitude, forecast_date)
Loading