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: 2 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ SQLite 保存:

每次任务成功结束时,状态存储在重置任务失败计数的同一事务中清理历史数据。已处理文章、已发布简报和上下文快照保留 `HISTORY_HOURS`,预警按独立的 `WARNING_RETENTION_HOURS` 保留;恰好位于阈值时刻的记录仍属于当前窗口。尚未成功发布的待处理文章和使用 upsert 维护的健康状态不参与历史清理。失败任务不清理历史,以免在恢复前丢失诊断与待处理上下文。

历史上下文快照在进入 LLM 前按 source 折叠连续相同值,并依次保留各 source 的最新值、窗口基线和最近变化节点;每条记录通过 `history_role` 明确标记这三种角色。`LLM_HISTORY_MAX_DOCUMENTS` 限制快照总数,`LLM_HISTORY_MAX_CHARACTERS` 限制 `recent_context_documents` JSON 数组的完整序列化字符数;超过字符预算的完整文档被跳过而不截断正文。最近成功发布的简报仍随 payload 提供,模型因此可以用有界的基线、最新值及变化节点判断相对上次成功发布的累计变化。DEBUG 诊断只记录候选数、入选数和序列化字符数,不记录来源名称、URL 或正文。

## 时间模型

应用只接受 Pendulum 的时区感知 `DateTime`,绝不把缺少明确时区的信息解释为时间点,也不依赖进程或服务器的本地时区。内存中的时间保留其明确时区,Pendulum 直接按绝对时间比较,不为比较提前转换。
Expand Down
6 changes: 6 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ Nominatim 限速使用 `await asyncio.sleep()`,它只挂起当前协程并把

大陆行政区排除规则在载入参考数据和比较 API 返回值时都使用 `casefold()`,不存在两边归一化不一致。空气质量分级数量很小且最后一档无上限,线性扫描比为 `bisect` 拆分阈值与结果更直接。provider UTC offset 接受整分钟的 ±14 小时范围,这是合法时区边界,不应收窄。

## LLM 历史输入预算的当前范围

`LLM_HISTORY_MAX_DOCUMENTS` 和 `LLM_HISTORY_MAX_CHARACTERS` 只限制逐小时积累的历史 API 上下文快照。历史文章与已发布简报仍由 `HISTORY_HOURS` 控制时间范围,不另设数量或字符预算。当前接受这一边界,是因为已配置 RSS 的发布频率较低,普通 briefing 只有值得打扰时才保存正文,而 API 快照是唯一会在每个调度轮次稳定增长的输入类别。

应用以 DEBUG 元数据记录完整 payload 的序列化字符数,但不记录正文。若 RSS 发布量或地点数显著增加、历史简报开始接近逐小时产生、payload 字符数持续增长,或再次出现与输入规模相关的模型超时或上下文长度错误,应把文章、简报和快照收敛到带类别最低保留量的统一历史输入预算,而不是继续分别增加当前两个快照上限。

## 异步编排中的同步本地持久化

