From 5416510c6511b78461699119766b6448b8370b2b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Jul 2026 00:15:57 +0800 Subject: [PATCH] feat: make briefing schedule configurable and add manual trigger support - Add BRIEFING_DAILY_HOUR/MINUTE and BRIEFING_CRON env vars for scheduling - Replace hardcoded 8:00 and 9:00-23:00 schedule with configurable values - Add --run-now flag to daemon for immediate one-shot briefing - Add --version/-V flag to CLI - Bump version to 0.2.0 - Fix test_config fixture leaking real rss-sources.json --- README.md | 8 ++-- docs/design.md | 2 +- docs/requirements.md | 6 +-- env.example | 6 ++- pyproject.toml | 2 +- tests/test_cli.py | 75 ++++++++++++++++++++++++++++++++++++ tests/test_config.py | 60 +++++++++++++++++++++++++++++ uv.lock | 2 +- weather_briefing/__init__.py | 2 +- weather_briefing/cli.py | 40 ++++++++++++++----- weather_briefing/config.py | 28 ++++++++++++++ 11 files changed, 211 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 40c36174..33ea4634 100644 --- a/README.md +++ b/README.md @@ -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 解析并缓存坐标与国家信息,已有坐标时不发起地理编码请求。 - 完整地名无法解析时按可配置规则逐级降低查询精度;首次匹配会投递实际匹配地名和坐标,请用户确认并写回私密地点文件。 @@ -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 diff --git a/docs/design.md b/docs/design.md index 199daa24..9f50eab4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -14,7 +14,7 @@ 匹配 `verbatim_title_patterns` 的文章跳过 LLM,总结任务只对清洗后的正文做独立完整投递。因此“原样”指正文语义内容不改写、不摘要、不截断,不表示保留来源页面的 HTML、交互组件或元数据噪声。清洗后的同一正文也作为后续预报的权威上下文。 -08:00 日报与 09:00–23:00 小时简报共用同一个 LLM 调用和结构化结果校验路径。日报额外加载昨日权威预报、空气质量和生活指数并要求生成生活建议;小时简报只处理增量信息且禁止重复建议。 +默认 08:00 的日报与 09:00–23:00 的小时简报共用同一个 LLM 调用和结构化结果校验路径,两类调度均可由运行时环境调整。日报额外加载昨日权威预报、空气质量和生活指数并要求生成生活建议;小时简报只处理增量信息且禁止重复建议。 ## 天气、空气质量与生活指数上下文 diff --git a/docs/requirements.md b/docs/requirements.md index b9e4bd54..de8faedc 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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;差异只体现在输入上下文和输出约束,不能用静态模板代替小时总结。 ## 采集与记忆 diff --git a/env.example b/env.example index c9992488..3ecc50f8 100644 --- a/env.example +++ b/env.example @@ -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 diff --git a/pyproject.toml b/pyproject.toml index db6ad109..2c20bc2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_cli.py b/tests/test_cli.py index 972218c0..fa3472c9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 @@ -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"]) diff --git a/tests/test_config.py b/tests/test_config.py index 6ad1fdee..b82d752a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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() diff --git a/uv.lock b/uv.lock index 1b20137b..510c79ec 100644 --- a/uv.lock +++ b/uv.lock @@ -521,7 +521,7 @@ wheels = [ [[package]] name = "weather-briefing" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "apscheduler" }, diff --git a/weather_briefing/__init__.py b/weather_briefing/__init__.py index e1f25196..e69020da 100644 --- a/weather_briefing/__init__.py +++ b/weather_briefing/__init__.py @@ -1,3 +1,3 @@ """Stateful weather briefing generator.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/weather_briefing/cli.py b/weather_briefing/cli.py index 426a1c02..dce9d73c 100644 --- a/weather_briefing/cli.py +++ b/weather_briefing/cli.py @@ -3,6 +3,7 @@ import argparse import asyncio from collections.abc import Callable +from datetime import UTC, datetime from pathlib import Path import httpx @@ -11,6 +12,7 @@ from apscheduler.triggers.cron import CronTrigger from dotenv import load_dotenv +from . import __version__ from .air_quality import ( AirQualityProvider, AQICNProvider, @@ -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( @@ -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, ) @@ -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)) diff --git a/weather_briefing/config.py b/weather_briefing/config.py index 49001935..c3946849 100644 --- a/weather_briefing/config.py +++ b/weather_briefing/config.py @@ -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 @@ -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))) @@ -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: @@ -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") @@ -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, )