diff --git a/docs/design.md b/docs/design.md index 13fd7c3b..0aae0d27 100644 --- a/docs/design.md +++ b/docs/design.md @@ -54,7 +54,7 @@ service 将最终解析得到的完整地点名作为 `location_scope.full_name` QWeather 认证由独立的 `QWeatherJWTAuthenticator` 负责。它从运行环境读取 Base64 编码的 Ed25519 PKCS#8 私钥 PEM,解码后通过 EdDSA 签发 JWT:Header 只加入凭据 ID `kid`,Payload 只加入项目 ID `sub`、提前 30 秒的 `iat` 和可配置的短期 `exp`。provider 只依赖认证协议生成 Bearer header,不接触私钥字段,也不支持把长期 API KEY 混入同一请求。 -上下文快照被转换为带稳定 source ID 和验证 URL 的文档,与 RSS 输入使用同一套引用校验。08:00 的模型提示要求同时输出 AQI 标准,禁止换算或混用标准,并参考健康提示和生活指数生成运动、穿衣及口罩建议;PM2.5 直接按“PM2.5 数值 单位”表达,过敏指数和花粉只进入生活建议。天气与空气质量文档使用同一个 provider 展示名称。 +上下文快照被转换为带稳定 source ID 和验证 URL 的文档,与 RSS 输入使用同一套引用校验。08:00 的模型提示要求同时输出 AQI 标准,禁止换算或混用标准,并参考健康提示和生活指数生成运动、穿衣及口罩建议;PM2.5 直接按“PM2.5 数值 单位”表达,过敏指数和花粉只进入生活建议。建议使用结构化 topic,程序强制每日预报覆盖穿衣、除湿、运动和口罩;当前输入包含综合过敏指数或花粉过敏原时,同时强制覆盖过敏建议。QWeather adapter 根据供应商的过敏指数代码标记天气文档,Open-Meteo 花粉文档直接携带相同标记,核心编排不依赖供应商文案识别过敏数据。天气与空气质量文档使用同一个 provider 展示名称。 ## 状态 @@ -86,7 +86,9 @@ SQLite 没有原生日期时间类型,状态存储需要直接对 TEXT 做范 ## 来源引用 -输入文章和辅助 API 响应都带唯一 source ID、展示名称与 URL。模型输出 JSON 中每条结论包含 `source_ids`;程序验证所有 ID 都存在,再生成平台无关的 `BriefingResult`。各投递 provider 的 renderer 从相同结构化结果生成自己的链接与排版,并始终以 API provider 名称或 RSS 配置中的 `name` 作为链接文字;RSS 配置应使用公众号、微博账号或发布机构等公开名称。引用未知来源会使任务失败,禁止模型编造链接。 +输入文章和辅助 API 响应都带唯一 source ID、展示名称与 URL。模型输出 JSON 中标题、概述和每条结论都包含 `source_ids`;程序验证引用非空且所有 ID 都存在,再生成平台无关的 `BriefingResult`。各投递 provider 的 renderer 从相同结构化结果生成自己的链接与排版,在标题、概述和各分项后明确标注“来源”,并始终以 API provider 名称或 RSS 配置中的 `name` 作为链接文字;RSS 配置应使用公众号、微博账号或发布机构等公开名称。引用未知来源或遗漏标题、概述来源会使任务进入 LLM 修复重试,禁止模型编造链接。 + +同一时段的天气现象在来源间冲突时,模型不得把片段拼成无争议的单一结论。契约要求明确呈现差异,并在能够识别当地权威气象机构时优先采用其最新信息,同时保留冲突来源引用;核心编排不硬编码地区机构名称或供应商优先级。 ## 平台模板与投递 diff --git a/tests/test_allergen.py b/tests/test_allergen.py index 2a6002c2..bb4e89eb 100644 --- a/tests/test_allergen.py +++ b/tests/test_allergen.py @@ -68,6 +68,7 @@ def test_allergen_to_document_format() -> None: assert document.id == "allergen:test" assert document.name == "Test 花粉" + assert document.has_allergen_information assert "桦木:15 粒/m³(中)" in document.content assert "禾本:3 粒/m³(低)" in document.content assert "总体等级:中" in document.content diff --git a/tests/test_llm.py b/tests/test_llm.py index cb96487d..f2efe2f8 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -14,7 +14,9 @@ def test_rejects_model_invented_source() -> None: payload = { "headline": "Briefing", + "headline_source_ids": ["source"], "overview": "Overview", + "overview_source_ids": ["source"], "conclusions": [{"text": "Claim", "source_ids": ["invented"]}], "active_warnings": [], "resolved_warning_ids": [], @@ -32,7 +34,9 @@ def test_rejects_model_invented_source() -> None: def test_accepts_suppressed_message_with_unchanged_active_warning() -> None: payload = { "headline": "Briefing", + "headline_source_ids": ["source"], "overview": "Overview", + "overview_source_ids": ["source"], "conclusions": [], "active_warnings": [ { @@ -131,6 +135,51 @@ def test_rejects_conclusion_without_source_ids() -> None: ) +@pytest.mark.parametrize("source_ids", [None, "source", [None], [""]]) +def test_rejects_malformed_source_ids(source_ids) -> None: + payload = { + "headline": "Briefing", + "overview": "Overview", + "conclusions": [{"text": "Claim", "source_ids": source_ids}], + "active_warnings": [], + "resolved_warning_ids": [], + "advice": [], + "disaster_tracking": [], + } + + with pytest.raises(LLMError, match="source_ids must"): + parse_result( + payload, + pendulum.datetime(2026, 7, 13, 9, tz="Asia/Shanghai"), + {"source"}, + ) + + +@pytest.mark.parametrize("key", ["conclusions", "disaster_tracking", "advice"]) +@pytest.mark.parametrize("text", [None, "", " ", 42]) +def test_rejects_sourced_item_without_non_empty_text(key: str, text) -> None: + item = {"text": text, "source_ids": ["source"]} + if key == "advice": + item["topic"] = "clothing" + payload = { + "headline": "Briefing", + "overview": "Overview", + "conclusions": [], + "active_warnings": [], + "resolved_warning_ids": [], + "advice": [], + "disaster_tracking": [], + key: [item], + } + + with pytest.raises(LLMError, match=f"{key} entries must contain non-empty text"): + parse_result( + payload, + pendulum.datetime(2026, 7, 13, 9, tz="Asia/Shanghai"), + {"source"}, + ) + + def test_rejects_active_warnings_not_an_array() -> None: payload = { "headline": "Briefing", @@ -227,6 +276,75 @@ def test_rejects_non_boolean_should_publish() -> None: ) +@pytest.mark.parametrize("field", ("headline_source_ids", "overview_source_ids")) +def test_rejects_summary_without_source_ids(field: str) -> None: + payload = { + "headline": "Briefing", + "headline_source_ids": ["source"], + "overview": "Overview", + "overview_source_ids": ["source"], + "conclusions": [], + "active_warnings": [], + "resolved_warning_ids": [], + "advice": [], + "disaster_tracking": [], + } + del payload[field] + + with pytest.raises(LLMError, match=rf"{field} must cite"): + parse_result( + payload, + pendulum.datetime(2026, 7, 13, 9, tz="Asia/Shanghai"), + {"source"}, + ) + + +def test_rejects_advice_without_a_valid_topic() -> None: + payload = { + "headline": "Briefing", + "headline_source_ids": ["source"], + "overview": "Overview", + "overview_source_ids": ["source"], + "conclusions": [], + "active_warnings": [], + "resolved_warning_ids": [], + "advice": [{"text": "Advice", "source_ids": ["source"]}], + "disaster_tracking": [], + } + + with pytest.raises(LLMError, match="must use a valid topic"): + parse_result( + payload, + pendulum.datetime(2026, 7, 13, 9, tz="Asia/Shanghai"), + {"source"}, + ) + + +@pytest.mark.parametrize( + ("advice", "message"), + (("not-an-array", "advice must be an array"), (["not-an-object"], "advice entries must be objects")), +) +def test_rejects_invalid_advice_structure(advice: object, message: str) -> None: + payload = { + "headline": "Briefing", + "headline_source_ids": ["source"], + "overview": "Overview", + "overview_source_ids": ["source"], + "conclusions": [], + "active_warnings": [], + "resolved_warning_ids": [], + "advice": advice, + "disaster_tracking": [], + } + + with pytest.raises(LLMError, match=message): + parse_result( + payload, + pendulum.datetime(2026, 7, 13, 9, tz="Asia/Shanghai"), + {"source"}, + ) + + async def test_openai_provider_rejects_empty_json_content() -> None: async with httpx.AsyncClient( transport=httpx.MockTransport( diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 95182756..f53d478e 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -20,3 +20,10 @@ def test_prompt_separates_advice_and_avoids_repetition() -> None: assert "过敏原信息只能放入 advice" in SYSTEM_PROMPT assert "不得使用“原始浓度”" in SYSTEM_PROMPT assert "不得在 conclusions 中重复" in SYSTEM_PROMPT + + +def test_prompt_requires_attribution_and_preserves_source_conflicts() -> None: + assert "headline_source_ids、overview_source_ids" in SYSTEM_PROMPT + assert "不得拼接成无争议的单一结论" in SYSTEM_PROMPT + assert "优先采用可识别的当地权威气象机构" in SYSTEM_PROMPT + assert "input.required_advice_topics" in SYSTEM_PROMPT diff --git a/tests/test_reference_data.py b/tests/test_reference_data.py index 2c675593..bcbfb488 100644 --- a/tests/test_reference_data.py +++ b/tests/test_reference_data.py @@ -4,6 +4,7 @@ from weather_briefing.reference_data import ( ReferenceDataError, load_reference_data, + reference_string, reference_string_tuple, reference_value, ) @@ -17,6 +18,7 @@ def test_packaged_reference_data_is_available() -> None: ) assert reference_string_tuple("content_cleaning.json", "default_remove_selectors") assert reference_string_tuple("provider_defaults.json", "qweather_lifestyle_index_types") + assert reference_string("provider_defaults.json", "qweather_allergen_index_type") == "7" def test_air_quality_guidance_covers_values_above_last_bounded_band() -> None: @@ -51,6 +53,14 @@ def test_reference_string_tuple_rejects_non_list_value() -> None: reference_string_tuple("geography.json", "mainland_china_service_bounds") +@pytest.mark.parametrize("value", [None, "", " ", 7]) +def test_reference_string_rejects_invalid_value(monkeypatch, value) -> None: + monkeypatch.setattr("weather_briefing.reference_data.reference_value", lambda *args: value) + + with pytest.raises(ReferenceDataError, match="non-empty string"): + reference_string("provider_defaults.json", "qweather_allergen_index_type") + + def test_load_reference_data_rejects_non_dict_root(monkeypatch) -> None: from weather_briefing.reference_data import load_reference_data diff --git a/tests/test_render.py b/tests/test_render.py index 416f447e..1f29888c 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,6 +1,15 @@ import pendulum - -from weather_briefing.models import Article, BriefingResult, Conclusion, SourceDocument, Warning +import pytest + +from weather_briefing.models import ( + Advice, + AdviceTopic, + Article, + BriefingResult, + Conclusion, + SourceDocument, + Warning, +) from weather_briefing.render import PlainTextRenderer, TelegramHTMLRenderer @@ -18,30 +27,56 @@ def test_render_briefing_uses_safe_telegram_html() -> None: result = BriefingResult( "Daily ", "Warm & humid", + ("source",), + ("source",), (Conclusion("Carry an umbrella", ("source",)),), ) rendered = TelegramHTMLRenderer().render_briefing(result, (article,), ()) - assert rendered.body.startswith("Daily <Forecast>") + assert rendered.body.startswith("Daily <Forecast> (来源:") assert "Warm & humid" in rendered.body assert 'Feed' in rendered.body - assert rendered.visible_length == len("Daily \n\nWarm & humid\n\n天气信息\n\n• Carry an umbrella Feed") + assert rendered.visible_length == len( + "Daily (来源:Feed)\n\n" + "Warm & humid (来源:Feed)\n\n" + "天气信息\n\n• Carry an umbrella (来源:Feed)" + ) def test_plain_text_renderer_uses_the_same_structured_briefing() -> None: - result = BriefingResult("Daily", "Overview", ()) + context = SourceDocument("source", "Source", "https://example.invalid/source", "") + result = BriefingResult("Daily", "Overview", ("source",), ("source",), ()) - rendered = PlainTextRenderer().render_briefing(result, (), ()) + rendered = PlainTextRenderer().render_briefing(result, (), (context,)) - assert rendered.body == "Daily\n\nOverview" + assert rendered.body == ( + "Daily (来源:Source: https://example.invalid/source)\n\n" + "Overview (来源:Source: https://example.invalid/source)" + ) assert "" not in rendered.body +@pytest.mark.parametrize("renderer", (TelegramHTMLRenderer(), PlainTextRenderer())) +def test_renderers_fail_when_a_source_reference_is_missing( + renderer: TelegramHTMLRenderer | PlainTextRenderer, +) -> None: + result = BriefingResult("Daily", "Overview", ("missing",), ("missing",), ()) + + with pytest.raises(KeyError, match="missing"): + renderer.render_briefing(result, (), ()) + + def test_renderers_fall_back_to_source_id_for_legacy_blank_name() -> None: now = pendulum.datetime(2026, 7, 11, 8, tz="Asia/Shanghai") article = Article("source", "feed", " ", "Title", "https://example.invalid/a", now, "Body") - result = BriefingResult("Daily", "Overview", (Conclusion("Update", ("source",)),)) + result = BriefingResult( + "Daily", + "Overview", + ("source",), + ("source",), + (Conclusion("Update", ("source",)),), + ) html = TelegramHTMLRenderer().render_briefing(result, (article,), ()) plain = PlainTextRenderer().render_briefing(result, (article,), ()) @@ -60,8 +95,10 @@ def test_telegram_html_renderer_uses_context_source_name_as_attribution() -> Non result = BriefingResult( "Daily", "Overview", + ("allergen:open-meteo",), + ("allergen:open-meteo",), (), - advice=(Conclusion("花粉浓度较高", ("allergen:open-meteo",)),), + advice=(Advice(AdviceTopic.ALLERGEN, "花粉浓度较高", ("allergen:open-meteo",)),), ) rendered = TelegramHTMLRenderer().render_briefing(result, (), (context,)) @@ -81,8 +118,10 @@ def test_plain_text_renderer_uses_context_source_name_as_attribution() -> None: result = BriefingResult( "Daily", "Overview", + ("allergen:open-meteo",), + ("allergen:open-meteo",), (), - advice=(Conclusion("花粉浓度较高", ("allergen:open-meteo",)),), + advice=(Advice(AdviceTopic.ALLERGEN, "花粉浓度较高", ("allergen:open-meteo",)),), ) rendered = PlainTextRenderer().render_briefing(result, (), (context,)) @@ -97,6 +136,8 @@ def test_telegram_html_renders_active_warnings_section() -> None: result = BriefingResult( "Daily", "Overview", + ("source",), + ("source",), (), active_warnings=(warning,), ) @@ -115,6 +156,8 @@ def test_plain_text_renders_active_warnings_section() -> None: result = BriefingResult( "Daily", "Overview", + ("source",), + ("source",), (), active_warnings=(warning,), ) diff --git a/tests/test_service.py b/tests/test_service.py index 5fcf78cb..d26862c8 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -87,8 +87,9 @@ async def fetch(self, config: ContextSourceConfig) -> SourceDocument: class StaticWeatherContextProvider: - def __init__(self) -> None: + def __init__(self, *, allergen_advice_available: bool = False) -> None: self.coordinates: tuple[float, float] | None = None + self._allergen_advice_available = allergen_advice_available async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapshot: self.coordinates = (latitude, longitude) @@ -98,6 +99,7 @@ async def fetch(self, latitude: float, longitude: float) -> WeatherContextSnapsh source_url="https://example.invalid/weather", observed_at=pendulum.datetime(2026, 7, 13, 8, tz="UTC"), weather_forecast=("Rain later",), + allergen_advice_available=self._allergen_advice_available, air_quality=AirQualitySnapshot( source_id="air-quality:test", source_name="Test air quality", @@ -141,13 +143,20 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic "text": "AQI is 42 under test-standard.", "source_ids": [source_id], } + required_topics = payload["required_advice_topics"] + assert isinstance(required_topics, list) + advice = [{"topic": topic, "text": conclusion["text"], "source_ids": [source_id]} for topic in required_topics] + if self._include_briefing_advice and not advice: + advice = [{"topic": "clothing", **conclusion}] return { "headline": "Daily briefing", + "headline_source_ids": [source_id], "overview": "Air quality is good.", + "overview_source_ids": [source_id], "conclusions": [conclusion], "active_warnings": [], "resolved_warning_ids": [], - "advice": ([conclusion] if payload["mode"] == "forecast" or self._include_briefing_advice else []), + "advice": advice, "disaster_tracking": [], "should_publish": self._should_publish, } @@ -270,6 +279,12 @@ async def test_forecast_uses_configured_coordinates_and_air_quality_context( assert "PM2.5 12 µg/m³" in content assert "原始浓度" not in content assert llm.payload["location_scope"] == expected_scope + assert llm.payload["required_advice_topics"] == [ + "clothing", + "dehumidification", + "exercise", + "mask", + ] recent_briefings = llm.payload["recent_briefings"] assert _is_dict_list(recent_briefings) assert recent_briefings == [ @@ -283,6 +298,79 @@ async def test_forecast_uses_configured_coordinates_and_air_quality_context( assert publisher.messages == [(RenderedMessage(body, len(body)), True, False)] +async def test_forecast_rejects_missing_allergen_advice_when_input_contains_it(tmp_path: Path) -> None: + class MissingAllergenAdviceLLM(RecordingLLM): + async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]: + result = await super().summarize(system_prompt, payload) + advice = result["advice"] + assert _is_dict_list(advice) + result["advice"] = [item for item in advice if item["topic"] != "allergen"] + return result + + settings = _TestSettings( + timezone=pendulum.timezone("Asia/Shanghai"), + feeds=(), + context_sources=(), + llm_max_attempts=1, + ) + delivery = DeliveryProvider(PlainTextRenderer(), RecordingPublisher()) + now = pendulum.datetime(2026, 7, 13, 8, tz=settings.timezone) + + with SQLiteStateStore(tmp_path / "missing-allergen.sqlite3") as state: + service = BriefingService( + settings, + _location(), + state, + EmptyRSSSource(), + EmptyContextSource(), + MissingAllergenAdviceLLM(), + delivery, + delivery, + StaticWeatherContextProvider(allergen_advice_available=True), + ) + with pytest.raises(LLMError, match="validation failed") as error: + await service.run("forecast", now) + + assert "missing required topics: allergen" in str(error.value.__cause__) + + +async def test_forecast_rejects_allergen_advice_without_allergen_source(tmp_path: Path) -> None: + class WrongAllergenSourceLLM(RecordingLLM): + async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]: + result = await super().summarize(system_prompt, payload) + advice = result["advice"] + assert _is_dict_list(advice) + allergen_advice = next(item for item in advice if item["topic"] == "allergen") + allergen_advice["source_ids"] = ["air-quality:test"] + return result + + settings = _TestSettings( + timezone=pendulum.timezone("Asia/Shanghai"), + feeds=(), + context_sources=(), + llm_max_attempts=1, + ) + delivery = DeliveryProvider(PlainTextRenderer(), RecordingPublisher()) + now = pendulum.datetime(2026, 7, 13, 8, tz=settings.timezone) + + with SQLiteStateStore(tmp_path / "wrong-allergen-source.sqlite3") as state: + service = BriefingService( + settings, + _location(), + state, + EmptyRSSSource(), + EmptyContextSource(), + WrongAllergenSourceLLM(), + delivery, + delivery, + StaticWeatherContextProvider(allergen_advice_available=True), + ) + with pytest.raises(LLMError, match="validation failed") as error: + await service.run("forecast", now) + + assert "must cite a current allergen-capable source" in str(error.value.__cause__) + + async def test_forecast_date_is_separate_from_run_time_and_reaches_weather_provider(tmp_path: Path) -> None: timezone = pendulum.timezone("Asia/Shanghai") run_time = pendulum.datetime(2026, 7, 13, 22, 30, tz=timezone) @@ -481,7 +569,9 @@ class UnchangedWarningLLM: async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]: return { "headline": "Warning unchanged", + "headline_source_ids": list(warning.source_ids), "overview": "No material change.", + "overview_source_ids": list(warning.source_ids), "conclusions": [], "active_warnings": [ { @@ -560,7 +650,9 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic source_id = str(sources[0]["source_id"]) return { "headline": "Accumulated update", + "headline_source_ids": [source_id], "overview": "Changes are now worth sending.", + "overview_source_ids": [source_id], "conclusions": [{"text": "Accumulated change", "source_ids": [source_id]}], "active_warnings": [], "resolved_warning_ids": [], @@ -992,16 +1084,23 @@ async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dic if self.attempts == 1: return { "headline": "Briefing", + "headline_source_ids": ["invented"], "overview": "Overview", + "overview_source_ids": ["invented"], "conclusions": [{"text": "Claim", "source_ids": ["invented"]}], "active_warnings": [], "resolved_warning_ids": [], "advice": [], "disaster_tracking": [], } + allowed_source_ids = payload["allowed_source_ids"] + assert isinstance(allowed_source_ids, list) + source_id = str(allowed_source_ids[0]) return { "headline": "Briefing", + "headline_source_ids": [source_id], "overview": "Overview", + "overview_source_ids": [source_id], "conclusions": [], "active_warnings": [], "resolved_warning_ids": [], @@ -1071,9 +1170,14 @@ async def test_briefing_exceeding_character_limit_is_rejected(tmp_path: Path) -> class LongLLM: async def summarize(self, system_prompt: str, payload: dict[str, object]) -> dict[str, object]: + context_documents = payload["context_documents"] + assert _is_dict_list(context_documents) + source_id = str(context_documents[0]["source_id"]) return { "headline": "A" * 100, + "headline_source_ids": [source_id], "overview": "B" * 100, + "overview_source_ids": [source_id], "conclusions": [], "active_warnings": [], "resolved_warning_ids": [], diff --git a/tests/test_weather_context.py b/tests/test_weather_context.py index 457931e6..af6391c3 100644 --- a/tests/test_weather_context.py +++ b/tests/test_weather_context.py @@ -137,10 +137,17 @@ def handler(request: httpx.Request) -> httpx.Response: "code": "200", "daily": [ { + "type": "1", "name": "运动指数", "category": "适宜", "text": "适宜进行户外运动。", - } + }, + { + "type": "7", + "name": "过敏指数", + "category": "不易发", + "text": "天气条件不易诱发过敏。", + }, ], }, ) @@ -178,7 +185,11 @@ def handler(request: httpx.Request) -> httpx.Response: assert snapshot.source_id == "weather:qweather" assert snapshot.observed_at.to_iso8601_string() == "2026-07-13T08:00:00+08:00" assert len(snapshot.weather_forecast) == 2 - assert snapshot.lifestyle_advice == ("运动指数(适宜):适宜进行户外运动。",) + assert snapshot.lifestyle_advice == ( + "运动指数(适宜):适宜进行户外运动。", + "过敏指数(不易发):天气条件不易诱发过敏。", + ) + assert snapshot.allergen_advice_available assert snapshot.air_quality is not None assert snapshot.air_quality.source_name == "QWeather" assert snapshot.air_quality.aqi_standard == "中国环境空气质量指数(cn-mee)" @@ -188,6 +199,7 @@ def handler(request: httpx.Request) -> httpx.Response: "weather:qweather", "air-quality:qweather", ] + assert documents[0].has_allergen_information async def test_qweather_provider_selects_requested_future_date() -> None: diff --git a/weather_briefing/allergen.py b/weather_briefing/allergen.py index 835b18ac..fe920077 100644 --- a/weather_briefing/allergen.py +++ b/weather_briefing/allergen.py @@ -27,6 +27,7 @@ def allergen_to_document(snapshot: AllergenSnapshot) -> SourceDocument: id=snapshot.source_id, name=snapshot.source_name, url=snapshot.source_url, + has_allergen_information=True, content=( f"观测时间:{observed_at}\n" f"花粉过敏原:\n{levels}\n" diff --git a/weather_briefing/data/provider_defaults.json b/weather_briefing/data/provider_defaults.json index ba486680..a680021b 100644 --- a/weather_briefing/data/provider_defaults.json +++ b/weather_briefing/data/provider_defaults.json @@ -15,5 +15,6 @@ "7", "8", "15" - ] + ], + "qweather_allergen_index_type": "7" } diff --git a/weather_briefing/llm.py b/weather_briefing/llm.py index 343629f7..3c24e698 100644 --- a/weather_briefing/llm.py +++ b/weather_briefing/llm.py @@ -8,7 +8,7 @@ import pendulum from .api_client import api_call_extensions -from .models import BriefingResult, Conclusion, Warning +from .models import Advice, AdviceTopic, BriefingResult, Conclusion, Warning from .time_utils import require_aware_datetime @@ -100,7 +100,27 @@ def parse_result( ) -> BriefingResult: require_aware_datetime(now, context="Briefing result time") - def conclusions(key: str) -> tuple[Conclusion, ...]: + def cited_source_ids(value: Mapping[str, Any], field: str) -> tuple[str, ...]: + raw_source_ids = value.get(field, []) + if not isinstance(raw_source_ids, list): + raise LLMError(f"{field} must be an array") + if not all(isinstance(item, str) and item.strip() for item in raw_source_ids): + raise LLMError(f"{field} must contain non-empty strings") + parsed = tuple(raw_source_ids) + if not parsed: + raise LLMError(f"{field} must cite at least one source ID") + unknown = set(parsed) - valid_source_ids + if unknown: + raise LLMError(f"Model cited unknown source IDs: {sorted(unknown)}") + return parsed + + def sourced_text(value: Mapping[str, Any], key: str) -> str: + text = value.get("text") + if not isinstance(text, str) or not text.strip(): + raise LLMError(f"{key} entries must contain non-empty text") + return text + + def parse_sourced_text_items(key: str) -> tuple[Conclusion, ...]: values = payload.get(key, []) if not isinstance(values, list): raise LLMError(f"{key} must be an array") @@ -108,13 +128,34 @@ def conclusions(key: str) -> tuple[Conclusion, ...]: for value in values: if not isinstance(value, dict): raise LLMError(f"{key} entries must be objects") - source_ids = tuple(str(item) for item in value.get("source_ids", [])) - if not source_ids: - raise LLMError(f"{key} entries must cite at least one source ID") - unknown = set(source_ids) - valid_source_ids - if unknown: - raise LLMError(f"Model cited unknown source IDs: {sorted(unknown)}") - parsed.append(Conclusion(text=str(value["text"]), source_ids=source_ids)) + parsed.append( + Conclusion( + text=sourced_text(value, key), + source_ids=cited_source_ids(value, "source_ids"), + ) + ) + return tuple(parsed) + + def advice() -> tuple[Advice, ...]: + values = payload.get("advice", []) + if not isinstance(values, list): + raise LLMError("advice must be an array") + parsed: list[Advice] = [] + for value in values: + if not isinstance(value, dict): + raise LLMError("advice entries must be objects") + try: + topic = AdviceTopic(str(value["topic"])) + except (KeyError, ValueError): + allowed = ", ".join(item.value for item in AdviceTopic) + raise LLMError(f"advice entries must use a valid topic: {allowed}") from None + parsed.append( + Advice( + topic=topic, + text=sourced_text(value, "advice"), + source_ids=cited_source_ids(value, "source_ids"), + ) + ) return tuple(parsed) warning_values = payload.get("active_warnings", []) @@ -142,14 +183,19 @@ def conclusions(key: str) -> tuple[Conclusion, ...]: should_publish = payload.get("should_publish", True) if not isinstance(should_publish, bool): raise LLMError("should_publish must be a boolean") + parsed_conclusions = parse_sourced_text_items("conclusions") + parsed_advice = advice() + parsed_disaster_tracking = parse_sourced_text_items("disaster_tracking") return BriefingResult( headline=str(payload["headline"]), overview=str(payload["overview"]), - conclusions=conclusions("conclusions"), + headline_source_ids=cited_source_ids(payload, "headline_source_ids"), + overview_source_ids=cited_source_ids(payload, "overview_source_ids"), + conclusions=parsed_conclusions, active_warnings=tuple(warnings), resolved_warning_ids=tuple(str(item) for item in payload.get("resolved_warning_ids", [])), - advice=conclusions("advice"), - disaster_tracking=conclusions("disaster_tracking"), + advice=parsed_advice, + disaster_tracking=parsed_disaster_tracking, should_publish=should_publish, raw_payload=dict(payload), ) diff --git a/weather_briefing/models.py b/weather_briefing/models.py index 1c7eee2e..affced5e 100644 --- a/weather_briefing/models.py +++ b/weather_briefing/models.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import StrEnum import pendulum @@ -70,6 +71,7 @@ class SourceDocument: name: str url: str content: str + has_allergen_information: bool = False @dataclass(frozen=True, slots=True) @@ -123,6 +125,7 @@ class WeatherContextSnapshot: lifestyle_advice: tuple[str, ...] = () air_quality: AirQualitySnapshot | None = None allergen: AllergenSnapshot | None = None + allergen_advice_available: bool = False @dataclass(frozen=True, slots=True) @@ -141,14 +144,31 @@ class Conclusion: source_ids: tuple[str, ...] +class AdviceTopic(StrEnum): + CLOTHING = "clothing" + DEHUMIDIFICATION = "dehumidification" + EXERCISE = "exercise" + MASK = "mask" + ALLERGEN = "allergen" + + +@dataclass(frozen=True, slots=True) +class Advice: + topic: AdviceTopic + text: str + source_ids: tuple[str, ...] + + @dataclass(frozen=True, slots=True) class BriefingResult: headline: str overview: str + headline_source_ids: tuple[str, ...] + overview_source_ids: tuple[str, ...] conclusions: tuple[Conclusion, ...] active_warnings: tuple[Warning, ...] = () resolved_warning_ids: tuple[str, ...] = () - advice: tuple[Conclusion, ...] = () + advice: tuple[Advice, ...] = () disaster_tracking: tuple[Conclusion, ...] = () should_publish: bool = True raw_payload: dict[str, object] = field(default_factory=dict, compare=False) diff --git a/weather_briefing/prompts.py b/weather_briefing/prompts.py index d0573650..a3dbed9b 100644 --- a/weather_briefing/prompts.py +++ b/weather_briefing/prompts.py @@ -1,16 +1,21 @@ SYSTEM_PROMPT = """你是谨慎的天气信息编辑。只能根据输入资料形成结论,不得编造事实或链接。 输出单个 JSON 对象,字段为: - headline: string +- headline_source_ids: [string] - overview: string +- overview_source_ids: [string] - conclusions: [{text, source_ids}] - active_warnings: [{id, title, status, detail, source_ids}] - resolved_warning_ids: [string] - disaster_tracking: [{text, source_ids}] -- advice: [{text, source_ids}] +- advice: [{topic, text, source_ids}] - should_publish: boolean source_ids 只能使用输入中出现的 source ID,每条事实性结论至少引用一个来源。 -conclusions、active_warnings、disaster_tracking 和 advice 中的每一项都必须包含至少一个 source_id。 +headline_source_ids、overview_source_ids 以及 conclusions、active_warnings、disaster_tracking 和 advice +中的每一项都必须包含至少一个 source_id。 +不同来源对同一时段的天气现象有冲突时,不得拼接成无争议的单一结论;应明确说明差异, +并优先采用可识别的当地权威气象机构最新信息,同时引用存在冲突的来源。 当前预警必须单独表达。历史有效预警在没有明确解除或降级证据时应继续保留。 只有资料明确说明解除时,才把其 id 放入 resolved_warning_ids。 灾害动态只保留明确影响 input.location_scope 的台风、海啸、地震等自然灾害,并说明当前位置和预计方向。 @@ -19,7 +24,8 @@ 判断地域相关性时,影响完整地点名或覆盖该地点的上级行政区才算相关;只影响同级或下级其他地区不相关。 例如地点为北京市西城区中南海时,影响中南海、西城区或北京市应保留,只影响海淀区则排除。 仅仅提及灾害、灾害位于远处、已经移出或资料明确说明无影响时,disaster_tracking 必须为空,且不得据此发布。 -forecast 模式需要给出穿衣、除湿、运动、口罩建议;briefing 模式的 advice 必须为空数组。 +forecast 模式的 advice 必须覆盖 input.required_advice_topics 中的每个 topic,且 topic 只能取 +clothing、dehumidification、exercise、mask、allergen;briefing 模式的 advice 必须为空数组。 forecast 模式存在空气质量资料时,必须简要报告 AQI 数值及其标准、PM2.5 数值和单位(如有); 表述为“PM2.5 xx μg/m³”,不得使用“原始浓度”。 forecast 模式只总结 input.forecast_date 指定日期的天气;其他日期仅可作为变化趋势上下文。 diff --git a/weather_briefing/reference_data.py b/weather_briefing/reference_data.py index 4a5871ec..3726629b 100644 --- a/weather_briefing/reference_data.py +++ b/weather_briefing/reference_data.py @@ -38,6 +38,14 @@ def reference_value(filename: str, *path: str) -> Any: return value +def reference_string(filename: str, *path: str) -> str: + value = reference_value(filename, *path) + if not isinstance(value, str) or not value.strip(): + joined_path = ".".join(path) + raise ReferenceDataError(f"Reference data field must be a non-empty string: {filename}:{joined_path}") + return value + + def reference_string_tuple(filename: str, *path: str) -> tuple[str, ...]: value = reference_value(filename, *path) if not isinstance(value, list) or not value or not all(isinstance(item, str) and item.strip() for item in value): diff --git a/weather_briefing/render.py b/weather_briefing/render.py index 4b8675e6..32758961 100644 --- a/weather_briefing/render.py +++ b/weather_briefing/render.py @@ -6,6 +6,7 @@ from bs4 import BeautifulSoup from .models import ( + Advice, Article, BriefingResult, Conclusion, @@ -38,14 +39,18 @@ def render_briefing( article.id: _html_link(article.url, _article_source_name(article)) for article in reference_articles } source_links.update({document.id: _html_link(document.url, document.name) for document in context}) - lines = [f"{_html_text(result.headline)}", "", _html_text(result.overview), ""] + lines = [ + f"{_html_text(result.headline)} {_html_attribution(result.headline_source_ids, source_links)}", + "", + f"{_html_text(result.overview)} {_html_attribution(result.overview_source_ids, source_links)}", + "", + ] if result.active_warnings: lines.extend(["当前生效的气象预警", ""]) for warning in result.active_warnings: - links = " ".join(source_links[source_id] for source_id in warning.source_ids) lines.append( f"• {_html_text(warning.title)}({_html_text(warning.status)}):" - f"{_html_text(warning.detail)} {links}".rstrip() + f"{_html_text(warning.detail)} {_html_attribution(warning.source_ids, source_links)}" ) lines.append("") lines.extend(_html_items("天气信息", result.conclusions, source_links)) @@ -79,12 +84,17 @@ def render_briefing( article.id: f"{_article_source_name(article)}: {article.url}" for article in reference_articles } source_references.update({document.id: f"{document.name}: {document.url}" for document in context}) - lines = [result.headline, "", result.overview, ""] + lines = [ + f"{result.headline} {_plain_attribution(result.headline_source_ids, source_references)}", + "", + f"{result.overview} {_plain_attribution(result.overview_source_ids, source_references)}", + "", + ] if result.active_warnings: lines.extend(["当前生效的气象预警", ""]) for warning in result.active_warnings: - sources = " ".join(source_references[item] for item in warning.source_ids) - lines.append(f"- {warning.title}({warning.status}):{warning.detail} {sources}".rstrip()) + sources = _plain_attribution(warning.source_ids, source_references) + lines.append(f"- {warning.title}({warning.status}):{warning.detail} {sources}") lines.append("") lines.extend(_plain_items("天气信息", result.conclusions, source_references)) lines.extend(_plain_items("灾害动态", result.disaster_tracking, source_references)) @@ -110,28 +120,44 @@ def _html_link(url: str, label: str) -> str: return f'{_html_text(label)}' -def _html_items(title: str, items: tuple[Conclusion, ...], source_links: dict[str, str]) -> list[str]: +def _html_items( + title: str, + items: tuple[Conclusion | Advice, ...], + source_links: dict[str, str], +) -> list[str]: if not items: return [] lines = [f"{_html_text(title)}", ""] for item in items: - links = " ".join(source_links[source_id] for source_id in item.source_ids) - lines.append(f"• {_html_text(item.text)} {links}".rstrip()) + lines.append(f"• {_html_text(item.text)} {_html_attribution(item.source_ids, source_links)}") lines.append("") return lines -def _plain_items(title: str, items: tuple[Conclusion, ...], source_references: dict[str, str]) -> list[str]: +def _plain_items( + title: str, + items: tuple[Conclusion | Advice, ...], + source_references: dict[str, str], +) -> list[str]: if not items: return [] lines = [title, ""] for item in items: - sources = " ".join(source_references[source_id] for source_id in item.source_ids) - lines.append(f"- {item.text} {sources}".rstrip()) + lines.append(f"- {item.text} {_plain_attribution(item.source_ids, source_references)}") lines.append("") return lines +def _html_attribution(source_ids: tuple[str, ...], source_links: dict[str, str]) -> str: + sources = "、".join(source_links[source_id] for source_id in source_ids) + return f"(来源:{sources})" + + +def _plain_attribution(source_ids: tuple[str, ...], source_references: dict[str, str]) -> str: + sources = ";".join(source_references[source_id] for source_id in source_ids) + return f"(来源:{sources})" + + def _html_message(body: str) -> RenderedMessage: visible = BeautifulSoup(body, "html.parser").get_text() return RenderedMessage(body=body, visible_length=len(visible)) diff --git a/weather_briefing/service.py b/weather_briefing/service.py index 7870a534..78d73ad2 100644 --- a/weather_briefing/service.py +++ b/weather_briefing/service.py @@ -8,7 +8,16 @@ import pendulum from .llm import LLMError, LLMProvider, parse_result -from .models import Article, BriefingResult, ContextSourceConfig, FeedConfig, ResolvedLocation, SourceDocument, Warning +from .models import ( + AdviceTopic, + Article, + BriefingResult, + ContextSourceConfig, + FeedConfig, + ResolvedLocation, + SourceDocument, + Warning, +) from .prompts import SYSTEM_PROMPT from .publishers import DeliveryProvider from .sources import ContextDocumentSource, RSSFeedSource @@ -237,20 +246,32 @@ async def _run( ) briefing_limit = self._delivery.briefing_limit(self._settings.briefing_max_characters) payload["output_constraints"] = {"briefing_max_characters": briefing_limit} + required_advice_topics = _required_advice_topics(kind, context) + payload["required_advice_topics"] = [topic.value for topic in required_advice_topics] + allergen_source_ids = {document.id for document in context if document.has_allergen_information} valid_source_ids = {article.id for article in source_articles} | {document.id for document in reference_context} - def validate_length(candidate: BriefingResult) -> None: + def validate_result(candidate: BriefingResult) -> None: candidate_message = self._delivery.render_briefing(candidate, source_articles, reference_context) if kind == "briefing" and candidate.advice: raise LLMError("briefing must not repeat lifestyle advice") if kind == "forecast" and not candidate.should_publish: raise LLMError("forecast must set should_publish=true") + missing_advice_topics = set(required_advice_topics) - {item.topic for item in candidate.advice} + if missing_advice_topics: + missing = ", ".join(sorted(topic.value for topic in missing_advice_topics)) + raise LLMError(f"forecast advice is missing required topics: {missing}") + if any( + item.topic is AdviceTopic.ALLERGEN and allergen_source_ids.isdisjoint(item.source_ids) + for item in candidate.advice + ): + raise LLMError("allergen advice must cite a current allergen-capable source") if candidate_message.visible_length > briefing_limit: raise LLMError( f"briefing has {candidate_message.visible_length} visible characters; limit is {briefing_limit}" ) - result = await self._summarize(payload, now, valid_source_ids, validator=validate_length) + result = await self._summarize(payload, now, valid_source_ids, validator=validate_result) message = self._delivery.render_briefing( result, source_articles, @@ -486,6 +507,23 @@ def _unique_articles(articles: tuple[Article, ...]) -> tuple[Article, ...]: return tuple({article.id: article for article in articles}.values()) +def _required_advice_topics( + kind: str, + context: tuple[SourceDocument, ...], +) -> tuple[AdviceTopic, ...]: + if kind != "forecast": + return () + topics = [ + AdviceTopic.CLOTHING, + AdviceTopic.DEHUMIDIFICATION, + AdviceTopic.EXERCISE, + AdviceTopic.MASK, + ] + if any(document.has_allergen_information for document in context): + topics.append(AdviceTopic.ALLERGEN) + return tuple(topics) + + def _unique_documents( documents: tuple[SourceDocument, ...], ) -> tuple[SourceDocument, ...]: diff --git a/weather_briefing/weather_context.py b/weather_briefing/weather_context.py index fe55909c..1a18608f 100644 --- a/weather_briefing/weather_context.py +++ b/weather_briefing/weather_context.py @@ -17,7 +17,7 @@ from .allergen import allergen_guidance, allergen_to_document, pollen_type_names from .api_client import api_call_extensions from .models import AirQualitySnapshot, AllergenLevel, AllergenSnapshot, SourceDocument, WeatherContextSnapshot -from .reference_data import ReferenceDataError, reference_string_tuple +from .reference_data import ReferenceDataError, reference_string, reference_string_tuple from .time_utils import ( datetime_timezone_specifier, parse_datetime_with_default_timezone, @@ -151,6 +151,7 @@ def __init__( self._index_types = index_types or reference_string_tuple( "provider_defaults.json", "qweather_lifestyle_index_types" ) + self._allergen_index_type = reference_string("provider_defaults.json", "qweather_allergen_index_type") async def fetch( self, @@ -205,6 +206,7 @@ async def fetch( indices_payload: dict[str, object] = {} lifestyle_advice: tuple[str, ...] = () + allergen_advice_available = False if forecast_date is None or str(forecast_date) == first_forecast_date: operation = "lifestyle indices" indices_response = await self._client.get( @@ -224,7 +226,13 @@ async def fetch( "QWeather returned a non-success indices status " f"code={_safe_api_status(indices_payload.get('code'))}" ) - lifestyle_advice = tuple(_format_qweather_lifestyle(item) for item in indices_payload.get("daily", ())) + daily_indices = tuple(indices_payload.get("daily", ())) + lifestyle_advice = tuple(_format_qweather_lifestyle(item) for item in daily_indices) + allergen_advice_available = any( + str(item.get("type")) == self._allergen_index_type + for item in daily_indices + if isinstance(item, dict) + ) source_url = str( weather_payload.get("fxLink") or indices_payload.get("fxLink") or "https://www.qweather.com/" ) @@ -248,6 +256,7 @@ async def fetch( weather_forecast=weather_forecast, lifestyle_advice=lifestyle_advice, air_quality=air_quality, + allergen_advice_available=allergen_advice_available, ) async def fetch_for_date( @@ -627,6 +636,7 @@ def snapshot_to_documents(snapshot: WeatherContextSnapshot) -> tuple[SourceDocum id=snapshot.source_id, name=snapshot.source_name, url=snapshot.source_url, + has_allergen_information=snapshot.allergen_advice_available, content=( f"更新时间:{snapshot.observed_at.to_iso8601_string()}\n" f"今明天气预报:\n{weather}\n"