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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

## 功能

- 08:00 生成当日预报及穿衣、除湿、运动、口罩建议。
- 09:00–23:00 每小时生成增量简报,不重复生活建议。
- 默认 08:00 生成当日预报及穿衣、除湿、运动、口罩建议。
- 默认 09:00–23:00 每小时生成增量简报,不重复生活建议。
- 标题匹配配置规则的权威预报文章经 HTML 与页面噪声清洗后完整独立转发,并进入后续预报上下文。
- 08:00 通过可组合 provider 加入 API 天气预报、AQI、指数标准、PM2.5 原始浓度及生活指数,并用于穿衣、运动和口罩建议。
- 日报通过可组合 provider 加入 API 天气预报、AQI、指数标准、PM2.5 原始浓度及生活指数,并用于穿衣、运动和口罩建议。
- 中国大陆天气默认使用 QWeather、Open-Meteo 的降级顺序,其他地区默认只使用 Open-Meteo;也可通过 `WEATHER_PROVIDERS` 显式指定主要来源和其他备用来源。
- 支持多个关注地点;只给地名时通过 Open-Meteo Geocoding 解析并缓存坐标与国家信息,已有坐标时不发起地理编码请求。
- 完整地名无法解析时按可配置规则逐级降低查询精度;首次匹配会投递实际匹配地名和坐标,请用户确认并写回私密地点文件。
Expand Down Expand Up @@ -55,6 +55,8 @@ BRIEFING_STATE_PATH=state/replay.sqlite3 weather-briefing run daily --at 2026-07

项目提供单一 OCI 镜像,不需要 Docker Compose。构建并运行常驻调度器:

日报默认在 `BRIEFING_TIMEZONE` 的 08:00 运行,可用 `GREETING_HOUR` 和 `GREETING_MINUTE` 调整。小时简报默认在 09:00–23:00 的整点运行;`BRIEFING_CRON` 是 APScheduler 的小时字段表达式,例如 `9-23`、`8,12,16` 或 `*/2`。启动常驻调度器时传入 `daemon --run-now` 可在建立定时任务前立即运行一次小时简报。

