osint-dashboard/news/summerizer/run_news_summarizer.py

132 lines
4.4 KiB
Python
Raw Normal View History

#!/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())