2026-08-24 17:28:46 -04:00
|
|
|
#!/usr/bin/env python3
|
2026-08-28 22:53:31 -04:00
|
|
|
"""Scheduler loop for the news summarizer.
|
2026-08-24 17:28:46 -04:00
|
|
|
|
2026-08-28 22:53:31 -04:00
|
|
|
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.
|
2026-08-24 17:28:46 -04:00
|
|
|
|
|
|
|
|
Env (all optional, 12-factor):
|
2026-08-28 22:53:31 -04:00
|
|
|
NEWS_SUMMARIZE_INTERVAL_S seconds between analyst runs (default 900)
|
2026-08-24 17:28:46 -04:00
|
|
|
NEWS_SUMMARIZE_RUN_ON_START "1" to summarize once immediately on boot (default 1)
|
2026-08-28 22:53:31 -04:00
|
|
|
NEWS_RECAP_HOUR / MINUTE wall-clock recap time (default 23:00)
|
|
|
|
|
TZ IANA tz (default America/New_York)
|
2026-08-27 23:17:45 -04:00
|
|
|
NOUS_API_KEY optional in env; Keys UI / api_keys also works
|
2026-08-24 17:28:46 -04:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
2026-08-28 22:53:31 -04:00
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
from zoneinfo import ZoneInfo
|
2026-08-24 17:28:46 -04:00
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
|
|
|
logger = logging.getLogger("news.summarizer.scheduler")
|
|
|
|
|
|
2026-08-28 20:53:32 -04:00
|
|
|
INTERVAL_S = max(1, int(os.getenv("NEWS_SUMMARIZE_INTERVAL_S", "900")))
|
2026-08-24 17:28:46 -04:00
|
|
|
RUN_ON_START = os.getenv("NEWS_SUMMARIZE_RUN_ON_START", "1").lower() in ("1", "true", "yes")
|
2026-08-28 22:53:31 -04:00
|
|
|
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
|
2026-08-24 17:28:46 -04:00
|
|
|
|
|
|
|
|
|
2026-08-28 22:53:31 -04:00
|
|
|
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)
|
2026-08-24 17:28:46 -04:00
|
|
|
try:
|
2026-08-28 22:53:31 -04:00
|
|
|
proc = subprocess.run(
|
|
|
|
|
[sys.executable, "summarizer.py"], cwd="/app", env=env
|
|
|
|
|
)
|
|
|
|
|
logger.info("%s finished rc=%s", kind, proc.returncode)
|
2026-08-24 17:28:46 -04:00
|
|
|
except Exception: # noqa: BLE001 — keep the loop alive across failures
|
2026-08-28 22:53:31 -04:00
|
|
|
logger.exception("%s failed", kind)
|
2026-08-24 17:28:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
2026-08-27 23:17:45 -04:00
|
|
|
if not os.getenv("NOUS_API_KEY", "").strip():
|
2026-08-24 17:28:46 -04:00
|
|
|
logger.warning(
|
2026-08-27 23:17:45 -04:00
|
|
|
"NOUS_API_KEY unset in env — will read api_keys on each run; idle if both empty"
|
2026-08-24 17:28:46 -04:00
|
|
|
)
|
2026-08-28 22:53:31 -04:00
|
|
|
tz = _tz()
|
|
|
|
|
hour, minute = recap_hour_minute()
|
2026-08-24 17:28:46 -04:00
|
|
|
logger.info(
|
2026-08-28 22:53:31 -04:00
|
|
|
"news summarizer loop starting (interval_s=%s, run_on_start=%s, recap=%02d:%02d %s)",
|
|
|
|
|
INTERVAL_S, RUN_ON_START, hour, minute, tz,
|
2026-08-24 17:28:46 -04:00
|
|
|
)
|
2026-08-28 22:53:31 -04:00
|
|
|
last_periodic: datetime | None = None
|
2026-08-24 17:28:46 -04:00
|
|
|
if RUN_ON_START:
|
2026-08-28 22:53:31 -04:00
|
|
|
run_summarize(recap=False)
|
|
|
|
|
last_periodic = datetime.now(tz)
|
2026-08-24 17:28:46 -04:00
|
|
|
while True:
|
2026-08-28 22:53:31 -04:00
|
|
|
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
|
2026-08-24 17:28:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|