Add a Monday-morning weekly recap briefing
No US session closes into Monday KST, so Mondays (default 07:00, cron in the daemon with restart catch-up) now publish a weekly review built entirely from stored data: the quotes table collapsed into week-over- week changes and the week's daily briefing headlines as the narrative. The recap is a BriefingResult kind="weekly" reusing MarketPayload, so the store, verification pass, all delivery channels (Blogger title "미국 주간 리뷰 <monday>") and the dashboard (주간 리뷰 badge) work unchanged. Manual runs via `run-once --weekly [--date <monday>]`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,12 +22,14 @@ from us2kr.deliver import build_deliverers
|
||||
from us2kr.market_time import MarketClock
|
||||
from us2kr.pipeline import run_briefing
|
||||
from us2kr.store import Store
|
||||
from us2kr.weekly import run_weekly
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BRIEFING_JOB_ID = "briefing"
|
||||
RECOVERY_JOB_ID = "briefing-recover"
|
||||
PREMARKET_JOB_ID = "premarket"
|
||||
WEEKLY_JOB_ID = "weekly"
|
||||
HEARTBEAT_JOB_ID = "heartbeat"
|
||||
|
||||
# None => APScheduler runs the one-shot briefing job whenever it becomes due, even hours late (machine
|
||||
@@ -64,6 +66,7 @@ class Daemon:
|
||||
now_fn: Callable[[], datetime] | None = None,
|
||||
deliverers_fn: Callable[[Settings], list[Any]] | None = None,
|
||||
premarket_fn: Callable[[], Any] | None = None,
|
||||
weekly_fn: Callable[[date], Any] | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.scheduler = scheduler
|
||||
@@ -74,6 +77,8 @@ class Daemon:
|
||||
self.deliverers_fn = deliverers_fn or build_deliverers
|
||||
# Extension point: plug a real pre-market update here (PLAN.md P6).
|
||||
self.premarket_fn = premarket_fn
|
||||
# Monday weekly recap; None disables (run_daemon wires us2kr.weekly.run_weekly).
|
||||
self.weekly_fn = weekly_fn
|
||||
self.next_run: tuple[date, datetime] | None = None
|
||||
self.stop_requested = False
|
||||
# Serialises pipeline runs (scheduled job / catch-up / heartbeat recovery) across executor threads.
|
||||
@@ -87,8 +92,13 @@ class Daemon:
|
||||
if self.stop_requested: # signal arrived during catch-up, before the scheduler was running
|
||||
log.info("shutdown requested during catch-up - not scheduling jobs")
|
||||
return
|
||||
self.run_weekly_catchup()
|
||||
if self.stop_requested:
|
||||
log.info("shutdown requested during weekly catch-up - not scheduling jobs")
|
||||
return
|
||||
self.schedule_next()
|
||||
self.schedule_premarket()
|
||||
self.schedule_weekly()
|
||||
self.schedule_heartbeat()
|
||||
|
||||
def install_signal_handlers(self) -> None:
|
||||
@@ -181,6 +191,46 @@ class Daemon:
|
||||
)
|
||||
log.info("premarket update scheduled Mon-Fri %02d:%02d KST", hour, minute)
|
||||
|
||||
def schedule_weekly(self) -> None:
|
||||
if not (self.settings.weekly_enabled and self.weekly_fn):
|
||||
return
|
||||
hour, minute = _parse_hhmm(self.settings.weekly_time_kst)
|
||||
self.scheduler.add_job(
|
||||
self._weekly_job,
|
||||
CronTrigger(day_of_week="mon", hour=hour, minute=minute, timezone=ZoneInfo(KST)),
|
||||
id=WEEKLY_JOB_ID,
|
||||
name="weekly recap",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
log.info("weekly recap scheduled Mon %02d:%02d KST", hour, minute)
|
||||
|
||||
def run_weekly_catchup(self) -> bool:
|
||||
"""On a Monday restart, run a missed weekly recap within the catch-up window. True if run."""
|
||||
if not (self.settings.weekly_enabled and self.weekly_fn):
|
||||
return False
|
||||
now = self.now_fn()
|
||||
if now.weekday() != 0:
|
||||
return False
|
||||
hour, minute = _parse_hhmm(self.settings.weekly_time_kst)
|
||||
due = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if not (due <= now < due + timedelta(hours=self.settings.catchup_window_hours)):
|
||||
return False
|
||||
if self.store.has_report(now.date(), kind="weekly"):
|
||||
log.info("weekly catch-up: recap for %s already sent", now.date())
|
||||
return False
|
||||
if not self._run_lock.acquire(blocking=False):
|
||||
return False
|
||||
try:
|
||||
log.info("weekly catch-up: running missed recap for %s", now.date())
|
||||
self.weekly_fn(now.date())
|
||||
except Exception: # daemon must keep going
|
||||
log.exception("weekly catch-up for %s failed", now.date())
|
||||
self._notify_error("US2KR 주간 리뷰 실패", f"monday={now.date()}")
|
||||
finally:
|
||||
self._run_lock.release()
|
||||
return True
|
||||
|
||||
def schedule_heartbeat(self) -> None:
|
||||
self.scheduler.add_job(
|
||||
self._heartbeat_job,
|
||||
@@ -224,6 +274,18 @@ class Daemon:
|
||||
except Exception:
|
||||
log.exception("premarket update failed")
|
||||
|
||||
def _weekly_job(self) -> None:
|
||||
"""Monday recap. run_weekly itself is idempotent per Monday (reports kind='weekly')."""
|
||||
monday = self.now_fn().date()
|
||||
try:
|
||||
assert self.weekly_fn is not None # scheduled only when set
|
||||
log.info("scheduled weekly recap starting for %s", monday)
|
||||
with self._run_lock:
|
||||
self.weekly_fn(monday)
|
||||
except Exception as exc: # never let a job kill the scheduler
|
||||
log.exception("weekly recap for %s failed", monday)
|
||||
self._notify_error("US2KR 주간 리뷰 실패", f"monday={monday}: {type(exc).__name__}: {exc}"[:500])
|
||||
|
||||
def _heartbeat_job(self) -> None:
|
||||
"""Hourly liveness log; also the backstop that re-arms a missing or stale briefing schedule."""
|
||||
now = self.now_fn()
|
||||
@@ -314,6 +376,7 @@ def run_daemon(settings: Settings) -> None:
|
||||
clock=clock,
|
||||
store=store,
|
||||
run_fn=lambda session: run_briefing(session, settings),
|
||||
weekly_fn=lambda monday: run_weekly(monday, settings),
|
||||
)
|
||||
daemon.install_signal_handlers()
|
||||
log.info(
|
||||
|
||||
Reference in New Issue
Block a user