#!/usr/bin/env python3 """Scheduler loop for the news summarizer. 15-minute analyst (NEWS_SUMMARIZE_INTERVAL_S, default 900s) plus a daily recap at 23:00 in TZ (default America/New_York) over the last 24 hours. Serial: a slow LLM pass never overlaps the next. Env (all optional, 12-factor): NEWS_SUMMARIZE_INTERVAL_S seconds between analyst runs (default 900) NEWS_SUMMARIZE_RUN_ON_START "1" to summarize once immediately on boot (default 1) NEWS_RECAP_HOUR / MINUTE wall-clock recap time (default 23:00) TZ IANA tz (default America/New_York) NOUS_API_KEY optional in env; Keys UI / api_keys also works """ from __future__ import annotations import logging import os import subprocess import sys import time from datetime import datetime, timedelta from zoneinfo import ZoneInfo logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logger = logging.getLogger("news.summarizer.scheduler") INTERVAL_S = max(1, int(os.getenv("NEWS_SUMMARIZE_INTERVAL_S", "900"))) RUN_ON_START = os.getenv("NEWS_SUMMARIZE_RUN_ON_START", "1").lower() in ("1", "true", "yes") DEFAULT_TZ = "America/New_York" DEFAULT_RECAP_HOUR = 23 DEFAULT_RECAP_MINUTE = 0 def _tz() -> ZoneInfo: name = (os.getenv("TZ") or DEFAULT_TZ).strip() or DEFAULT_TZ return ZoneInfo(name) def recap_hour_minute() -> tuple[int, int]: hour = int(os.getenv("NEWS_RECAP_HOUR", str(DEFAULT_RECAP_HOUR))) minute = int(os.getenv("NEWS_RECAP_MINUTE", str(DEFAULT_RECAP_MINUTE))) return hour, minute def next_recap_datetime( now: datetime, hour: int | None = None, minute: int | None = None ) -> datetime: """Next 23:00 (or hour/minute) strictly after *now* in now's timezone.""" if now.tzinfo is None: now = now.replace(tzinfo=_tz()) env_h, env_m = recap_hour_minute() hour = env_h if hour is None else hour minute = env_m if minute is None else minute candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0) if now >= candidate: candidate += timedelta(days=1) return candidate def next_event( now: datetime, last_periodic: datetime | None, interval_s: int, hour: int = DEFAULT_RECAP_HOUR, minute: int = DEFAULT_RECAP_MINUTE, ) -> tuple[datetime, str]: """Return (when, 'recap'|'interval') for the sooner of recap vs interval.""" recap_at = next_recap_datetime(now, hour=hour, minute=minute) periodic_at = now if last_periodic is None else last_periodic + timedelta(seconds=interval_s) if recap_at <= periodic_at: return recap_at, "recap" return periodic_at, "interval" def run_summarize(*, recap: bool = False) -> None: kind = "recap" if recap else "interval" logger.info( "%s starting at %s", kind, datetime.now().isoformat(timespec="seconds") ) env = os.environ.copy() if recap: env["NEWS_RECAP"] = "1" else: env.pop("NEWS_RECAP", None) try: proc = subprocess.run( [sys.executable, "summarizer.py"], cwd="/app", env=env ) logger.info("%s finished rc=%s", kind, proc.returncode) except Exception: # noqa: BLE001 — keep the loop alive across failures logger.exception("%s failed", kind) def main() -> None: if not os.getenv("NOUS_API_KEY", "").strip(): logger.warning( "NOUS_API_KEY unset in env — will read api_keys on each run; idle if both empty" ) tz = _tz() hour, minute = recap_hour_minute() logger.info( "news summarizer loop starting (interval_s=%s, run_on_start=%s, recap=%02d:%02d %s)", INTERVAL_S, RUN_ON_START, hour, minute, tz, ) last_periodic: datetime | None = None if RUN_ON_START: run_summarize(recap=False) last_periodic = datetime.now(tz) while True: now = datetime.now(tz) when, kind = next_event( now, last_periodic, INTERVAL_S, hour=hour, minute=minute ) sleep_s = max(1, (when - now).total_seconds()) logger.info("next %s in %ss", kind, int(sleep_s)) time.sleep(sleep_s) now = datetime.now(tz) if kind == "recap": run_summarize(recap=True) # Recap covers the 15-min window; don't immediately fire interval. last_periodic = now else: run_summarize(recap=False) last_periodic = now if __name__ == "__main__": sys.exit(main())