diff --git a/docs/design.md b/docs/design.md index b9326a48..74404f91 100644 --- a/docs/design.md +++ b/docs/design.md @@ -104,9 +104,9 @@ RSS 来源可以按地点 ID 限定范围,并配置正文清洗规则。`verba 和风天气使用 `QWeatherJWTAuthenticator` 签发短期 JWT。认证输入包括项目 ID、凭据 ID、专属 API Host 和 Base64 编码的 Ed25519 私钥。实现不支持用长期 API Key 代替该流程。 -`OpenMeteoProvider` 提供全球天气,并从独立接口读取空气质量和欧洲花粉数据。适配器在生成来源正文前把 WMO 天气代码转换为可读的中文天气现象;未知代码保留为“未识别天气现象”并写入安全日志。公开接口适用于非商业使用,没有 SLA;Base URL 和可选 API Key 可以替换。 +`OpenMeteoProvider` 提供全球天气,并从独立接口读取空气质量和欧洲花粉数据。Open-Meteo API 返回语言中立的结构化数据,适配器把天气、空气质量和花粉统一转换为固定 `en` 来源正文。WMO 天气代码映射在模块加载时完成验证,未知代码保留为 “Unrecognized weather condition” 并写入安全日志。`SourceDocument` 记录英文来源语言,模型按地点配置的目标语言生成最终简报。公开接口适用于非商业使用,没有 SLA;Base URL 和可选 API Key 可以替换。 -`AQICNProvider` 只在最终天气结果缺少空气质量时补充当前观测。它不参与未来日期查询,也不把 PM2.5 单项 AQI 换算为浓度。 +`AQICNProvider` 只在最终天气结果缺少空气质量时补充当前观测。AQICN 的结构化数据也统一转换为固定 `en` 来源正文。它不参与未来日期查询,也不把 PM2.5 单项 AQI 换算为浓度。 `NEASingaporeNowcastProvider` 只提供新加坡两小时预报。`JMAJapanForecastProvider` 只提供地点所配置预报区的日本本地预报。它们没有完整天气服务所需的全部数据,因此与 Open-Meteo 组合,而不是作为完整天气 fallback。 diff --git a/tests/test_air_quality.py b/tests/test_air_quality.py index c40eb3d4..4e7e6515 100644 --- a/tests/test_air_quality.py +++ b/tests/test_air_quality.py @@ -35,14 +35,16 @@ def handler(request: httpx.Request) -> httpx.Response: assert snapshot.aqi == 120 assert snapshot.aqi_standard == "US EPA" + assert snapshot.category == "Unhealthy for sensitive groups" assert snapshot.effective_at is not None assert snapshot.effective_at.to_iso8601_string() == "2026-07-13T08:00:00+08:00" assert snapshot.time_kind is AirQualityTimeKind.OBSERVATION assert snapshot.pm25_aqi == 100 assert snapshot.pm25_concentration is None + assert snapshot.output_language == "en" document = air_quality_to_document(snapshot) - assert "AQI:120(标准:US EPA" in document.content - assert "PM2.5 不可用" in document.content + assert "AQI: 120 (Standard: US EPA" in document.content + assert "PM2.5 concentration: Unavailable" in document.content assert "原始浓度" not in document.content assert "折算" not in document.content diff --git a/tests/test_allergen.py b/tests/test_allergen.py index 353156bf..4096d072 100644 --- a/tests/test_allergen.py +++ b/tests/test_allergen.py @@ -13,28 +13,28 @@ def test_allergen_guidance_zero_is_none() -> None: category, guidance = allergen_guidance(0) - assert category == "无" + assert category == "None" assert guidance def test_allergen_guidance_low_band() -> None: category, _ = allergen_guidance(5) - assert category == "低" + assert category == "Low" def test_allergen_guidance_moderate_band() -> None: category, _ = allergen_guidance(20) - assert category == "中" + assert category == "Moderate" def test_allergen_guidance_high_band() -> None: category, _ = allergen_guidance(50) - assert category == "高" + assert category == "High" def test_allergen_guidance_extreme_band() -> None: category, _ = allergen_guidance(500) - assert category == "极高" + assert category == "Very high" @pytest.mark.parametrize("concentration", [-1, float("nan"), float("inf"), float("-inf")]) diff --git a/tests/test_languages.py b/tests/test_languages.py index 0f105d87..82bdbf66 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -50,7 +50,7 @@ def test_weather_provider_language_metadata_distinguishes_selectable_and_fixed_s assert QWEATHER_LANGUAGE_SUPPORT.select("ja") == "ja" assert QWEATHER_LANGUAGE_SUPPORT.api_code("ja") == "ja" assert OPEN_METEO_LANGUAGE_SUPPORT.selectable is False - assert OPEN_METEO_LANGUAGE_SUPPORT.default == "zh-CN" + assert OPEN_METEO_LANGUAGE_SUPPORT.default == "en" @pytest.mark.parametrize( diff --git a/tests/test_reference_data.py b/tests/test_reference_data.py index 59d60bf4..ee6c4e3a 100644 --- a/tests/test_reference_data.py +++ b/tests/test_reference_data.py @@ -78,7 +78,7 @@ def test_packaged_reference_data_is_available() -> None: 96, 99, } - assert weather_codes[53] == "中等强度毛毛雨" + assert weather_codes[53] == "Moderate drizzle" assert localization_table("weather_document")["ja"]["forecast"] == "天気予報" assert localization_table("briefing")["zh-Hans"]["weather"] == "天气信息" classification = telegram_error_classification() @@ -90,12 +90,12 @@ def test_packaged_reference_data_is_available() -> None: "value", ( {}, - {"descriptions_zh_CN": {}}, - {"descriptions_zh_CN": {"unknown": "晴朗"}}, - {"descriptions_zh_CN": {"00": "晴朗"}}, - {"descriptions_zh_CN": {"9" * 5_000: "未知"}}, - {"descriptions_zh_CN": {"0": ""}}, - {"descriptions_zh_CN": {"0": "晴朗"}, "unknown": {}}, + {"descriptions_en": {}}, + {"descriptions_en": {"unknown": "Clear sky"}}, + {"descriptions_en": {"00": "Clear sky"}}, + {"descriptions_en": {"9" * 5_000: "Unknown"}}, + {"descriptions_en": {"0": ""}}, + {"descriptions_en": {"0": "Clear sky"}, "unknown": {}}, ), ) def test_open_meteo_weather_codes_reject_invalid_data(monkeypatch, value) -> None: diff --git a/tests/test_render.py b/tests/test_render.py index 42857fec..afc06a47 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -79,7 +79,7 @@ def test_renderers_fall_back_to_source_id_for_legacy_blank_name() -> None: def test_telegram_html_renderer_uses_context_source_name_as_attribution() -> None: context = SourceDocument( "allergen:open-meteo", - "Open-Meteo / CAMS ENSEMBLE 花粉过敏原", + "Open-Meteo / CAMS ENSEMBLE pollen allergens", "https://open-meteo.com/en/docs/air-quality-api", "Pollen data", ) @@ -93,14 +93,14 @@ def test_telegram_html_renderer_uses_context_source_name_as_attribution() -> Non rendered = TelegramHTMLRenderer().render_briefing(result, (), (context,)) assert ( - 'Open-Meteo / CAMS ENSEMBLE 花粉过敏原' + 'Open-Meteo / CAMS ENSEMBLE pollen allergens' ) in rendered.body def test_plain_text_renderer_uses_context_source_name_as_attribution() -> None: context = SourceDocument( "allergen:open-meteo", - "Open-Meteo / CAMS ENSEMBLE 花粉过敏原", + "Open-Meteo / CAMS ENSEMBLE pollen allergens", "https://open-meteo.com/en/docs/air-quality-api", "Pollen data", ) @@ -113,7 +113,9 @@ def test_plain_text_renderer_uses_context_source_name_as_attribution() -> None: rendered = PlainTextRenderer().render_briefing(result, (), (context,)) - assert ("Open-Meteo / CAMS ENSEMBLE 花粉过敏原: https://open-meteo.com/en/docs/air-quality-api") in rendered.body + assert ( + "Open-Meteo / CAMS ENSEMBLE pollen allergens: https://open-meteo.com/en/docs/air-quality-api" + ) in rendered.body def test_telegram_html_renders_active_warnings_section() -> None: diff --git a/tests/test_weather_context.py b/tests/test_weather_context.py index 9a81643d..23a6c226 100644 --- a/tests/test_weather_context.py +++ b/tests/test_weather_context.py @@ -533,7 +533,9 @@ def handler(request: httpx.Request) -> httpx.Response: ).fetch(52.52, 13.41) assert len(snapshot.weather_forecast) == 2 - assert "局部多云" in snapshot.weather_forecast[0] + assert snapshot.output_language == "en" + assert "Partly cloudy" in snapshot.weather_forecast[0] + assert "maximum precipitation probability 10%" in snapshot.weather_forecast[0] assert "WMO" not in snapshot.weather_forecast[0] assert snapshot.observed_at.to_iso8601_string() == "2026-07-13T08:00:00+02:00" assert snapshot.observed_at.timezone_name == "Europe/Berlin" @@ -544,14 +546,23 @@ def handler(request: httpx.Request) -> httpx.Response: def test_open_meteo_weather_code_uses_readable_description() -> None: - assert _open_meteo_weather_description(53) == "中等强度毛毛雨" + assert _open_meteo_weather_description(53) == "Moderate drizzle" + + +def test_open_meteo_weather_code_lookup_uses_cached_loader_boundary(monkeypatch) -> None: + monkeypatch.setattr( + "weather_briefing.weather_context.open_meteo_weather_code_descriptions", + lambda: {53: "Reloaded description"}, + ) + + assert _open_meteo_weather_description(53) == "Reloaded description" def test_open_meteo_unknown_weather_code_uses_readable_fallback(caplog) -> None: with caplog.at_level("WARNING", logger="weather_briefing.weather_context"): description = _open_meteo_weather_description(100) - assert description == "未识别天气现象" + assert description == "Unrecognized weather condition" assert "Unknown Open-Meteo weather code code=100" in caplog.text @@ -559,7 +570,7 @@ def test_open_meteo_invalid_weather_code_uses_readable_fallback(caplog) -> None: with caplog.at_level("WARNING", logger="weather_briefing.weather_context"): description = _open_meteo_weather_description("53") - assert description == "未识别天气现象" + assert description == "Unrecognized weather condition" assert "Invalid Open-Meteo weather code value_type=str" in caplog.text @@ -615,7 +626,7 @@ def handler(request: httpx.Request) -> httpx.Response: ).fetch_for_date(39.9, 116.3, target_date) assert len(snapshot.weather_forecast) == 1 - assert snapshot.weather_forecast[0].startswith("2026-07-15:") + assert snapshot.weather_forecast[0].startswith("2026-07-15: ") assert snapshot.air_quality is not None assert snapshot.air_quality.aqi == 90 assert snapshot.air_quality.effective_at is not None @@ -1884,15 +1895,17 @@ def handler(request: httpx.Request) -> httpx.Response: assert snapshot.air_quality is not None assert snapshot.allergen is not None assert snapshot.allergen.source_id == "allergen:open-meteo" - assert snapshot.allergen.source_name == "Open-Meteo / CAMS ENSEMBLE 花粉过敏原" + assert snapshot.allergen.source_name == "Open-Meteo / CAMS ENSEMBLE pollen allergens" + assert snapshot.allergen.output_language == "en" level_names = {level.name for level in snapshot.allergen.levels} - assert {"桤木", "桦木", "禾本"} == level_names - birch = next(level for level in snapshot.allergen.levels if level.name == "桦木") - assert birch.category == "中" + assert {"Alder", "Birch", "Grass"} == level_names + birch = next(level for level in snapshot.allergen.levels if level.name == "Birch") + assert birch.category == "Moderate" assert birch.concentration == 15 - assert snapshot.allergen.overall_category == "中" + assert snapshot.allergen.overall_category == "Moderate" documents = snapshot_to_documents(snapshot) assert "allergen:open-meteo" in [doc.id for doc in documents] + assert all(doc.language == "en" for doc in documents) async def test_open_meteo_provider_returns_no_allergen_when_pollen_absent() -> None: @@ -1947,7 +1960,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert snapshot.allergen is not None level_names = {level.name for level in snapshot.allergen.levels} - assert level_names == {"禾本"} + assert level_names == {"Grass"} async def test_open_meteo_allergen_handles_missing_time_gracefully() -> None: diff --git a/weather_briefing/air_quality.py b/weather_briefing/air_quality.py index ad15046c..446c6e9d 100644 --- a/weather_briefing/air_quality.py +++ b/weather_briefing/air_quality.py @@ -91,7 +91,7 @@ async def fetch( pm25_unit=None, category=category, health_guidance=guidance, - output_language="zh-CN", + output_language="en", ) diff --git a/weather_briefing/data/air_quality_guidance.json b/weather_briefing/data/air_quality_guidance.json index 1624d4d1..077617f0 100644 --- a/weather_briefing/data/air_quality_guidance.json +++ b/weather_briefing/data/air_quality_guidance.json @@ -3,33 +3,33 @@ "bands": [ { "maximum_aqi": 50, - "category": "优", - "guidance": "空气污染风险很低,一般无需因空气质量调整户外活动。" + "category": "Good", + "guidance": "Air pollution risk is low; no activity changes are generally needed." }, { "maximum_aqi": 100, - "category": "良", - "guidance": "极少数异常敏感人群应减少长时间、高强度户外活动。" + "category": "Moderate", + "guidance": "Unusually sensitive people should consider reducing prolonged or heavy outdoor exertion." }, { "maximum_aqi": 150, - "category": "对敏感人群不健康", - "guidance": "儿童、活跃成年人和呼吸系统疾病人群应减少长时间户外活动。" + "category": "Unhealthy for sensitive groups", + "guidance": "Children, active adults, and people with respiratory disease should reduce prolonged outdoor exertion." }, { "maximum_aqi": 200, - "category": "不健康", - "guidance": "敏感人群应避免长时间户外活动,其他人群也应适当减少户外活动。" + "category": "Unhealthy", + "guidance": "Sensitive groups should avoid prolonged outdoor exertion; everyone else should reduce it." }, { "maximum_aqi": 300, - "category": "非常不健康", - "guidance": "所有人应减少户外活动,敏感人群尽量避免外出。" + "category": "Very unhealthy", + "guidance": "Everyone should reduce outdoor activity, and sensitive groups should avoid going outdoors." }, { "maximum_aqi": null, - "category": "危险", - "guidance": "所有人应避免户外活动,并关注当地健康部门建议。" + "category": "Hazardous", + "guidance": "Everyone should avoid outdoor activity and follow local public health advice." } ] } diff --git a/weather_briefing/data/allergen_guidance.json b/weather_briefing/data/allergen_guidance.json index fe98dcef..9a3b1e1d 100644 --- a/weather_briefing/data/allergen_guidance.json +++ b/weather_briefing/data/allergen_guidance.json @@ -1,37 +1,37 @@ { "pollen_types": { - "alder": "桤木", - "birch": "桦木", - "grass": "禾本", - "mugwort": "蒿属", - "olive": "橄榄", - "ragweed": "豚草" + "alder": "Alder", + "birch": "Birch", + "grass": "Grass", + "mugwort": "Mugwort", + "olive": "Olive", + "ragweed": "Ragweed" }, "bands": [ { "maximum_concentration": 0, - "category": "无", - "guidance": "花粉浓度极低,过敏体质人群一般无需因花粉调整户外活动。" + "category": "None", + "guidance": "Pollen concentrations are negligible; no activity changes are generally needed." }, { "maximum_concentration": 10, - "category": "低", - "guidance": "花粉浓度较低,少数极度敏感者可做适当防护。" + "category": "Low", + "guidance": "Pollen concentrations are low; highly sensitive people may take routine precautions." }, { "maximum_concentration": 30, - "category": "中", - "guidance": "花粉浓度中等,过敏体质人群户外活动后建议清洗面部和鼻腔。" + "category": "Moderate", + "guidance": "Pollen concentrations are moderate; sensitive people should wash their face and nose after outdoor activity." }, { "maximum_concentration": 100, - "category": "高", - "guidance": "花粉浓度较高,花粉过敏者应减少户外活动,外出佩戴口罩。" + "category": "High", + "guidance": "Pollen concentrations are high; people with pollen allergies should limit outdoor activity and wear a mask outside." }, { "maximum_concentration": null, - "category": "极高", - "guidance": "花粉浓度很高,花粉过敏者应尽量避免外出,关好门窗并使用空气净化设备。" + "category": "Very high", + "guidance": "Pollen concentrations are very high; people with pollen allergies should avoid going outside, close windows, and use air filtration." } ] } diff --git a/weather_briefing/data/open_meteo_weather_codes.json b/weather_briefing/data/open_meteo_weather_codes.json index 85401122..15acaf88 100644 --- a/weather_briefing/data/open_meteo_weather_codes.json +++ b/weather_briefing/data/open_meteo_weather_codes.json @@ -1,32 +1,32 @@ { - "descriptions_zh_CN": { - "0": "晴朗", - "1": "大致晴朗", - "2": "局部多云", - "3": "阴天", - "45": "有雾", - "48": "雾凇", - "51": "轻微毛毛雨", - "53": "中等强度毛毛雨", - "55": "强毛毛雨", - "56": "轻微冻毛毛雨", - "57": "强冻毛毛雨", - "61": "小雨", - "63": "中雨", - "65": "大雨", - "66": "轻微冻雨", - "67": "强冻雨", - "71": "小雪", - "73": "中雪", - "75": "大雪", - "77": "米雪", - "80": "轻微阵雨", - "81": "中等强度阵雨", - "82": "强阵雨", - "85": "轻微阵雪", - "86": "强阵雪", - "95": "雷暴", - "96": "雷暴伴轻微冰雹", - "99": "雷暴伴强冰雹" + "descriptions_en": { + "0": "Clear sky", + "1": "Mainly clear", + "2": "Partly cloudy", + "3": "Overcast", + "45": "Fog", + "48": "Depositing rime fog", + "51": "Light drizzle", + "53": "Moderate drizzle", + "55": "Dense drizzle", + "56": "Light freezing drizzle", + "57": "Dense freezing drizzle", + "61": "Slight rain", + "63": "Moderate rain", + "65": "Heavy rain", + "66": "Light freezing rain", + "67": "Heavy freezing rain", + "71": "Slight snowfall", + "73": "Moderate snowfall", + "75": "Heavy snowfall", + "77": "Snow grains", + "80": "Slight rain showers", + "81": "Moderate rain showers", + "82": "Violent rain showers", + "85": "Slight snow showers", + "86": "Heavy snow showers", + "95": "Thunderstorm", + "96": "Thunderstorm with slight hail", + "99": "Thunderstorm with heavy hail" } } diff --git a/weather_briefing/reference_data.py b/weather_briefing/reference_data.py index 8002e2a3..c270c70e 100644 --- a/weather_briefing/reference_data.py +++ b/weather_briefing/reference_data.py @@ -144,11 +144,11 @@ def reference_string_tuple(filename: str, *path: str) -> tuple[str, ...]: @cache def open_meteo_weather_code_descriptions() -> Mapping[int, str]: - """Return validated Chinese descriptions for Open-Meteo WMO weather codes.""" + """Return validated English descriptions for Open-Meteo WMO weather codes.""" value = load_reference_data("open_meteo_weather_codes.json") - descriptions = value.get("descriptions_zh_CN") - if set(value) != {"descriptions_zh_CN"} or not isinstance(descriptions, dict) or not descriptions: - raise ReferenceDataError("Open-Meteo weather codes must contain Chinese descriptions") + descriptions = value.get("descriptions_en") + if set(value) != {"descriptions_en"} or not isinstance(descriptions, dict) or not descriptions: + raise ReferenceDataError("Open-Meteo weather codes must contain English descriptions") validated: dict[int, str] = {} for code, description in descriptions.items(): diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index 083a6f37..cdb70978 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -46,10 +46,11 @@ supported=("zh-CN", "zh-TW", "en", "ja"), api_codes=(("zh-CN", "zh"), ("zh-TW", "zh-hant"), ("en", "en"), ("ja", "ja")), ) -OPEN_METEO_LANGUAGE_SUPPORT = LanguageSupport.fixed("zh-CN") +OPEN_METEO_LANGUAGE_SUPPORT = LanguageSupport.fixed("en") _WEATHER_DOCUMENT_LABELS = localization_table("weather_document") _QWEATHER_FORMATS = localization_table("qweather") +open_meteo_weather_code_descriptions() class WeatherContextError(RuntimeError): @@ -672,7 +673,7 @@ def _parse_allergen( ) return AllergenSnapshot( source_id="allergen:open-meteo", - source_name="Open-Meteo / CAMS ENSEMBLE 花粉过敏原", + source_name="Open-Meteo / CAMS ENSEMBLE pollen allergens", source_url="https://open-meteo.com/en/docs/air-quality-api", observed_at=observed_at, levels=tuple(levels), @@ -1025,18 +1026,19 @@ def _float_value(value: object) -> float: def _format_open_meteo_day(daily: dict[str, object], index: int) -> str: return ( - f"{_open_meteo_daily_value(daily, 'time', index)}:" - f"{_open_meteo_weather_description(_open_meteo_daily_value(daily, 'weather_code', index))}," + f"{_open_meteo_daily_value(daily, 'time', index)}: " + f"{_open_meteo_weather_description(_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)}" + f"{_open_meteo_daily_value(daily, 'temperature_2m_max', index)} °C, " + f"feels like {_open_meteo_daily_value(daily, 'apparent_temperature_min', index)}~" + f"{_open_meteo_daily_value(daily, 'apparent_temperature_max', index)} °C, " + f"expected precipitation {_open_meteo_daily_value(daily, 'precipitation_sum', index)} mm, " + f"maximum precipitation probability " + f"{_open_meteo_daily_value(daily, 'precipitation_probability_max', index)}%, " + f"maximum wind speed {_open_meteo_daily_value(daily, 'wind_speed_10m_max', index)} km/h, " + f"maximum gust {_open_meteo_daily_value(daily, 'wind_gusts_10m_max', index)} km/h, " + f"dominant wind direction {_open_meteo_daily_value(daily, 'wind_direction_10m_dominant', index)}°, " + f"maximum UV index {_open_meteo_daily_value(daily, 'uv_index_max', index)}" ) @@ -1048,4 +1050,4 @@ def _open_meteo_weather_description(value: object) -> str: _LOGGER.warning("Unknown Open-Meteo weather code code=%d", value) else: _LOGGER.warning("Invalid Open-Meteo weather code value_type=%s", type(value).__name__) - return "未识别天气现象" + return "Unrecognized weather condition"