Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 展示名称。

## 状态

Expand Down Expand Up @@ -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 修复重试,禁止模型编造链接。

同一时段的天气现象在来源间冲突时,模型不得把片段拼成无争议的单一结论。契约要求明确呈现差异,并在能够识别当地权威气象机构时优先采用其最新信息,同时保留冲突来源引用;核心编排不硬编码地区机构名称或供应商优先级。

## 平台模板与投递

Expand Down
1 change: 1 addition & 0 deletions tests/test_allergen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions tests/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand All @@ -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": [
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions tests/test_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions tests/test_reference_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from weather_briefing.reference_data import (
ReferenceDataError,
load_reference_data,
reference_string,
reference_string_tuple,
reference_value,
)
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
63 changes: 53 additions & 10 deletions tests/test_render.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -18,30 +27,56 @@ def test_render_briefing_uses_safe_telegram_html() -> None:
result = BriefingResult(
"Daily <Forecast>",
"Warm & humid",
("source",),
("source",),
(Conclusion("Carry an umbrella", ("source",)),),
)

rendered = TelegramHTMLRenderer().render_briefing(result, (article,), ())

assert rendered.body.startswith("<b>Daily &lt;Forecast&gt;</b>")
assert rendered.body.startswith("<b>Daily &lt;Forecast&gt;</b> (来源:")
assert "Warm &amp; humid" in rendered.body
assert '<a href="https://example.invalid/?a=1&amp;b=2">Feed</a>' in rendered.body
assert rendered.visible_length == len("Daily <Forecast>\n\nWarm & humid\n\n天气信息\n\n• Carry an umbrella Feed")
assert rendered.visible_length == len(
"Daily <Forecast> (来源: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 "<b>" 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,), ())
Expand All @@ -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,))
Expand All @@ -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,))
Expand All @@ -97,6 +136,8 @@ def test_telegram_html_renders_active_warnings_section() -> None:
result = BriefingResult(
"Daily",
"Overview",
("source",),
("source",),
(),
active_warnings=(warning,),
)
Expand All @@ -115,6 +156,8 @@ def test_plain_text_renders_active_warnings_section() -> None:
result = BriefingResult(
"Daily",
"Overview",
("source",),
("source",),
(),
active_warnings=(warning,),
)
Expand Down
Loading