### 当前选择
Expand Down
2 changes: 2 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,5 @@ BRIEFING_STATE_PATH=state/weather.sqlite3
RSS_FAILURE_THRESHOLD=3
WARNING_RETENTION_HOURS=12
HISTORY_HOURS=48
LLM_HISTORY_MAX_DOCUMENTS=8
LLM_HISTORY_MAX_CHARACTERS=16000
2 changes: 2 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,8 @@ async def fail_run(
rss_failure_threshold=3,
warning_retention_hours=12,
history_hours=48,
llm_history_max_documents=16,
llm_history_max_characters=16_000,
briefing_max_characters=3500,
greeting_hour=8,
greeting_minute=0,
Expand Down
31 changes: 31 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,37 @@ def test_mainland_weather_providers_default_to_qweather_then_open_meteo(monkeypa
assert settings.llm_base_url is None
assert settings.qweather_index_types == ("1", "3", "6", "7", "8", "15")
assert settings.qweather_jwt_lifetime_seconds == 900
assert settings.llm_history_max_documents == 8
assert settings.llm_history_max_characters == 16_000


def test_llm_history_limits_can_be_configured(monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("LLM_HISTORY_MAX_DOCUMENTS", "6")
monkeypatch.setenv("LLM_HISTORY_MAX_CHARACTERS", "8000")

settings = Settings.from_env()

assert settings.llm_history_max_documents == 6
assert settings.llm_history_max_characters == 8_000


@pytest.mark.parametrize("value", ("0", "257"))
def test_llm_history_document_limit_rejects_unsafe_values(monkeypatch, value: str) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("LLM_HISTORY_MAX_DOCUMENTS", value)

with pytest.raises(ConfigurationError, match="LLM_HISTORY_MAX_DOCUMENTS"):
Settings.from_env()


@pytest.mark.parametrize("value", ("0", "1", "1000001"))
def test_llm_history_character_limit_rejects_unsafe_values(monkeypatch, value: str) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("LLM_HISTORY_MAX_CHARACTERS", value)

with pytest.raises(ConfigurationError, match="LLM_HISTORY_MAX_CHARACTERS"):
Settings.from_env()


def test_environment_example_preserves_default_qweather_indices() -> None:
Expand Down
72 changes: 70 additions & 2 deletions tests/test_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import asyncio
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TypeGuard
Expand All @@ -20,7 +22,7 @@
)
from weather_briefing.publishers import DeliveryProvider
from weather_briefing.render import PlainTextRenderer
from weather_briefing.service import BriefingService
from weather_briefing.service import BriefingService, _bounded_context_history, _context_history_candidates
from weather_briefing.state import SQLiteStateStore
from weather_briefing.weather_context import WeatherContextError

Expand All @@ -34,6 +36,8 @@ class _TestSettings:
rss_failure_threshold: int = 3
warning_retention_hours: int = 12
history_hours: int = 48
llm_history_max_documents: int = 8
llm_history_max_characters: int = 16_000
briefing_max_characters: int = 3500
llm_max_attempts: int = 3

Expand Down Expand Up @@ -209,6 +213,61 @@ def _location(
)


def _context_document(source_id: str, content: str) -> SourceDocument:
return SourceDocument(
id=source_id,
name=f"Source {source_id}",
url=f"https://example.invalid/{source_id}",
content=content,
)


def test_context_history_keeps_latest_baselines_and_recent_changes() -> None:
documents = (
_context_document("weather", "weather baseline"),
_context_document("weather", "weather baseline"),
_context_document("weather", "weather changed"),
_context_document("air", "air baseline"),
_context_document("air", "air changed"),
_context_document("weather", "weather latest"),
_context_document("air", "air latest"),
)

selected = _context_history_candidates(documents, max_documents=6)

assert [(candidate.document.id, candidate.document.content, candidate.role) for candidate in selected] == [
("air", "air latest", "latest"),
("weather", "weather latest", "latest"),
("air", "air baseline", "retention_baseline"),
("weather", "weather baseline", "retention_baseline"),
("air", "air changed", "recent_change"),
("weather", "weather changed", "recent_change"),
]


def test_context_history_enforces_document_and_serialized_character_limits() -> None:
oversized = _context_document("oversized", "private-history" * 20)
compact = _context_document("compact", "current")
compact_payload, compact_characters = _bounded_context_history(
(compact,),
max_documents=1,
max_characters=1_000,
)
character_limit = compact_characters

selected, serialized_characters = _bounded_context_history(
(compact, oversized),
max_documents=2,
max_characters=character_limit,
)

assert [item["source_id"] for item in selected] == ["compact"]
assert selected[0]["history_role"] == "latest"
assert len(selected) <= 2
assert serialized_characters == len(json.dumps(selected, ensure_ascii=False, separators=(",", ":")))
assert serialized_characters <= character_limit


@pytest.mark.parametrize(
("kind", "historical_ids"),
(("briefing", ["historical-verbatim"]), ("forecast", ["historical-verbatim", "historical-summary"])),
Expand Down Expand Up @@ -551,6 +610,7 @@ async def test_briefing_also_uses_the_llm_provider(tmp_path: Path) -> None:

async def test_briefing_api_only_update_can_be_remembered_without_delivery(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
timezone = pendulum.timezone("Asia/Shanghai")
settings = _TestSettings(
Expand All @@ -571,6 +631,10 @@ async def test_briefing_api_only_update_can_be_remembered_without_delivery(
now = pendulum.datetime(2026, 7, 13, 9, tz=timezone)

with SQLiteStateStore(tmp_path / "state.sqlite3") as state:
state.save_context_documents(
(_context_document("weather:test", "sensitive historical body"),),
now.subtract(hours=1),
)
service = BriefingService(
settings,
_location(),
Expand All @@ -582,13 +646,17 @@ async def test_briefing_api_only_update_can_be_remembered_without_delivery(
delivery,
weather_context,
)
result = await service.run("briefing", now)
with caplog.at_level(logging.DEBUG, logger="weather_briefing.service"):
result = await service.run("briefing", now)
remembered = state.recent_context_documents(now, 1)

assert result is None
assert llm.payload is not None
assert llm.payload["mode"] == "briefing"
assert publisher.messages == []
assert "input_documents=1 selected_documents=1 serialized_characters=" in caplog.text
assert "LLM payload prepared: serialized_characters=" in caplog.text
assert "sensitive historical body" not in caplog.text
assert {document.id for document in remembered} == {
"weather:test",
"air-quality:test",
Expand Down
9 changes: 9 additions & 0 deletions weather_briefing/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ class Settings:
rss_failure_threshold: int
warning_retention_hours: int
history_hours: int
llm_history_max_documents: int
llm_history_max_characters: int
briefing_max_characters: int
greeting_hour: int
greeting_minute: int
Expand Down Expand Up @@ -424,6 +426,13 @@ def from_env(cls) -> Settings:
rss_failure_threshold=_positive_integer("RSS_FAILURE_THRESHOLD", 3),
warning_retention_hours=_positive_integer("WARNING_RETENTION_HOURS", 12),
history_hours=_positive_integer("HISTORY_HOURS", 48),
llm_history_max_documents=_bounded_positive_integer("LLM_HISTORY_MAX_DOCUMENTS", 8, 256),
llm_history_max_characters=_bounded_integer(
"LLM_HISTORY_MAX_CHARACTERS",
16_000,
2,
1_000_000,
),
briefing_max_characters=briefing_max_characters,
greeting_hour=daily_cron_hour,
greeting_minute=daily_cron_minute,
Expand Down
1 change: 1 addition & 0 deletions weather_briefing/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
与上次成功发布相比没有值得打扰的累计变化时必须设为 false。forecast 模式必须设为 true。
总结 deferred_articles、historical_articles、recent_context_documents 和 recent_briefings 时,
必须先判断每条信息的时效性。
recent_context_documents 的 history_role 分别标识各来源的最新值、保留窗口基线和最近变化节点。
对气温、降水、风力、空气质量、短时预报等会过期的信息,始终以时间最新且仍适用于当前时刻的来源为准;
不得因为旧信息尚未发送就保留已经被较新快照取代的数值或结论。
active_warnings 应包含仍有效的预警以维持状态,但预警仅仅持续生效且内容无实质变化时必须设为 false。
Expand Down
Loading