diff --git a/README.md b/README.md index d42d06b6..270de798 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ uv run --frozen weather-briefing run briefing `env.example` 将必填项、条件必填项和选填项分别写在注释中,所有凭据和投递标识均为无效占位值。复制 `locations.example.json` 为被 Git 忽略的 `locations.json` 后可配置多个地点;示例使用北京市西城区中南海的公开坐标。每项必须有稳定 `id`,并在 `name` 与成对的 `latitude`、`longitude` 之间至少提供一项:只有名称时程序正向解析并支持降精度回退,只有坐标时通过 Nominatim 反查规范地点名和行政信息,两者都有时不发起定位请求。解析结果缓存到 `state/`。 +`CONTEXT_SOURCES_JSON` 可配置由 `id`、`name`、`url` 和可选 `language` 组成的辅助 HTTP 上下文数组。`language` 使用基础 BCP 47-like 标签记录正文实际语种;无法确定时省略并按 `und` 处理,不能默认假定为中文。 + LLM 调用由 any-llm SDK 承担。`LLM_PROVIDER` 使用 any-llm 的 provider ID,`LLM_MODEL` 是该 provider 接受的模型 ID;凭据、API Base 和其他认证配置直接使用 any-llm 为对应 provider 定义的环境变量。开发环境安装 `any-llm-sdk[all]` 以验证全部 provider,应用的基础运行依赖只安装 SDK 核心包;官方 OCI 镜像预装 `deepseek`、`openai` 和 `openrouter` provider extras,其他生产安装可按实际选择合并 extras,例如 `uv pip install 'weather-briefing' 'any-llm-sdk[deepseek,openai]>=1.19,<2'`。为兼容已有 DeepSeek 部署,`DEEPSEEK_MODEL` 可作为 `LLM_MODEL` 的后备,`DEEPSEEK_BASE_URL` 可作为 `DEEPSEEK_API_BASE` 的后备。 应用将带时间、级别和 logger 名称的运行日志写入标准错误;INFO 日志记录每个地点的天气 provider 顺序和逻辑降级过程,并为天气、空气质量、地理编码、LLM、RSS、辅助上下文及 Telegram 的每个实际 HTTP 请求记录 provider、operation、方法、成功或失败、耗时和 HTTP 状态或异常类型,因此可从容器日志还原外部 API 调用历史。RSS 重试与 Telegram 分片分别按实际请求次数记录。常规 INFO 日志及仅由 `DEBUG=true` 启用的非敏感诊断不记录坐标、标题、正文、URL、token、chat ID、请求 endpoint 或异常消息;DEBUG 元数据覆盖从 RSS 清洗、权威预报转发和平台渲染到 Telegram 分片接受状态的链路。若仍需排查平台渲染或分片内容,可在不重启 daemon 的情况下临时记录完整渲染正文: diff --git a/docs/design.md b/docs/design.md index a66a764b..e29c9581 100644 --- a/docs/design.md +++ b/docs/design.md @@ -50,6 +50,8 @@ service 将最终解析得到的完整地点名作为 `location_scope.full_name` 能力组合边界由 `capabilities.py` 的 `CapabilityProviderSet` 承担。天气、空气质量、结构化过敏原、生活指数、预警和短时预报属于可独立声明的 capability;现有 QWeather/Open-Meteo 完整上下文 adapter 暂时挂在天气槽位,AQICN 挂在空气质量槽位。`ALLERGEN` 表示 adapter 能提供独立 `AllergenSnapshot`,不表示任意与过敏有关的文本;QWeather 类型 7 综合过敏指数仍属于 `LIFESTYLE`,即使它会标记文档包含过敏建议。这样本地气象机构可以只实现预警或 nowcast,而不必伪装为完整天气 provider;后续能力 provider 不应为填充无关字段而发起额外请求。 +每个天气或能力 provider 通过 `LanguageSupport` 声明固定或可选的输出语言,规范化快照和 `SourceDocument` 使用 BCP 47 标签记录实际正文语言,并把该标签随当前及历史上下文传入 LLM。可选语言在 provider 请求边界映射为厂商 wire code;固定语言不会被伪装为用户目标语言。LLM 只在来源语言与目标输出语言不同时于最终输出阶段翻译一次,来源语言已经匹配时不得先翻译到中间语言再译回。SQLite 持久化来源语言,旧快照迁移时按当时唯一的中文输出契约标记为 `zh-CN`。 + `QWeatherProvider` 的常规预报读取实时空气质量、今明两日天气和当日生活指数;显式目标日期查询改用 3 日生活指数及 3 日空气质量预报,并按天气预报中的目标日期选择同一天的数据。它提供目标日期天气、温度、风、湿度、预期降水及运动、穿衣、旅游、舒适度和交通指数。空气质量请求失败不会丢弃已经有效的天气结果;常规预报把空气质量留空交给补充层,目标日期查询则保留缺失而不使用当前 AQICN 观测冒充预报。 `OpenMeteoProvider` 使用全球 Weather Forecast API,并尝试从其独立 Air Quality API 获取 U.S. AQI 与 PM2.5 浓度。公开 endpoint 适用于非商业免费使用、要求署名且无 SLA;Base URL 和可选 API Key 可配置,以便切换商业 endpoint。 diff --git a/docs/notes.md b/docs/notes.md index f28a0340..50938573 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -82,6 +82,8 @@ Nominatim 限速使用 `await asyncio.sleep()`,它只挂起当前协程并把 ## LLM 历史输入预算的当前范围 +来源语言是输入边界的事实元数据,不是用户目标语种的别名。`SourceDocument` 构造时规范化基础 BCP 47-like 标签,再写入 SQLite 和 LLM payload,避免同一正文因大小写差异产生伪历史变化;旧数据库的语言列只保留迁移时已知的 `zh-CN` 默认值,未声明语言的任意 HTTP context 使用 `und`。`LanguageSupport.select()` 继续用于显式选择 provider 已声明的 wire 语言并严格拒绝未知值,而地点级用户目标语种通过 `match()` 按逐步去除区域/脚本后缀选择最接近的 provider 语言,匹配失败回到 provider 默认值。这样固定只输出 `ja` 的 API 可以服务 `ja-JP` 的用户而不在构造阶段失败;LLM 仍收到实际来源标签,并在同一主语言时避免无意义的往返翻译。当前成立条件是所有内置 provider、地点配置和 context source 只需要语言、脚本与地区子标签,因此解析刻意不承诺完整 BCP 47 extension/private-use 语法。任一 provider wire code、用户配置或外部持久化契约开始要求 extension/private-use 标签,或出现需要按 Unicode locale extension 区分输出的产品需求时,必须改用成熟 BCP 47 解析器,并重新审视规范化、主语言匹配与 wire-code 映射。 + `LLM_HISTORY_MAX_DOCUMENTS` 和 `LLM_HISTORY_MAX_CHARACTERS` 只限制逐小时积累的历史 API 上下文快照。历史文章与已发布简报仍由 `HISTORY_HOURS` 控制时间范围,不另设数量或字符预算。当前接受这一边界,是因为已配置 RSS 的发布频率较低,普通 briefing 只有值得打扰时才保存正文,而 API 快照是唯一会在每个调度轮次稳定增长的输入类别。 应用以 DEBUG 元数据记录完整 payload 的序列化字符数,但不记录正文。若 RSS 发布量或地点数显著增加、历史简报开始接近逐小时产生、payload 字符数持续增长,或再次出现与输入规模相关的模型超时或上下文长度错误,应把文章、简报和快照收敛到带类别最低保留量的统一历史输入预算,而不是继续分别增加当前两个快照上限。 diff --git a/env.example b/env.example index bc88fdae..8204a29f 100644 --- a/env.example +++ b/env.example @@ -36,6 +36,7 @@ GEOCODING_CACHE_PATH=state/geocoding.json # Optional private RSS sources. The file may be absent or contain an empty array. RSS_SOURCES_FILE=rss-sources.json +# Auxiliary HTTP context items accept id, name, url, and optional language; unknown defaults to und. CONTEXT_SOURCES_JSON=[] RSS_MAX_ATTEMPTS=3 RSS_RETRY_MIN_SECONDS=3 diff --git a/tests/test_air_quality.py b/tests/test_air_quality.py index 96c7c20f..c40eb3d4 100644 --- a/tests/test_air_quality.py +++ b/tests/test_air_quality.py @@ -220,10 +220,36 @@ def test_air_quality_document_labels_forecast_time() -> None: assert "预报时段:2026-07-15T18:00:00+08:00" in document.content assert "观测时间" not in document.content assert document.history_value is not None - assert document.history_value.startswith("时间类型:forecast\n") + assert document.history_value.startswith("时间类型:预报\n") assert "2026-07-15T18:00:00+08:00" not in document.history_value +def test_air_quality_document_scaffold_matches_english_source_language() -> None: + snapshot = AirQualitySnapshot( + source_id="air-quality:test", + source_name="Test", + source_url="https://example.invalid/air-quality", + effective_at=None, + time_kind=AirQualityTimeKind.OBSERVATION, + aqi=42, + aqi_display="42", + aqi_standard="US EPA", + pm25_aqi=None, + pm25_concentration=None, + pm25_unit=None, + category="Good", + health_guidance="Normal activities are suitable.", + output_language="en", + ) + + document = air_quality_to_document(snapshot) + + assert document.language == "en" + assert "Observed at: Unavailable" in document.content + assert "Health guidance: Normal activities are suitable." in document.content + assert "健康提示" not in document.content + + def test_health_guidance_unbounded_band_required(monkeypatch) -> None: from weather_briefing.air_quality import _guidance_bands diff --git a/tests/test_allergen.py b/tests/test_allergen.py index 0f157d36..353156bf 100644 --- a/tests/test_allergen.py +++ b/tests/test_allergen.py @@ -76,6 +76,26 @@ def test_allergen_to_document_format() -> None: assert "2026-07-13T08:00:00Z" not in document.history_value +def test_allergen_document_scaffold_matches_english_source_language() -> None: + snapshot = AllergenSnapshot( + source_id="allergen:test", + source_name="Test pollen", + source_url="https://example.invalid/allergen", + observed_at=None, + levels=(AllergenLevel(name="Birch", category="Moderate", concentration=15),), + overall_category="Moderate", + health_guidance="Reduce prolonged outdoor activity.", + output_language="en", + ) + + document = allergen_to_document(snapshot) + + assert document.language == "en" + assert "Pollen allergens:" in document.content + assert "Birch: 15 grains/m³ (Moderate)" in document.content + assert "花粉过敏原" not in document.content + + def test_allergen_to_document_without_observed_at() -> None: snapshot = AllergenSnapshot( source_id="allergen:test", diff --git a/tests/test_config.py b/tests/test_config.py index c6547e81..784bb444 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -777,6 +777,36 @@ def test_context_source_accepts_and_strips_required_strings(self, monkeypatch) - assert source.id == "context" assert source.name == "Context" assert source.url == "https://example.invalid/context" + assert source.language == "und" + + def test_context_source_normalizes_declared_language(self, monkeypatch) -> None: + _required_environment(monkeypatch) + monkeypatch.setenv( + "CONTEXT_SOURCES_JSON", + '[{"id":"context","name":"Context","url":"https://example.invalid/context","language":"EN-us"}]', + ) + + assert Settings.from_env().context_sources[0].language == "en-US" + + @pytest.mark.parametrize("language", ("english", 1, None)) + def test_context_source_rejects_invalid_language(self, monkeypatch, language: object) -> None: + _required_environment(monkeypatch) + monkeypatch.setenv( + "CONTEXT_SOURCES_JSON", + json.dumps( + [ + { + "id": "context", + "name": "Context", + "url": "https://example.invalid/context", + "language": language, + } + ] + ), + ) + + with pytest.raises(ConfigurationError, match=r"CONTEXT_SOURCES_JSON\[0\]\.language"): + Settings.from_env() def test_invalid_timezone_raises_error(self, monkeypatch) -> None: _required_environment(monkeypatch) diff --git a/tests/test_languages.py b/tests/test_languages.py new file mode 100644 index 00000000..9f4d8e2f --- /dev/null +++ b/tests/test_languages.py @@ -0,0 +1,98 @@ +import pytest + +from weather_briefing.languages import LanguageSupport, localized_labels, normalize_language_tag +from weather_briefing.models import SourceDocument +from weather_briefing.weather_context import OPEN_METEO_LANGUAGE_SUPPORT, QWEATHER_LANGUAGE_SUPPORT + + +def test_language_tags_are_normalized() -> None: + assert normalize_language_tag(" zh-hans ") == "zh-Hans" + assert normalize_language_tag("EN-us") == "en-US" + + +@pytest.mark.parametrize("value", ("", "en_US", "english")) +def test_language_tags_reject_invalid_values(value: str) -> None: + with pytest.raises(ValueError, match="BCP 47"): + normalize_language_tag(value) + + +def test_language_support_selects_supported_api_language() -> None: + support = LanguageSupport( + default="zh-CN", + supported=("zh-CN", "ja"), + api_codes=(("zh-CN", "zh"), ("ja", "ja")), + ) + + assert support.select(None) == "zh-CN" + assert support.select("JA") == "ja" + assert support.api_code("ja") == "ja" + assert support.selectable is True + + +def test_language_support_matches_region_variant_to_provider_language() -> None: + support = LanguageSupport(default="zh-CN", supported=("zh-CN", "ja")) + + assert support.match(None) == "zh-CN" + assert support.match("ja-JP") == "ja" + assert support.match("fr-FR") == "zh-CN" + + +def test_fixed_language_rejects_other_output_language() -> None: + support = LanguageSupport.fixed("en") + + assert support.selectable is False + assert support.api_code() == "en" + with pytest.raises(ValueError, match="does not support"): + support.select("ja") + + +def test_weather_provider_language_metadata_distinguishes_selectable_and_fixed_sources() -> None: + 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" + + +@pytest.mark.parametrize( + ("default", "supported"), + (("en", ()), ("ja", ("en",)), ("en", ("en", "EN"))), +) +def test_language_support_rejects_invalid_supported_sets(default: str, supported: tuple[str, ...]) -> None: + with pytest.raises(ValueError, match="unique supported default"): + LanguageSupport(default=default, supported=supported) + + +@pytest.mark.parametrize( + "api_codes", + ( + (("en", "en"),), + (("en", "en"), ("ja", "ja"), ("JA", "jp")), + (("en", "en"), ("fr", "fr")), + (("en", "en"), ("ja", " ")), + ), +) +def test_language_support_rejects_incomplete_or_invalid_api_codes( + api_codes: tuple[tuple[str, str], ...], +) -> None: + with pytest.raises(ValueError, match="API codes"): + LanguageSupport(default="en", supported=("en", "ja"), api_codes=api_codes) + + +def test_localized_labels_match_exact_then_primary_language() -> None: + translations = {"en": {"label": "Label"}, "zh-TW": {"label": "標籤"}} + + assert localized_labels("en-US", translations)["label"] == "Label" + assert localized_labels("zh-TW", translations)["label"] == "標籤" + with pytest.raises(ValueError, match="No document scaffold"): + localized_labels("ja", translations) + + +def test_source_document_normalizes_language_at_model_boundary() -> None: + document = SourceDocument("source", "Source", "https://example.invalid/source", "内容", language="JA") + + assert document.language == "ja" + + +def test_source_document_rejects_invalid_language() -> None: + with pytest.raises(ValueError, match="language tag"): + SourceDocument("source", "Source", "https://example.invalid/source", "内容", language="english") diff --git a/tests/test_service.py b/tests/test_service.py index 261387aa..3db7d098 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -341,6 +341,7 @@ def test_context_history_enforces_document_and_serialized_character_limits() -> "latest", oversized.name, oversized.url, + oversized.language, oversized.content, "", ): @@ -380,6 +381,7 @@ def test_context_history_uses_deterministic_summary_before_skipping_mandatory_do "source_id": "weather", "name": "Source weather", "url": "https://example.invalid/weather", + "language": "zh-CN", "content": "weather summary", "history_role": "latest", "content_compacted": True, diff --git a/tests/test_sources.py b/tests/test_sources.py index 7847ec8f..c3d44f43 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -263,12 +263,13 @@ async def test_http_context_source_fetches_successfully() -> None: transport=httpx.MockTransport(lambda _: httpx.Response(200, text="context data")) ) as client: result = await HTTPContextSource(client).fetch( - ContextSourceConfig(id="ctx", name="Context", url="https://example.invalid/ctx") + ContextSourceConfig(id="ctx", name="Context", url="https://example.invalid/ctx", language="en") ) assert isinstance(result, SourceDocument) assert result.id == "ctx" assert result.content == "context data" + assert result.language == "en" async def test_http_context_source_raises_on_http_error() -> None: diff --git a/tests/test_state.py b/tests/test_state.py index 90c09fc6..ee01e655 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -379,6 +379,22 @@ def test_context_snapshots_are_available_for_briefing_change_detection(tmp_path: assert state.recent_context_documents(now.add(hours=3), 2) == () +def test_context_snapshot_language_is_persisted(tmp_path: Path) -> None: + now = pendulum.datetime(2026, 7, 13, 9, tz="Asia/Shanghai") + document = SourceDocument( + "weather:jma", + "JMA", + "https://example.invalid/jma", + "雨", + language="ja", + ) + + with SQLiteStateStore(tmp_path / "state.db") as state: + state.save_context_documents((document,), now) + + assert state.recent_context_documents(now.add(hours=1), 2) == (document,) + + def test_existing_context_snapshot_schema_adds_history_fields(tmp_path: Path) -> None: database_path = tmp_path / "existing-state.db" with closing(sqlite3.connect(database_path)) as connection: diff --git a/tests/test_weather_context.py b/tests/test_weather_context.py index a2265494..87fd0986 100644 --- a/tests/test_weather_context.py +++ b/tests/test_weather_context.py @@ -22,6 +22,8 @@ QWeatherJWTAuthenticator, QWeatherProvider, WeatherContextError, + _format_qweather_day, + _format_qweather_lifestyle, _open_meteo_daily_peak_values, snapshot_to_documents, ) @@ -131,6 +133,7 @@ def fake_encode( async def test_qweather_provider_returns_weather_lifestyle_and_air_quality() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.headers["Authorization"] == "Bearer runtime-token" + assert request.url.params["lang"] == "zh" if request.url.path == "/v7/weather/3d": return httpx.Response( 200, @@ -217,6 +220,7 @@ def handler(request: httpx.Request) -> httpx.Response: ).fetch(39.911389, 116.380556) assert snapshot.source_id == "weather:qweather" + assert snapshot.output_language == "zh-CN" assert snapshot.observed_at.to_iso8601_string() == "2026-07-13T08:00:00+08:00" assert len(snapshot.weather_forecast) == 2 assert snapshot.lifestyle_advice == ( @@ -240,6 +244,33 @@ def handler(request: httpx.Request) -> httpx.Response: assert snapshot.observed_at.to_iso8601_string() not in documents[0].history_value +async def test_qweather_provider_selects_supported_output_language() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["lang"] == "ja" + return httpx.Response(500) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(WeatherContextError, match="HTTP 500"): + await QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + output_language="ja", + ).fetch(1, 2) + + +async def test_qweather_provider_matches_region_variant_output_language() -> None: + async with httpx.AsyncClient() as client: + provider = QWeatherProvider( + client, + authenticator=StaticAuthenticator(), + base_url="https://api.example.invalid", + output_language="ja-JP", + ) + + assert provider.output_language == "ja" + + async def test_qweather_provider_selects_requested_future_date() -> None: target_date = pendulum.date(2026, 7, 15) @@ -1312,6 +1343,44 @@ async def test_snapshot_to_documents_without_air_quality() -> None: assert [doc.id for doc in documents] == ["weather:test"] +def test_weather_document_scaffold_matches_japanese_source_language() -> None: + snapshot = WeatherContextSnapshot( + source_id="weather:test", + source_name="Test", + source_url="https://example.invalid/", + observed_at=pendulum.datetime(2026, 7, 13, 8, tz="Asia/Tokyo"), + weather_forecast=("晴れ",), + lifestyle_advice=("運動指数:適しています",), + output_language="ja", + ) + + document = snapshot_to_documents(snapshot)[0] + + assert document.language == "ja" + assert "更新時刻:" in document.content + assert "天気予報:" in document.content + assert "更新时间" not in document.content + + +def test_qweather_scaffold_matches_selected_language() -> None: + forecast = { + "fxDate": "2026-07-20", + "textDay": "Sunny", + "textNight": "Clear", + "tempMin": "20", + "tempMax": "30", + "windDirDay": "South wind", + "windScaleDay": "3", + "humidity": "50", + "precip": "0", + } + + assert "relative humidity 50%" in _format_qweather_day(forecast, "en") + assert _format_qweather_lifestyle({"name": "運動指数", "category": "適切", "text": "運動日和です"}, "ja") == ( + "運動指数(適切):運動日和です" + ) + + async def test_qweather_air_quality_parses_invalid_indexes_gracefully() -> None: def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/v7/weather/3d": diff --git a/weather_briefing/air_quality.py b/weather_briefing/air_quality.py index db9a030e..789dffac 100644 --- a/weather_briefing/air_quality.py +++ b/weather_briefing/air_quality.py @@ -9,10 +9,74 @@ import pendulum from .api_client import api_call_extensions +from .languages import localized_labels from .models import AirQualitySnapshot, AirQualityTimeKind, SourceDocument from .reference_data import ReferenceDataError, reference_value from .time_utils import parse_datetime_with_default_timezone +_AIR_QUALITY_FORMATS = { + "zh-CN": { + "separator": ":", + "unavailable": "不可用", + "forecast_time": "预报时段", + "observation_time": "观测时间", + "observation": "观测", + "forecast": "预报", + "time_kind": "时间类型:{kind}", + "aqi": "AQI:{aqi}(标准:{standard};类别:{category})", + "aqi_summary": "AQI:{aqi}({category})", + "pm25_aqi": "PM2.5 单项 AQI:{value}(标准:{standard})", + "pm25_summary": "PM2.5 单项 AQI:{value};浓度:{concentration}", + "pm25": "PM2.5 {concentration}", + "health": "健康提示:{guidance}", + }, + "zh-TW": { + "separator": ":", + "unavailable": "無法取得", + "forecast_time": "預報時段", + "observation_time": "觀測時間", + "observation": "觀測", + "forecast": "預報", + "time_kind": "時間類型:{kind}", + "aqi": "AQI:{aqi}(標準:{standard};類別:{category})", + "aqi_summary": "AQI:{aqi}({category})", + "pm25_aqi": "PM2.5 單項 AQI:{value}(標準:{standard})", + "pm25_summary": "PM2.5 單項 AQI:{value};濃度:{concentration}", + "pm25": "PM2.5 {concentration}", + "health": "健康提示:{guidance}", + }, + "en": { + "separator": ": ", + "unavailable": "Unavailable", + "forecast_time": "Forecast period", + "observation_time": "Observed at", + "observation": "Observation", + "forecast": "Forecast", + "time_kind": "Time type: {kind}", + "aqi": "AQI: {aqi} (Standard: {standard}; Category: {category})", + "aqi_summary": "AQI: {aqi} ({category})", + "pm25_aqi": "PM2.5 sub-index AQI: {value} (Standard: {standard})", + "pm25_summary": "PM2.5 sub-index AQI: {value}; concentration: {concentration}", + "pm25": "PM2.5 concentration: {concentration}", + "health": "Health guidance: {guidance}", + }, + "ja": { + "separator": ":", + "unavailable": "利用不可", + "forecast_time": "予報期間", + "observation_time": "観測時刻", + "observation": "観測", + "forecast": "予報", + "time_kind": "時刻種別:{kind}", + "aqi": "AQI:{aqi}(基準:{standard}、区分:{category})", + "aqi_summary": "AQI:{aqi}({category})", + "pm25_aqi": "PM2.5 個別 AQI:{value}(基準:{standard})", + "pm25_summary": "PM2.5 個別 AQI:{value}、濃度:{concentration}", + "pm25": "PM2.5 濃度:{concentration}", + "health": "健康上の注意:{guidance}", + }, +} + class AirQualityError(RuntimeError): """Raised without exposing private API credentials or request URLs.""" @@ -88,40 +152,50 @@ async def fetch( pm25_unit=None, category=category, health_guidance=guidance, + output_language="zh-CN", ) def air_quality_to_document(snapshot: AirQualitySnapshot) -> SourceDocument: """Convert an air-quality snapshot into a citable source document.""" - effective_at = snapshot.effective_at.to_iso8601_string() if snapshot.effective_at is not None else "不可用" - time_label = "预报时段" if snapshot.time_kind is AirQualityTimeKind.FORECAST else "观测时间" - concentration = "不可用" + labels = localized_labels(snapshot.output_language, _AIR_QUALITY_FORMATS) + effective_at = ( + snapshot.effective_at.to_iso8601_string() if snapshot.effective_at is not None else labels["unavailable"] + ) + time_label = ( + labels["forecast_time"] if snapshot.time_kind is AirQualityTimeKind.FORECAST else labels["observation_time"] + ) + concentration = labels["unavailable"] if snapshot.pm25_concentration is not None and snapshot.pm25_unit: concentration = f"{snapshot.pm25_concentration:g} {snapshot.pm25_unit}" - pm25_aqi = "不可用" if snapshot.pm25_aqi is None else f"{snapshot.pm25_aqi:g}" - history_value = ( - f"时间类型:{snapshot.time_kind.value}\n" - f"AQI:{snapshot.aqi_display}(标准:{snapshot.aqi_standard};类别:{snapshot.category})\n" - f"PM2.5 单项 AQI:{pm25_aqi}(标准:{snapshot.aqi_standard})\n" - f"PM2.5 {concentration}\n" - f"健康提示:{snapshot.health_guidance}" + pm25_aqi = labels["unavailable"] if snapshot.pm25_aqi is None else f"{snapshot.pm25_aqi:g}" + aqi = labels["aqi"].format( + aqi=snapshot.aqi_display, + standard=snapshot.aqi_standard, + category=snapshot.category, + ) + pm25_index = labels["pm25_aqi"].format(value=pm25_aqi, standard=snapshot.aqi_standard) + pm25 = labels["pm25"].format(concentration=concentration) + health = labels["health"].format(guidance=snapshot.health_guidance) + history_value = "\n".join( + ( + labels["time_kind"].format(kind=labels[snapshot.time_kind.value]), + aqi, + pm25_index, + pm25, + health, + ) ) return SourceDocument( id=snapshot.source_id, name=snapshot.source_name, url=snapshot.source_url, - content=( - f"{time_label}:{effective_at}\n" - f"AQI:{snapshot.aqi_display}(标准:{snapshot.aqi_standard};" - f"类别:{snapshot.category})\n" - f"PM2.5 单项 AQI:{pm25_aqi}(标准:{snapshot.aqi_standard})\n" - f"PM2.5 {concentration}\n" - f"健康提示:{snapshot.health_guidance}" - ), + content="\n".join((f"{time_label}{labels['separator']}{effective_at}", aqi, pm25_index, pm25, health)), + language=snapshot.output_language, history_summary=( - f"{time_label}:{effective_at}\n" - f"AQI:{snapshot.aqi_display}({snapshot.category})\n" - f"PM2.5 单项 AQI:{pm25_aqi};浓度:{concentration}" + f"{time_label}{labels['separator']}{effective_at}\n" + f"{labels['aqi_summary'].format(aqi=snapshot.aqi_display, category=snapshot.category)}\n" + f"{labels['pm25_summary'].format(value=pm25_aqi, concentration=concentration)}" ), history_value=history_value, ) diff --git a/weather_briefing/allergen.py b/weather_briefing/allergen.py index e8745826..e064281d 100644 --- a/weather_briefing/allergen.py +++ b/weather_briefing/allergen.py @@ -5,9 +5,53 @@ from functools import cache from math import isfinite +from .languages import localized_labels from .models import AllergenSnapshot, SourceDocument from .reference_data import ReferenceDataError, reference_value +_ALLERGEN_FORMATS = { + "zh-CN": { + "separator": ":", + "unavailable": "不可用", + "observed_at": "观测时间", + "allergens": "花粉过敏原", + "overall": "总体等级", + "health": "健康提示", + "count": "花粉类型数", + "level": "- {name}:{concentration:g} 粒/m³({category})", + }, + "zh-TW": { + "separator": ":", + "unavailable": "無法取得", + "observed_at": "觀測時間", + "allergens": "花粉過敏原", + "overall": "整體等級", + "health": "健康提示", + "count": "花粉類型數", + "level": "- {name}:{concentration:g} 粒/m³({category})", + }, + "en": { + "separator": ": ", + "unavailable": "Unavailable", + "observed_at": "Observed at", + "allergens": "Pollen allergens", + "overall": "Overall level", + "health": "Health guidance", + "count": "Pollen type count", + "level": "- {name}: {concentration:g} grains/m³ ({category})", + }, + "ja": { + "separator": ":", + "unavailable": "利用不可", + "observed_at": "観測時刻", + "allergens": "花粉アレルゲン", + "overall": "総合レベル", + "health": "健康上の注意", + "count": "花粉種類数", + "level": "- {name}:{concentration:g} 粒/m³({category})", + }, +} + def allergen_guidance(concentration: float) -> tuple[str, str]: """Return (category, guidance) for a pollen concentration in grains/m³.""" @@ -21,27 +65,43 @@ def allergen_guidance(concentration: float) -> tuple[str, str]: def allergen_to_document(snapshot: AllergenSnapshot) -> SourceDocument: """Convert an allergen snapshot into a citable source document.""" - observed_at = snapshot.observed_at.to_iso8601_string() if snapshot.observed_at is not None else "不可用" + labels = localized_labels(snapshot.output_language, _ALLERGEN_FORMATS) + separator = labels["separator"] + observed_at = ( + snapshot.observed_at.to_iso8601_string() if snapshot.observed_at is not None else labels["unavailable"] + ) levels = ( - "\n".join(f"- {level.name}:{level.concentration:g} 粒/m³({level.category})" for level in snapshot.levels) - or "不可用" + "\n".join( + labels["level"].format( + name=level.name, + concentration=level.concentration, + category=level.category, + ) + for level in snapshot.levels + ) + or labels["unavailable"] ) history_value = ( - f"花粉过敏原:\n{levels}\n总体等级:{snapshot.overall_category}\n健康提示:{snapshot.health_guidance}" + f"{labels['allergens']}{separator}\n{levels}\n" + f"{labels['overall']}{separator}{snapshot.overall_category}\n" + f"{labels['health']}{separator}{snapshot.health_guidance}" ) return SourceDocument( id=snapshot.source_id, name=snapshot.source_name, url=snapshot.source_url, has_allergen_information=True, + language=snapshot.output_language, content=( - f"观测时间:{observed_at}\n" - f"花粉过敏原:\n{levels}\n" - f"总体等级:{snapshot.overall_category}\n" - f"健康提示:{snapshot.health_guidance}" + f"{labels['observed_at']}{separator}{observed_at}\n" + f"{labels['allergens']}{separator}\n{levels}\n" + f"{labels['overall']}{separator}{snapshot.overall_category}\n" + f"{labels['health']}{separator}{snapshot.health_guidance}" ), history_summary=( - f"观测时间:{observed_at}\n花粉类型数:{len(snapshot.levels)}\n总体等级:{snapshot.overall_category}" + f"{labels['observed_at']}{separator}{observed_at}\n" + f"{labels['count']}{separator}{len(snapshot.levels)}\n" + f"{labels['overall']}{separator}{snapshot.overall_category}" ), history_value=history_value, ) diff --git a/weather_briefing/capabilities.py b/weather_briefing/capabilities.py index 7f6cd964..1703ea19 100644 --- a/weather_briefing/capabilities.py +++ b/weather_briefing/capabilities.py @@ -2,13 +2,14 @@ from __future__ import annotations -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from enum import StrEnum from typing import Protocol import pendulum from .air_quality import AirQualityError, AirQualityProvider +from .languages import LanguageSupport from .models import WeatherContextSnapshot from .time_utils import datetime_timezone_specifier @@ -43,6 +44,7 @@ class ProviderCapabilities: provider_id: str provider_name: str capabilities: frozenset[CapabilityName] + language_support: LanguageSupport = field(default_factory=lambda: LanguageSupport.fixed("zh-CN")) def supports(self, capability: CapabilityName) -> bool: """Return whether this provider exposes a capability.""" diff --git a/weather_briefing/cli.py b/weather_briefing/cli.py index 840c763c..03c16c25 100644 --- a/weather_briefing/cli.py +++ b/weather_briefing/cli.py @@ -46,6 +46,8 @@ from .state import SQLiteRuntimeDiagnostics, SQLiteStateStore from .time_utils import parse_aware_datetime from .weather_context import ( + OPEN_METEO_LANGUAGE_SUPPORT, + QWEATHER_LANGUAGE_SUPPORT, FallbackWeatherContextProvider, LoggedWeatherContextProvider, OpenMeteoProvider, @@ -422,6 +424,7 @@ def _build_telegram_publisher( CapabilityName.LIFESTYLE, } ), + language_support=QWEATHER_LANGUAGE_SUPPORT, ), WeatherProviderName.OPEN_METEO: ProviderCapabilities( provider_id=WeatherProviderName.OPEN_METEO, @@ -433,6 +436,7 @@ def _build_telegram_publisher( CapabilityName.ALLERGEN, } ), + language_support=OPEN_METEO_LANGUAGE_SUPPORT, ), } diff --git a/weather_briefing/config.py b/weather_briefing/config.py index d66acdbb..8488a546 100644 --- a/weather_briefing/config.py +++ b/weather_briefing/config.py @@ -16,6 +16,7 @@ from soupsieve import SelectorSyntaxError from soupsieve import compile as compile_selector +from .languages import normalize_language_tag from .models import ContextSourceConfig, FeedConfig, LocationSpec, ResolvedLocation from .reference_data import reference_string_tuple from .registries import PublisherName, WeatherProviderName @@ -177,7 +178,16 @@ def _context_source(item: object, index: int) -> ContextSourceConfig: if not isinstance(value, str) or not value.strip(): raise ConfigurationError(f"CONTEXT_SOURCES_JSON[{index}].{field} must be a non-empty string") values[field] = value.strip() - return ContextSourceConfig(id=values["id"], name=values["name"], url=values["url"]) + language_value = item.get("language", "und") + if not isinstance(language_value, str): + raise ConfigurationError(f"CONTEXT_SOURCES_JSON[{index}].language must be a basic BCP 47-like language tag") + try: + language = normalize_language_tag(language_value) + except ValueError as exc: + raise ConfigurationError( + f"CONTEXT_SOURCES_JSON[{index}].language must be a basic BCP 47-like language tag" + ) from exc + return ContextSourceConfig(id=values["id"], name=values["name"], url=values["url"], language=language) def _configured_weather_providers() -> tuple[str, ...] | None: diff --git a/weather_briefing/languages.py b/weather_briefing/languages.py new file mode 100644 index 00000000..465162d7 --- /dev/null +++ b/weather_briefing/languages.py @@ -0,0 +1,96 @@ +"""Small, provider-neutral language metadata used at provider boundaries.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass + +_LANGUAGE_TAG = re.compile(r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*") + + +def normalize_language_tag(value: str) -> str: + """Validate a basic BCP 47-like tag and normalize its separator casing.""" + value = value.strip() + if _LANGUAGE_TAG.fullmatch(value) is None: + raise ValueError("Language must be a basic BCP 47-like language tag") + parts = value.split("-") + return "-".join([parts[0].lower(), *(part.title() if len(part) == 4 else part.upper() for part in parts[1:])]) + + +@dataclass(frozen=True, slots=True) +class LanguageSupport: + """Describe fixed or selectable output languages of a provider.""" + + default: str + supported: tuple[str, ...] + api_codes: tuple[tuple[str, str], ...] = () + + def __post_init__(self) -> None: + """Validate and normalize configured language tags.""" + default = normalize_language_tag(self.default) + supported = tuple(normalize_language_tag(value) for value in self.supported) + if not supported or default not in supported or len(set(supported)) != len(supported): + raise ValueError("Language support must contain a unique supported default language") + api_codes: list[tuple[str, str]] = [] + for language, code in self.api_codes: + normalized = normalize_language_tag(language) + if normalized not in supported or not isinstance(code, str) or not code.strip(): + raise ValueError("Language API codes must map supported languages to non-empty strings") + api_codes.append((normalized, code.strip())) + if api_codes and ( + len({language for language, _ in api_codes}) != len(api_codes) + or {language for language, _ in api_codes} != set(supported) + ): + raise ValueError("Language API codes must uniquely cover every supported language") + object.__setattr__(self, "default", default) + object.__setattr__(self, "supported", supported) + object.__setattr__(self, "api_codes", tuple(api_codes)) + + @property + def selectable(self) -> bool: + """Return whether the provider can select among multiple output languages.""" + return len(self.supported) > 1 + + def select(self, requested: str | None) -> str: + """Select a requested supported language or use the provider default.""" + if requested is None: + return self.default + normalized = normalize_language_tag(requested) + if normalized not in self.supported: + raise ValueError(f"Provider does not support output language: {normalized}") + return normalized + + def match(self, requested: str | None) -> str: + """Return the closest supported language for a user output request.""" + if requested is None: + return self.default + normalized = normalize_language_tag(requested) + parts = normalized.split("-") + while parts: + candidate = "-".join(parts) + if candidate in self.supported: + return candidate + parts.pop() + return self.default + + def api_code(self, selected: str | None = None) -> str: + """Return the provider wire-language code for a selected language.""" + language = self.select(selected) + if not self.api_codes: + return language + return dict(self.api_codes)[language] + + @classmethod + def fixed(cls, language: str) -> LanguageSupport: + """Create metadata for a provider with one fixed output language.""" + return cls(default=language, supported=(language,)) + + +def localized_labels(language: str, translations: Mapping[str, Mapping[str, str]]) -> Mapping[str, str]: + """Return scaffold labels for an exact or primary language tag.""" + normalized = normalize_language_tag(language) + for candidate in (normalized, normalized.split("-", maxsplit=1)[0]): + if candidate in translations: + return translations[candidate] + raise ValueError(f"No document scaffold labels for language: {normalized}") diff --git a/weather_briefing/models.py b/weather_briefing/models.py index 50ff2ea4..0bc39435 100644 --- a/weather_briefing/models.py +++ b/weather_briefing/models.py @@ -7,6 +7,8 @@ import pendulum +from .languages import normalize_language_tag + @dataclass(frozen=True, slots=True) class FeedConfig: @@ -29,6 +31,11 @@ class ContextSourceConfig: id: str name: str url: str + language: str = "und" + + def __post_init__(self) -> None: + """Normalize the declared or undefined source language.""" + object.__setattr__(self, "language", normalize_language_tag(self.language)) @dataclass(frozen=True, slots=True) @@ -87,10 +94,15 @@ class SourceDocument: name: str url: str content: str + language: str = "zh-CN" has_allergen_information: bool = False history_summary: str | None = None history_value: str | None = None + def __post_init__(self) -> None: + """Normalize the language before persistence or LLM serialization.""" + object.__setattr__(self, "language", normalize_language_tag(self.language)) + @dataclass(frozen=True, slots=True) class BriefingRecord: @@ -125,6 +137,7 @@ class AirQualitySnapshot: pm25_unit: str | None category: str health_guidance: str + output_language: str = "zh-CN" @dataclass(frozen=True, slots=True) @@ -147,6 +160,7 @@ class AllergenSnapshot: levels: tuple[AllergenLevel, ...] overall_category: str health_guidance: str + output_language: str = "zh-CN" @dataclass(frozen=True, slots=True) @@ -162,6 +176,7 @@ class WeatherContextSnapshot: air_quality: AirQualitySnapshot | None = None allergen: AllergenSnapshot | None = None allergen_advice_available: bool = False + output_language: str = "zh-CN" @dataclass(frozen=True, slots=True) diff --git a/weather_briefing/prompts.py b/weather_briefing/prompts.py index bdfd84a9..f065e0f1 100644 --- a/weather_briefing/prompts.py +++ b/weather_briefing/prompts.py @@ -12,6 +12,10 @@ - should_publish: boolean source_ids 只能使用输入中出现的 source ID,每条事实性结论至少引用一个来源。 +所有输出文本必须使用 input.output_language 指定的语言。context_documents 和 +recent_context_documents 中的 language 是来源正文的实际语言;来源语言与 output_language 相同时, +直接理解和总结原文,不得先翻译成其他中间语言再翻译回来。来源语言不同时只在形成最终输出时翻译一次, +不得把翻译结果冒充来源原文或改变专名、预警编号和数值。 headline_source_ids 以及 conclusions、active_warnings、disaster_tracking 和 advice 中的每一项都必须包含至少一个 source_id。 headline 必须是一句简洁、信息密集的标题,将当下最重要的天气概况浓缩其中;优先包含天气现象、 diff --git a/weather_briefing/service.py b/weather_briefing/service.py index e3a6736c..2a2dbc6a 100644 --- a/weather_briefing/service.py +++ b/weather_briefing/service.py @@ -603,6 +603,7 @@ def _build_payload( location_scope["country_code"] = self._location.country_code return { "mode": kind, + "output_language": "zh-CN", "now": now.isoformat(), "forecast_date": str(forecast_date or now.in_timezone(self._settings.timezone).date()), "region": self._location.name, @@ -615,7 +616,14 @@ def _build_payload( if kind == "forecast" or article.is_verbatim ], "context_documents": [ - {"source_id": item.id, "name": item.name, "url": item.url, "content": item.content} for item in context + { + "source_id": item.id, + "name": item.name, + "url": item.url, + "language": item.language, + "content": item.content, + } + for item in context ], "recent_context_documents": historical_context, "recent_briefings": [ @@ -654,6 +662,7 @@ def _serialize_context_document( "source_id": document.id, "name": document.name, "url": document.url, + "language": document.language, "content": document.history_summary if compact else document.content, "history_role": history_role, } @@ -819,10 +828,10 @@ def add_mandatory( ) -def _context_document_value(document: SourceDocument) -> tuple[str, str, str, str | None]: +def _context_document_value(document: SourceDocument) -> tuple[str, str, str, str, str | None]: if document.history_value is not None: - return document.name, document.url, document.history_value, None - return document.name, document.url, document.content, document.history_summary + return document.name, document.url, document.language, document.history_value, None + return document.name, document.url, document.language, document.content, document.history_summary def _required_advice_topics( diff --git a/weather_briefing/sources.py b/weather_briefing/sources.py index 0d78989f..7e23b382 100644 --- a/weather_briefing/sources.py +++ b/weather_briefing/sources.py @@ -182,4 +182,10 @@ async def fetch(self, config: ContextSourceConfig) -> SourceDocument: response.raise_for_status() except httpx.HTTPError: raise SourceFetchError(f"Context source {config.id} failed") from None - return SourceDocument(id=config.id, name=config.name, url=config.url, content=response.text) + return SourceDocument( + id=config.id, + name=config.name, + url=config.url, + content=response.text, + language=config.language, + ) diff --git a/weather_briefing/state.py b/weather_briefing/state.py index 616a1353..c6d706ba 100644 --- a/weather_briefing/state.py +++ b/weather_briefing/state.py @@ -161,7 +161,7 @@ def _initialize(self) -> None: ); CREATE TABLE IF NOT EXISTS context_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_id TEXT NOT NULL, - name TEXT NOT NULL, url TEXT NOT NULL, content TEXT NOT NULL, + name TEXT NOT NULL, url TEXT NOT NULL, content TEXT NOT NULL, language TEXT NOT NULL DEFAULT 'zh-CN', history_summary TEXT, history_value TEXT, observed_at TEXT NOT NULL ); @@ -191,6 +191,8 @@ def _initialize(self) -> None: self._connection.execute("ALTER TABLE context_snapshots ADD COLUMN history_summary TEXT") if "history_value" not in context_columns: self._connection.execute("ALTER TABLE context_snapshots ADD COLUMN history_value TEXT") + if "language" not in context_columns: + self._connection.execute("ALTER TABLE context_snapshots ADD COLUMN language TEXT NOT NULL DEFAULT 'zh-CN'") self._connection.commit() def known_article_ids(self, ids: tuple[str, ...]) -> set[str]: @@ -430,14 +432,15 @@ def _insert_context_documents( ) -> None: self._connection.executemany( """INSERT INTO context_snapshots( - source_id, name, url, content, history_summary, history_value, observed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)""", + source_id, name, url, content, language, history_summary, history_value, observed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", [ ( document.id, document.name, document.url, document.content, + document.language, document.history_summary, document.history_value, _storage_time(observed_at), @@ -450,7 +453,7 @@ def recent_context_documents(self, now: pendulum.DateTime, history_hours: int) - """Return context documents inside the configured history window.""" threshold = _storage_time(now.subtract(hours=history_hours)) rows = self._connection.execute( - """SELECT source_id, name, url, content, history_summary, history_value FROM context_snapshots + """SELECT source_id, name, url, content, language, history_summary, history_value FROM context_snapshots WHERE observed_at >= ? ORDER BY observed_at""", (threshold,), ) @@ -460,6 +463,7 @@ def recent_context_documents(self, now: pendulum.DateTime, history_hours: int) - name=str(row["name"]), url=str(row["url"]), content=str(row["content"]), + language=str(row["language"]), history_summary=str(row["history_summary"]) if row["history_summary"] is not None else None, history_value=str(row["history_value"]) if row["history_value"] is not None else None, ) diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index 1752fb0e..cb2bdd13 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -19,6 +19,7 @@ from .air_quality import AirQualityError, AirQualityProvider, air_quality_to_document, health_guidance from .allergen import allergen_guidance, allergen_to_document, pollen_type_names from .api_client import api_call_extensions +from .languages import LanguageSupport, localized_labels from .models import ( AirQualitySnapshot, AirQualityTimeKind, @@ -34,6 +35,94 @@ ) _LOGGER = logging.getLogger("weather_briefing.weather_context") +QWEATHER_LANGUAGE_SUPPORT = LanguageSupport( + default="zh-CN", + 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") + +_WEATHER_DOCUMENT_LABELS = { + "zh-CN": { + "separator": ":", + "section_separator": ":", + "unavailable": "不可用", + "updated_at": "更新时间", + "forecast": "天气预报", + "lifestyle": "生活与出行指数", + "summary": "天气概览", + "lifestyle_count": "生活与出行指数项数", + }, + "zh-TW": { + "separator": ":", + "section_separator": ":", + "unavailable": "無法取得", + "updated_at": "更新時間", + "forecast": "天氣預報", + "lifestyle": "生活與出行指數", + "summary": "天氣概覽", + "lifestyle_count": "生活與出行指數項數", + }, + "en": { + "separator": ": ", + "section_separator": ":", + "unavailable": "Unavailable", + "updated_at": "Updated at", + "forecast": "Weather forecast", + "lifestyle": "Lifestyle and travel indices", + "summary": "Weather summary", + "lifestyle_count": "Lifestyle and travel index count", + }, + "ja": { + "separator": ":", + "section_separator": ":", + "unavailable": "利用不可", + "updated_at": "更新時刻", + "forecast": "天気予報", + "lifestyle": "生活・外出指数", + "summary": "天気概要", + "lifestyle_count": "生活・外出指数の件数", + }, +} + +_QWEATHER_FORMATS = { + "zh-CN": { + "day": ( + "{date}:{day}转{night},{minimum}~{maximum}℃,{wind}{scale}级," + "相对湿度{humidity}%,预计降水量{precipitation}毫米" + ), + "lifestyle": "{name}({category}):{text}", + "unknown": "未知", + "no_details": "无详细建议", + }, + "zh-TW": { + "day": ( + "{date}:{day}轉{night},{minimum}~{maximum}℃,{wind}{scale}級," + "相對濕度{humidity}%,預計降水量{precipitation}毫米" + ), + "lifestyle": "{name}({category}):{text}", + "unknown": "未知", + "no_details": "無詳細建議", + }, + "en": { + "day": ( + "{date}: {day} to {night}; {minimum}-{maximum} °C; {wind}, force {scale}; " + "relative humidity {humidity}%; forecast precipitation {precipitation} mm" + ), + "lifestyle": "{name} ({category}): {text}", + "unknown": "Unknown", + "no_details": "No detailed advice", + }, + "ja": { + "day": ( + "{date}:{day}から{night}、{minimum}~{maximum}℃、{wind}{scale}級、" + "相対湿度{humidity}%、予想降水量{precipitation}mm" + ), + "lifestyle": "{name}({category}):{text}", + "unknown": "不明", + "no_details": "詳しいアドバイスはありません", + }, +} class WeatherContextError(RuntimeError): @@ -176,6 +265,13 @@ def authorization_header(self) -> str: class QWeatherProvider: """Fetch weather, lifestyle, and air-quality context from QWeather.""" + language_support = QWEATHER_LANGUAGE_SUPPORT + + @property + def output_language(self) -> str: + """Return the selected QWeather response language.""" + return self._output_language + def __init__( self, client: httpx.AsyncClient, @@ -183,11 +279,14 @@ def __init__( authenticator: QWeatherAuthenticator, base_url: str, index_types: tuple[str, ...] | None = None, + output_language: str = "zh-CN", ) -> None: """Configure authenticated QWeather access and lifestyle index selection.""" self._client = client self._authenticator = authenticator self._base_url = base_url + self._output_language = QWEATHER_LANGUAGE_SUPPORT.match(output_language) + self._api_language = QWEATHER_LANGUAGE_SUPPORT.api_code(self._output_language) self._index_types = index_types or reference_string_tuple( "provider_defaults.json", "qweather_lifestyle_index_types" ) @@ -209,7 +308,7 @@ async def fetch( f"{self._base_url}/v7/weather/3d", params={ "location": f"{longitude:.2f},{latitude:.2f}", - "lang": "zh", + "lang": self._api_language, "unit": "m", }, headers=headers, @@ -236,7 +335,7 @@ async def fetch( selected_forecasts = tuple(item for _, item in matching_forecasts) if matching_forecasts: forecast_index = matching_forecasts[0][0] - weather_forecast = tuple(_format_qweather_day(item) for item in selected_forecasts) + weather_forecast = tuple(_format_qweather_day(item, self._output_language) for item in selected_forecasts) if not weather_forecast: if forecast_date is None: raise WeatherContextError("QWeather returned no daily forecast") @@ -252,7 +351,7 @@ async def fetch( params={ "type": ",".join(self._index_types), "location": f"{longitude:.2f},{latitude:.2f}", - "lang": "zh", + "lang": self._api_language, }, headers=headers, extensions=api_call_extensions("qweather", "lifestyle-indices"), @@ -269,7 +368,9 @@ async def fetch( for item in indices_payload.get("daily", ()) if forecast_date is None or (isinstance(item, dict) and item.get("date") == str(forecast_date)) ) - lifestyle_advice = tuple(_format_qweather_lifestyle(item) for item in daily_indices) + lifestyle_advice = tuple( + _format_qweather_lifestyle(item, self._output_language) for item in daily_indices + ) allergen_advice_available = any( str(item.get("type")) == self._allergen_index_type for item in daily_indices @@ -318,6 +419,7 @@ async def fetch( lifestyle_advice=lifestyle_advice, air_quality=air_quality, allergen_advice_available=allergen_advice_available, + output_language=self._output_language, ) async def fetch_for_date( @@ -342,7 +444,7 @@ async def _fetch_air_quality( endpoint = "current" if forecast_index is None else "daily" response = await self._client.get( f"{self._base_url}/airquality/v1/{endpoint}/{latitude:.2f}/{longitude:.2f}", - params={"lang": "zh"}, + params={"lang": self._api_language}, headers=headers, extensions=api_call_extensions("qweather", "air-quality"), ) @@ -365,7 +467,13 @@ async def _fetch_air_quality( context="QWeather air-quality forecast start time", ) time_kind = AirQualityTimeKind.FORECAST - return _qweather_air_quality_snapshot(payload, source_url, effective_at, time_kind) + return _qweather_air_quality_snapshot( + payload, + source_url, + effective_at, + time_kind, + self._output_language, + ) except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc: _LOGGER.warning( "Weather API optional call failed provider=qweather operation=air-quality reason=%s", @@ -377,6 +485,8 @@ async def _fetch_air_quality( class OpenMeteoProvider: """Fetch global weather, air-quality, and pollen context from Open-Meteo.""" + language_support = OPEN_METEO_LANGUAGE_SUPPORT + def __init__( self, client: httpx.AsyncClient, @@ -469,6 +579,7 @@ async def fetch( weather_forecast=weather_forecast, air_quality=air_quality, allergen=allergen, + output_language=OPEN_METEO_LANGUAGE_SUPPORT.default, ) async def fetch_for_date( @@ -575,6 +686,7 @@ def _parse_air_quality( pm25_unit="μg/m³", category=category, health_guidance=guidance, + output_language=OPEN_METEO_LANGUAGE_SUPPORT.default, ) except (KeyError, TypeError, ValueError) as exc: _LOGGER.warning( @@ -625,6 +737,7 @@ def _parse_allergen( levels=tuple(levels), overall_category=overall_category, health_guidance=overall_guidance, + output_language=OPEN_METEO_LANGUAGE_SUPPORT.default, ) @@ -747,10 +860,15 @@ async def fetch_for_date( def snapshot_to_documents(snapshot: WeatherContextSnapshot) -> tuple[SourceDocument, ...]: """Convert a weather snapshot into citable LLM source documents.""" + labels = localized_labels(snapshot.output_language, _WEATHER_DOCUMENT_LABELS) weather = "\n".join(f"- {item}" for item in snapshot.weather_forecast) - lifestyle = "\n".join(f"- {item}" for item in snapshot.lifestyle_advice) or "不可用" - weather_summary = snapshot.weather_forecast[0] if snapshot.weather_forecast else "不可用" - history_value = f"今明天气预报:\n{weather}\n生活与出行指数:\n{lifestyle}" + lifestyle = "\n".join(f"- {item}" for item in snapshot.lifestyle_advice) or labels["unavailable"] + weather_summary = snapshot.weather_forecast[0] if snapshot.weather_forecast else labels["unavailable"] + separator = labels["separator"] + section_separator = labels["section_separator"] + history_value = ( + f"{labels['forecast']}{section_separator}\n{weather}\n{labels['lifestyle']}{section_separator}\n{lifestyle}" + ) documents = [ SourceDocument( id=snapshot.source_id, @@ -758,14 +876,15 @@ def snapshot_to_documents(snapshot: WeatherContextSnapshot) -> tuple[SourceDocum url=snapshot.source_url, has_allergen_information=snapshot.allergen_advice_available, content=( - f"更新时间:{snapshot.observed_at.to_iso8601_string()}\n" - f"今明天气预报:\n{weather}\n" - f"生活与出行指数:\n{lifestyle}" + f"{labels['updated_at']}{separator}{snapshot.observed_at.to_iso8601_string()}\n" + f"{labels['forecast']}{section_separator}\n{weather}\n" + f"{labels['lifestyle']}{section_separator}\n{lifestyle}" ), + language=snapshot.output_language, history_summary=( - f"更新时间:{snapshot.observed_at.to_iso8601_string()}\n" - f"天气概览:{weather_summary}\n" - f"生活与出行指数项数:{len(snapshot.lifestyle_advice)}" + f"{labels['updated_at']}{separator}{snapshot.observed_at.to_iso8601_string()}\n" + f"{labels['summary']}{separator}{weather_summary}\n" + f"{labels['lifestyle_count']}{separator}{len(snapshot.lifestyle_advice)}" ), history_value=history_value, ) @@ -789,7 +908,9 @@ def _qweather_air_quality_snapshot( source_url: str, effective_at: pendulum.DateTime | None, time_kind: AirQualityTimeKind, + output_language: str, ) -> AirQualitySnapshot: + labels = localized_labels(output_language, _QWEATHER_FORMATS) index = _first_mapping(payload, "indexes") pm25 = _mapping_by_code(payload, "pollutants", "pm2p5") concentration: dict[str, Any] = pm25.get("concentration", {}) @@ -808,8 +929,9 @@ def _qweather_air_quality_snapshot( pm25_aqi=_sub_index(pm25, str(index["code"])), pm25_concentration=float(concentration["value"]), pm25_unit=str(concentration["unit"]), - category=str(index.get("category", "未知")), + category=str(index.get("category", labels["unknown"])), health_guidance=str(advice.get("generalPopulation") or health.get("effect", "")), + output_language=output_language, ) @@ -839,11 +961,14 @@ def _aqi_standard(index: dict[str, object]) -> str: return name if name == code else f"{name}({code})" -def _format_qweather_lifestyle(item: dict[str, object]) -> str: +def _format_qweather_lifestyle(item: dict[str, object], language: str) -> str: + labels = localized_labels(language, _QWEATHER_FORMATS) name = str(item["name"]) - category = str(item.get("category", "未知")) - text = str(item.get("text") or "无详细建议") - return f"{name}({category}):{text}" + return labels["lifestyle"].format( + name=name, + category=str(item.get("category", labels["unknown"])), + text=str(item.get("text") or labels["no_details"]), + ) def _is_string_keyed_dict(value: object) -> TypeGuard[dict[str, object]]: @@ -854,7 +979,7 @@ def _is_object_list(value: object) -> TypeGuard[list[object]]: return isinstance(value, list) -def _format_qweather_day(item: object) -> str: +def _format_qweather_day(item: object, language: str) -> str: if not _is_string_keyed_dict(item): raise TypeError("daily forecast entries must be objects") required_fields = ( @@ -870,11 +995,16 @@ def _format_qweather_day(item: object) -> str: ) if missing_field := next((field for field in required_fields if field not in item), None): raise _QWeatherResponseError(f"daily forecast missing required field: {missing_field}") - return ( - f"{item['fxDate']}:{item['textDay']}转{item['textNight']}," - f"{item['tempMin']}~{item['tempMax']}℃," - f"{item['windDirDay']}{item['windScaleDay']}级," - f"相对湿度{item['humidity']}%,预计降水量{item['precip']}毫米" + return localized_labels(language, _QWEATHER_FORMATS)["day"].format( + date=item["fxDate"], + day=item["textDay"], + night=item["textNight"], + minimum=item["tempMin"], + maximum=item["tempMax"], + wind=item["windDirDay"], + scale=item["windScaleDay"], + humidity=item["humidity"], + precipitation=item["precip"], )