diff --git a/docs/design.md b/docs/design.md index 3e458f9c..f7d2a5b9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 不应为填充无关字段而发起额外请求。 + `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 和正负无穷等非有限输入;缺失或字段无效时不影响天气与空气质量结果。当前实现不把综合过敏指数解释为具体花粉种类,也不推断独立的杨絮、柳絮等飞絮信息。 diff --git a/docs/notes.md b/docs/notes.md index 6e125b94..7a9cf720 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -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 或不可信输入校验进入错误的层级。 diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 00000000..5d1b9506 --- /dev/null +++ b/tests/test_capabilities.py @@ -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)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 03d19d5f..1b81e2ad 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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, @@ -37,6 +38,7 @@ _precision_reduction_notice, _qweather_is_configured, _weather_context_provider, + _weather_provider_metadata, build_parser, daemon, main, @@ -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( @@ -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( diff --git a/weather_briefing/capabilities.py b/weather_briefing/capabilities.py new file mode 100644 index 00000000..b665caaa --- /dev/null +++ b/weather_briefing/capabilities.py @@ -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) +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( + 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) diff --git a/weather_briefing/cli.py b/weather_briefing/cli.py index 40610567..840c763c 100644 --- a/weather_briefing/cli.py +++ b/weather_briefing/cli.py @@ -27,6 +27,7 @@ AQICNProvider, ) from .api_client import LoggedAsyncClient +from .capabilities import CapabilityName, CapabilityProviderSet, ProviderCapabilities from .config import Settings, state_path_from_env, weather_providers_for from .geocoding import ( CachedLocationResolver, @@ -45,7 +46,6 @@ from .state import SQLiteRuntimeDiagnostics, SQLiteStateStore from .time_utils import parse_aware_datetime from .weather_context import ( - AirQualitySupplementingWeatherProvider, FallbackWeatherContextProvider, LoggedWeatherContextProvider, OpenMeteoProvider, @@ -411,20 +411,67 @@ def _build_telegram_publisher( PublisherName.TELEGRAM: _build_telegram_publisher, } +_WEATHER_PROVIDER_METADATA: dict[str, ProviderCapabilities] = { + WeatherProviderName.QWEATHER: ProviderCapabilities( + provider_id=WeatherProviderName.QWEATHER, + provider_name="QWeather", + capabilities=frozenset( + { + CapabilityName.WEATHER, + CapabilityName.AIR_QUALITY, + CapabilityName.LIFESTYLE, + } + ), + ), + WeatherProviderName.OPEN_METEO: ProviderCapabilities( + provider_id=WeatherProviderName.OPEN_METEO, + provider_name="Open-Meteo", + capabilities=frozenset( + { + CapabilityName.WEATHER, + CapabilityName.AIR_QUALITY, + CapabilityName.ALLERGEN, + } + ), + ), +} + + +def _weather_provider_metadata(names: Sequence[str]) -> ProviderCapabilities: + """Describe capabilities common to every active fallback provider.""" + metadata: list[ProviderCapabilities] = [] + for name in names: + item = _WEATHER_PROVIDER_METADATA.get(name) + if item is None: + raise ValueError(f"Weather provider {name!r} has no capability metadata") + metadata.append(item) + if len(metadata) == 1: + return metadata[0] + capabilities = metadata[0].capabilities + for item in metadata[1:]: + capabilities &= item.capabilities + return ProviderCapabilities( + provider_id="weather-composite", + provider_name="Weather provider composite", + capabilities=capabilities, + ) + def _weather_context_provider( settings: Settings, client: httpx.AsyncClient, location: ResolvedLocation, -) -> WeatherContextProvider: +) -> CapabilityProviderSet: names = weather_providers_for(location, settings.weather_providers) providers: list[WeatherContextProvider] = [] + active_names: list[str] = [] for name in names: if name == WeatherProviderName.QWEATHER and not _qweather_is_configured(settings): if settings.weather_providers is not None: raise ValueError("Explicit QWeather provider is missing JWT configuration") continue providers.append(LoggedWeatherContextProvider(name, _build_weather_provider(name, settings, client))) + active_names.append(name) if not providers: raise ValueError("No configured weather provider is available") _LOGGER.info( @@ -434,9 +481,20 @@ def _weather_context_provider( weather_provider: WeatherContextProvider = ( providers[0] if len(providers) == 1 else FallbackWeatherContextProvider(*providers) ) - return AirQualitySupplementingWeatherProvider( - weather_provider, - _aqicn_provider(settings, client), + air_quality_provider = _aqicn_provider(settings, client) + return CapabilityProviderSet( + weather=weather_provider, + weather_metadata=_weather_provider_metadata(active_names), + air_quality=air_quality_provider, + air_quality_metadata=( + ProviderCapabilities( + provider_id="air-quality:aqicn", + provider_name="AQICN", + capabilities=frozenset({CapabilityName.AIR_QUALITY}), + ) + if air_quality_provider is not None + else None + ), )