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:
jsahn
2026-09-12 23:52:31 +09:00
co-authored by Claude Fable 5
parent 1e6272da74
commit f5fa7ec70f
22 changed files with 995 additions and 33 deletions
+63
View File
@@ -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(