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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,4 @@ docker run -d --name weather-briefing --restart unless-stopped \
- CI 使用 Gitleaks 扫描凭据,并运行自定义隐私哨兵测试。
- Telegram token 与 chat ID 只从运行环境读取;测试可选择 stdout publisher。

地点解析使用 [Open-Meteo Geocoding](https://open-meteo.com/en/docs/geocoding-api);详细地名备用数据 © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright),通过 Nominatim 获取。公共 Nominatim 仅在本地缓存未命中时调用,并遵守其使用政策。
花粉数据由 [Open-Meteo](https://open-meteo.com/en/docs/air-quality-api) 基于 [CAMS ENSEMBLE](https://confluence.ecmwf.int/spaces/CKB/pages/202173092/CAMS+Regional+European+air+quality+analysis+and+forecast+data+documentation) 提供。地点解析使用 [Open-Meteo Geocoding](https://open-meteo.com/en/docs/geocoding-api);详细地名备用数据 © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright),通过 Nominatim 获取。公共 Nominatim 仅在本地缓存未命中时调用,并遵守其使用政策。
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ CLI 根据 `BRIEFING_CRON` 判断当天最后一个 briefing 小时,并在对

`AirQualitySupplementingWeatherProvider` 在最终天气快照缺少空气质量时调用可选 `AQICNProvider`。AQICN 只返回空气质量,不会被当成天气来源;它保留美国 EPA AQI 和 PM2.5 单项 AQI,不反向折算 PM2.5 浓度。天气来源与 AQICN 都无法提供空气质量时抛出可操作错误。

天气快照可选包含花粉过敏原信息。QWeather 生活指数请求过敏指数(类型 7),并将供应商返回的指数说明直接放入 `lifestyle_advice`。`OpenMeteoProvider` 在同一次空气质量 API 请求中额外请求花粉变量,按浓度分级(参考数据 `allergen_guidance.json`)转换为独立 `AllergenSnapshot`,供每日预报生活建议参考。Open-Meteo 花粉数据仅在欧洲花粉季可用,缺失或字段无效时不影响天气与空气质量结果。当前实现不把综合过敏指数解释为具体花粉种类,也不推断独立的杨絮、柳絮等飞絮信息。
天气快照可选包含花粉过敏原信息。QWeather 生活指数请求过敏指数(类型 7),并将供应商返回的指数说明直接放入 `lifestyle_advice`。`OpenMeteoProvider` 在同一次空气质量 API 请求中额外请求花粉变量,按浓度分级(参考数据 `allergen_guidance.json`)转换为独立 `AllergenSnapshot`,供每日预报生活建议参考。Open-Meteo 花粉数据仅在欧洲花粉季可用,来自 CAMS European Air Quality forecast 的 ENSEMBLE 数据;引用该上下文时,渲染层使用文档名称确定性展示 Open-Meteo 与 CAMS ENSEMBLE 署名。缺失或字段无效时不影响天气与空气质量结果。当前实现不把综合过敏指数解释为具体花粉种类,也不推断独立的杨絮、柳絮等飞絮信息。

QWeather 认证由独立的 `QWeatherJWTAuthenticator` 负责。它从运行环境读取 Base64 编码的 Ed25519 PKCS#8 私钥 PEM,解码后通过 EdDSA 签发 JWT:Header 只加入凭据 ID `kid`,Payload 只加入项目 ID `sub`、提前 30 秒的 `iat` 和可配置的短期 `exp`。provider 只依赖认证协议生成 Bearer header,不接触私钥字段,也不支持把长期 API KEY 混入同一请求。

Expand Down
42 changes: 41 additions & 1 deletion tests/test_render.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pendulum

from weather_briefing.models import Article, BriefingResult, Conclusion, Warning
from weather_briefing.models import Article, BriefingResult, Conclusion, SourceDocument, Warning
from weather_briefing.render import PlainTextRenderer, TelegramHTMLRenderer


Expand Down Expand Up @@ -38,6 +38,46 @@ def test_plain_text_renderer_uses_the_same_structured_briefing() -> None:
assert "<b>" not in rendered.body


def test_telegram_html_renderer_uses_context_source_name_as_attribution() -> None:
context = SourceDocument(
"allergen:open-meteo",
"Open-Meteo / CAMS ENSEMBLE 花粉过敏原",
"https://open-meteo.com/en/docs/air-quality-api",
"Pollen data",
)
result = BriefingResult(
"Daily",
"Overview",
(),
advice=(Conclusion("花粉浓度较高", ("allergen:open-meteo",)),),
)

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

assert (
'<a href="https://open-meteo.com/en/docs/air-quality-api">Open-Meteo / CAMS ENSEMBLE 花粉过敏原</a>'
) in rendered.body


def test_plain_text_renderer_uses_context_source_name_as_attribution() -> None:
context = SourceDocument(
"allergen:open-meteo",
"Open-Meteo / CAMS ENSEMBLE 花粉过敏原",
"https://open-meteo.com/en/docs/air-quality-api",
"Pollen data",
)
result = BriefingResult(
"Daily",
"Overview",
(),
advice=(Conclusion("花粉浓度较高", ("allergen:open-meteo",)),),
)

rendered = PlainTextRenderer().render_briefing(result, (), (context,))

assert ("Open-Meteo / CAMS ENSEMBLE 花粉过敏原: https://open-meteo.com/en/docs/air-quality-api") in rendered.body


def test_telegram_html_renders_active_warnings_section() -> None:
now = pendulum.datetime(2026, 7, 11, 8, tz="Asia/Shanghai")
article = Article("source", "feed", "Feed", "Title", "https://example.invalid/a", now, "Body")
Expand Down
1 change: 1 addition & 0 deletions tests/test_weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,7 @@ def handler(request: httpx.Request) -> httpx.Response:
assert snapshot.air_quality is not None
assert snapshot.allergen is not None
assert snapshot.allergen.source_id == "allergen:open-meteo"
assert snapshot.allergen.source_name == "Open-Meteo / CAMS ENSEMBLE 花粉过敏原"
level_names = {level.name for level in snapshot.allergen.levels}
assert {"桤木", "桦木", "禾本"} == level_names
birch = next(level for level in snapshot.allergen.levels if level.name == "桦木")
Expand Down
36 changes: 18 additions & 18 deletions weather_briefing/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,21 @@ def render_briefing(
reference_articles: tuple[Article, ...],
context: tuple[SourceDocument, ...],
) -> RenderedMessage:
source_urls = {article.id: article.url for article in reference_articles}
source_urls.update({document.id: document.url for document in context})
source_links = {article.id: _html_link(article.url) for article in reference_articles}
source_links.update({document.id: _html_link(document.url, document.name) for document in context})
lines = [f"<b>{_html_text(result.headline)}</b>", "", _html_text(result.overview), ""]
if result.active_warnings:
lines.extend(["<b>当前生效的气象预警</b>", ""])
for warning in result.active_warnings:
links = " ".join(_html_link(source_urls[source_id]) for source_id in warning.source_ids)
links = " ".join(source_links[source_id] for source_id in warning.source_ids)
lines.append(
f"• <b>{_html_text(warning.title)}({_html_text(warning.status)})</b>:"
f"{_html_text(warning.detail)} {links}".rstrip()
)
lines.append("")
lines.extend(_html_items("天气信息", result.conclusions, source_urls))
lines.extend(_html_items("灾害动态", result.disaster_tracking, source_urls))
lines.extend(_html_items("生活建议", result.advice, source_urls))
lines.extend(_html_items("天气信息", result.conclusions, source_links))
lines.extend(_html_items("灾害动态", result.disaster_tracking, source_links))
lines.extend(_html_items("生活建议", result.advice, source_links))
return _html_message("\n".join(lines).strip())

def render_verbatim(self, article: Article) -> RenderedMessage:
Expand All @@ -73,18 +73,18 @@ def render_briefing(
reference_articles: tuple[Article, ...],
context: tuple[SourceDocument, ...],
) -> RenderedMessage:
source_urls = {article.id: article.url for article in reference_articles}
source_urls.update({document.id: document.url for document in context})
source_references = {article.id: 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, ""]
if result.active_warnings:
lines.extend(["当前生效的气象预警", ""])
for warning in result.active_warnings:
sources = " ".join(source_urls[item] for item in warning.source_ids)
sources = " ".join(source_references[item] for item in warning.source_ids)
lines.append(f"- {warning.title}({warning.status}):{warning.detail} {sources}".rstrip())
lines.append("")
lines.extend(_plain_items("天气信息", result.conclusions, source_urls))
lines.extend(_plain_items("灾害动态", result.disaster_tracking, source_urls))
lines.extend(_plain_items("生活建议", result.advice, source_urls))
lines.extend(_plain_items("天气信息", result.conclusions, source_references))
lines.extend(_plain_items("灾害动态", result.disaster_tracking, source_references))
lines.extend(_plain_items("生活建议", result.advice, source_references))
return _plain_message("\n".join(lines).strip())

def render_verbatim(self, article: Article) -> RenderedMessage:
Expand All @@ -98,27 +98,27 @@ def _html_text(value: str) -> str:
return escape(unescape(value), quote=False)


def _html_link(url: str) -> str:
return f'<a href="{escape(url, quote=True)}">来源</a>'
def _html_link(url: str, label: str = "来源") -> str:
return f'<a href="{escape(url, quote=True)}">{_html_text(label)}</a>'


def _html_items(title: str, items: tuple[Conclusion, ...], source_urls: dict[str, str]) -> list[str]:
def _html_items(title: str, items: tuple[Conclusion, ...], source_links: dict[str, str]) -> list[str]:
if not items:
return []
lines = [f"<b>{_html_text(title)}</b>", ""]
for item in items:
links = " ".join(_html_link(source_urls[source_id]) for source_id in item.source_ids)
links = " ".join(source_links[source_id] for source_id in item.source_ids)
lines.append(f"• {_html_text(item.text)} {links}".rstrip())
lines.append("")
return lines


def _plain_items(title: str, items: tuple[Conclusion, ...], source_urls: dict[str, str]) -> list[str]:
def _plain_items(title: str, items: tuple[Conclusion, ...], source_references: dict[str, str]) -> list[str]:
if not items:
return []
lines = [title, ""]
for item in items:
sources = " ".join(source_urls[source_id] for source_id in item.source_ids)
sources = " ".join(source_references[source_id] for source_id in item.source_ids)
lines.append(f"- {item.text} {sources}".rstrip())
lines.append("")
return lines
Expand Down
2 changes: 1 addition & 1 deletion weather_briefing/weather_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ def _parse_allergen(
)
return AllergenSnapshot(
source_id="allergen:open-meteo",
source_name="Open-Meteo 花粉过敏原",
source_name="Open-Meteo / CAMS ENSEMBLE 花粉过敏原",
source_url="https://open-meteo.com/en/docs/air-quality-api",
observed_at=observed_at,
levels=tuple(levels),
Expand Down
Loading