```bash
cp env.example .env
cp locations.example.json locations.json
Expand Down
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

匹配 `verbatim_title_patterns` 的文章跳过 LLM,总结任务只对清洗后的正文做独立完整投递。因此“原样”指正文语义内容不改写、不摘要、不截断,不表示保留来源页面的 HTML、交互组件或元数据噪声。清洗后的同一正文也作为后续预报的权威上下文。

08:00 日报与 09:00–23:00 小时简报共用同一个 LLM 调用和结构化结果校验路径。日报额外加载昨日权威预报、空气质量和生活指数并要求生成生活建议;小时简报只处理增量信息且禁止重复建议。
默认 08:00 的日报与 09:00–23:00 的小时简报共用同一个 LLM 调用和结构化结果校验路径,两类调度均可由运行时环境调整。日报额外加载昨日权威预报、空气质量和生活指数并要求生成生活建议;小时简报只处理增量信息且禁止重复建议。

## 天气、空气质量与生活指数上下文

Expand Down
6 changes: 3 additions & 3 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

## 调度与内容

1. 每天 09:00–23:00 整点生成天气信息增量简报。
2. 每天 08:00 基于当天已发布信息、昨日预报及历史简报生成当日天气预报,并给出穿衣、除湿、运动、口罩建议。
1. 默认每天 09:00–23:00 整点生成天气信息增量简报;运行时可用 APScheduler 小时字段表达式调整运行小时
2. 默认每天 08:00 基于当天已发布信息、昨日预报及历史简报生成当日天气预报,并给出穿衣、除湿、运动、口罩建议;运行时可调整日报的当地时间
当日预报还应包含关注地区的 AQI、AQI 标准与 PM2.5 原始浓度(来源提供时),运动和口罩建议必须参考空气质量及生活指数。
3. 小时简报不重复生活建议;当前生效气象预警必须单独成篇。
4. 跟踪可能影响关注地区的台风、海啸、地震等灾害,说明位置和未来运动或传播方向。
5. 每条结论附输入来源链接,便于核验。
6. 08:00 日报和 09:00–23:00 小时简报都必须调用配置的 LLM provider;差异只体现在输入上下文和输出约束,不能用静态模板代替小时总结。
6. 日报和小时简报都必须调用配置的 LLM provider;差异只体现在输入上下文和输出约束,不能用静态模板代替小时总结。

## 采集与记忆

Expand Down
6 changes: 5 additions & 1 deletion env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ HTTP_TIMEOUT_SECONDS=30
# Open-Meteo Geocoding resolves and caches them. See locations.example.json.
BRIEFING_LOCATIONS_FILE=locations.json

# Optional scheduling timezone and geocoding endpoint settings.
# Optional scheduling timezone and schedule. BRIEFING_CRON accepts an
# APScheduler cron expression for the hour field, such as 9-23 or */2.
BRIEFING_TIMEZONE=Asia/Shanghai
GREETING_HOUR=8
GREETING_MINUTE=0
BRIEFING_CRON=9-23
GEOCODING_BASE_URL=https://geocoding-api.open-meteo.com
GEOCODING_API_KEY=
NOMINATIM_BASE_URL=https://nominatim.openstreetmap.org
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "uv_build"

[project]
name = "weather-briefing"
version = "0.1.0"
version = "0.2.0"
description = "Stateful, source-cited weather briefings"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
75 changes: 75 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
import pytest

from weather_briefing.cli import (
_hour_in_cron,
_in_schedule,
_location_state_path,
_parse_run_time,
_precision_reduction_notice,
build_parser,
)
from weather_briefing.config import Settings
from weather_briefing.models import ResolvedLocation


Expand Down Expand Up @@ -63,3 +67,74 @@ def test_precision_reduction_notice_contains_match_coordinates_and_action() -> N
assert "116.3805560" in notice
assert "locations.json" in notice
assert "确认" in notice


class TestHourInCron:
@pytest.mark.parametrize("hour", (9, 15, 23))
def test_range_includes_bounds(self, hour: int) -> None:
assert _hour_in_cron(hour, "9-23")

@pytest.mark.parametrize("hour", (0, 8, 24))
def test_range_excludes_outside(self, hour: int) -> None:
assert not _hour_in_cron(hour, "9-23")

def test_single_value(self) -> None:
assert _hour_in_cron(8, "8")
assert not _hour_in_cron(9, "8")

def test_comma_separated_list(self) -> None:
assert _hour_in_cron(8, "8,12,16")
assert _hour_in_cron(12, "8,12,16")
assert _hour_in_cron(16, "8,12,16")
assert not _hour_in_cron(9, "8,12,16")

def test_wildcard_falls_back_to_true(self) -> None:
assert _hour_in_cron(5, "*")

def test_stepped_wildcard_matches_only_scheduled_hours(self) -> None:
assert _hour_in_cron(4, "*/2")
assert not _hour_in_cron(5, "*/2")

def test_stepped_range(self) -> None:
assert _hour_in_cron(3, "1-23/2")
assert not _hour_in_cron(2, "1-23/2")

def test_comma_whitespace_tolerance(self) -> None:
assert _hour_in_cron(12, " 9 , 12 , 16 ")


class TestInSchedule:
def test_daily_matches_greeting_hour(self, monkeypatch) -> None:
monkeypatch.setenv("GREETING_HOUR", "7")
monkeypatch.setenv("GREETING_MINUTE", "30")
monkeypatch.setenv("DEEPSEEK_API_KEY", "k")
monkeypatch.setenv("DEEPSEEK_MODEL", "m")
monkeypatch.setenv("BRIEFING_LOCATIONS_FILE", str(Path(__file__).parents[1] / "locations.example.json"))
monkeypatch.setenv("RSS_SOURCES_FILE", str(Path(__file__).parents[1] / "rss-sources.example.json"))

settings = Settings.from_env()
now_match = pendulum.datetime(2026, 7, 14, 7, 0, tz=settings.timezone)
now_no_match = pendulum.datetime(2026, 7, 14, 8, 0, tz=settings.timezone)

assert _in_schedule("daily", now_match, settings)
assert not _in_schedule("daily", now_no_match, settings)

def test_hourly_matches_cron_range(self, monkeypatch) -> None:
monkeypatch.setenv("BRIEFING_CRON", "10-18")
monkeypatch.setenv("DEEPSEEK_API_KEY", "k")
monkeypatch.setenv("DEEPSEEK_MODEL", "m")
monkeypatch.setenv("BRIEFING_LOCATIONS_FILE", str(Path(__file__).parents[1] / "locations.example.json"))
monkeypatch.setenv("RSS_SOURCES_FILE", str(Path(__file__).parents[1] / "rss-sources.example.json"))

settings = Settings.from_env()
now_in = pendulum.datetime(2026, 7, 14, 10, 0, tz=settings.timezone)
now_out = pendulum.datetime(2026, 7, 14, 9, 0, tz=settings.timezone)

assert _in_schedule("hourly", now_in, settings)
assert not _in_schedule("hourly", now_out, settings)


def test_version_flag() -> None:
parser = build_parser()
with pytest.raises(SystemExit):
parser.parse_args(["--version"])
60 changes: 60 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,63 @@ def test_generic_llm_provider_uses_generic_configuration(monkeypatch) -> None:
assert settings.llm_provider == "openai-compatible"
assert settings.llm_model == "generic-model"
assert settings.llm_base_url == "https://compatible.example.invalid/v1"


class TestScheduleSettings:
def test_defaults(self, monkeypatch) -> None:
_required_environment(monkeypatch)

settings = Settings.from_env()

assert settings.greeting_hour == 8
assert settings.greeting_minute == 0
assert settings.hourly_cron == "9-23"

def test_custom_greeting(self, monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("GREETING_HOUR", "7")
monkeypatch.setenv("GREETING_MINUTE", "30")

settings = Settings.from_env()

assert settings.greeting_hour == 7
assert settings.greeting_minute == 30

def test_custom_hourly_cron(self, monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("BRIEFING_CRON", "8-20")

settings = Settings.from_env()

assert settings.hourly_cron == "8-20"

def test_empty_briefing_cron_rejected(self, monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("BRIEFING_CRON", "")

with pytest.raises(ConfigurationError, match="BRIEFING_CRON must not be empty"):
Settings.from_env()

@pytest.mark.parametrize("value", ("foo", "24", "9-", "9 - 18"))
def test_invalid_briefing_cron_rejected(self, monkeypatch, value: str) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("BRIEFING_CRON", value)

with pytest.raises(ConfigurationError, match="valid APScheduler hour expression"):
Settings.from_env()

@pytest.mark.parametrize("value", ("25", "-1", "abc"))
def test_greeting_hour_out_of_bounds_rejected(self, monkeypatch, value: str) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("GREETING_HOUR", value)

with pytest.raises(ConfigurationError):
Settings.from_env()

@pytest.mark.parametrize("value", ("60", "-1", "abc"))
def test_greeting_minute_out_of_bounds_rejected(self, monkeypatch, value: str) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("GREETING_MINUTE", value)

with pytest.raises(ConfigurationError):
Settings.from_env()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion weather_briefing/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Stateful weather briefing generator."""

__version__ = "0.1.0"
__version__ = "0.2.0"
40 changes: 31 additions & 9 deletions weather_briefing/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
import asyncio
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path

import httpx
Expand All @@ -11,6 +12,7 @@
from apscheduler.triggers.cron import CronTrigger
from dotenv import load_dotenv

from . import __version__
from .air_quality import (
AirQualityProvider,
AQICNProvider,
Expand Down Expand Up @@ -47,25 +49,35 @@

def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Generate a stateful weather briefing")
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
subparsers = parser.add_subparsers(dest="command", required=True)
run_parser = subparsers.add_parser("run")
run_parser.add_argument("kind", choices=("daily", "hourly"))
run_parser.add_argument("--enforce-window", action="store_true")
run_parser.add_argument("--at", help="Override run time with an ISO-8601 timestamp including UTC offset")
subparsers.add_parser("daemon")
daemon_parser = subparsers.add_parser("daemon")
daemon_parser.add_argument("--run-now", action="store_true", help="Run a briefing immediately before scheduling")
return parser


def _in_schedule(kind: str, now: pendulum.DateTime) -> bool:
def _in_schedule(kind: str, now: pendulum.DateTime, settings: Settings) -> bool:
if kind == "daily":
return now.hour == 8
return 9 <= now.hour <= 23
return now.hour == settings.greeting_hour
return _hour_in_cron(now.hour, settings.hourly_cron)


def _hour_in_cron(hour: int, cron_hour: str) -> bool:
if not 0 <= hour <= 23:
return False
current_hour = datetime(2000, 1, 1, hour, tzinfo=UTC)
trigger = CronTrigger(hour=cron_hour, timezone=UTC)
return trigger.get_next_fire_time(None, current_hour) == current_hour


async def run(kind: str, enforce_window: bool, at: str | None = None) -> None:
settings = Settings.from_env()
now = _parse_run_time(at, settings.timezone)
if enforce_window and not _in_schedule(kind, now):
if enforce_window and not _in_schedule(kind, now, settings):
print(f"Skipping delayed {kind} run outside configured local-time window")
return
async with httpx.AsyncClient(
Expand Down Expand Up @@ -295,18 +307,28 @@ def _parse_run_time(
return parse_aware_datetime(value, context="Run time").in_timezone(timezone)


async def daemon() -> None:
async def daemon(run_now: bool = False) -> None:
settings = Settings.from_env()
if run_now:
await run("hourly", False)
scheduler = AsyncIOScheduler(timezone=settings.timezone)
scheduler.add_job(
run,
CronTrigger(hour=8, minute=0, timezone=settings.timezone),
CronTrigger(
hour=settings.greeting_hour,
minute=settings.greeting_minute,
timezone=settings.timezone,
),
args=("daily", False),
max_instances=1,
)
scheduler.add_job(
run,
CronTrigger(hour="9-23", minute=0, timezone=settings.timezone),
CronTrigger(
hour=settings.hourly_cron,
minute=0,
timezone=settings.timezone,
),
args=("hourly", False),
max_instances=1,
)
Expand All @@ -318,7 +340,7 @@ def main() -> None:
load_dotenv(override=False)
args = build_parser().parse_args()
if args.command == "daemon":
asyncio.run(daemon())
asyncio.run(daemon(args.run_now))
else:
asyncio.run(run(args.kind, args.enforce_window, args.at))

Expand Down
28 changes: 28 additions & 0 deletions weather_briefing/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any

import pendulum
from apscheduler.triggers.cron import CronTrigger

from .models import ContextSourceConfig, FeedConfig, LocationSpec, ResolvedLocation
from .reference_data import reference_string_tuple
Expand Down Expand Up @@ -44,6 +45,24 @@ def _bounded_positive_integer(name: str, default: int, maximum: int) -> int:
return value


def _bounded_integer(name: str, default: int, minimum: int, maximum: int) -> int:
value = _integer(name, default)
if not minimum <= value <= maximum:
raise ConfigurationError(f"{name} must be between {minimum} and {maximum}")
return value


def _cron_hour(name: str, default: str) -> str:
value = os.getenv(name, default).strip()
if not value:
raise ConfigurationError(f"{name} must not be empty")
try:
CronTrigger(hour=value)
except ValueError as exc:
raise ConfigurationError(f"{name} must be a valid APScheduler hour expression") from exc
return value


def _float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
Expand Down Expand Up @@ -164,6 +183,9 @@ class Settings:
warning_retention_hours: int
history_hours: int
briefing_max_characters: int
greeting_hour: int
greeting_minute: int
hourly_cron: str

@classmethod
def from_env(cls) -> Settings:
Expand Down Expand Up @@ -202,6 +224,9 @@ def from_env(cls) -> Settings:
if retry_min < 0 or retry_max < retry_min:
raise ConfigurationError("RSS retry delay range is invalid")
briefing_max_characters = _positive_integer("BRIEFING_MAX_CHARACTERS", 3500)
daily_cron_hour = _bounded_integer("GREETING_HOUR", 8, 0, 23)
daily_cron_minute = _bounded_integer("GREETING_MINUTE", 0, 0, 59)
hourly_cron = _cron_hour("BRIEFING_CRON", "9-23")
llm_provider = os.getenv("LLM_PROVIDER", "deepseek")
if llm_provider == "deepseek":
api_key = _required("DEEPSEEK_API_KEY")
Expand Down Expand Up @@ -280,4 +305,7 @@ def from_env(cls) -> Settings:
warning_retention_hours=_positive_integer("WARNING_RETENTION_HOURS", 12),
history_hours=_positive_integer("HISTORY_HOURS", 48),
briefing_max_characters=briefing_max_characters,
greeting_hour=daily_cron_hour,
greeting_minute=daily_cron_minute,
hourly_cron=hourly_cron,
)