"""APScheduler instance for the Journal — daily prep generation. One per-user cron job: generate today's daily prep at the configured prep_time in the user's local timezone. Replaces briefing_scheduler. Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by event_scheduler.py. """ from __future__ import annotations import asyncio import datetime import json import logging from zoneinfo import ZoneInfo from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from sqlalchemy import select from fabledassistant.models import User, async_session from fabledassistant.services.journal_prep import ensure_daily_prep_message from fabledassistant.services.settings import get_setting logger = logging.getLogger(__name__) _scheduler: BackgroundScheduler | None = None _loop: asyncio.AbstractEventLoop | None = None DEFAULT_CONFIG = { "prep_enabled": True, "prep_hour": 5, "prep_minute": 0, "day_rollover_hour": 4, "morning_end_hour": 12, "midday_end_hour": 18, "closeout_enabled": True, } async def get_journal_config(user_id: int) -> dict: """Load a user's journal_config (JSON in settings) merged with defaults.""" raw = await get_setting(user_id, "journal_config", "") config: dict = {} if raw: try: parsed = json.loads(raw) if isinstance(raw, str) else raw if isinstance(parsed, dict): config = parsed except Exception: logger.warning("Invalid journal_config for user %d; using defaults", user_id) return {**DEFAULT_CONFIG, **config} async def get_user_timezone(user_id: int) -> str: return await get_setting(user_id, "user_timezone", "UTC") or "UTC" def _resolve_tz(tz_str: str) -> ZoneInfo: try: return ZoneInfo(tz_str) except Exception: return ZoneInfo("UTC") async def _do_daily_prep(user_id: int) -> None: try: tz_str = await get_user_timezone(user_id) tz = _resolve_tz(tz_str) config = await get_journal_config(user_id) rollover = int(config.get("day_rollover_hour", 4)) now = datetime.datetime.now(tz) # Today is the day after the most recent rollover. if now.hour < rollover: today = (now - datetime.timedelta(days=1)).date() else: today = now.date() await ensure_daily_prep_message( user_id=user_id, day_date=today, user_timezone=tz_str ) except Exception: logger.exception("Daily prep failed for user %d", user_id) def _run_daily_prep_threadsafe(user_id: int) -> None: if _loop is None: return asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop) async def _do_closeout(user_id: int) -> None: try: tz_str = await get_user_timezone(user_id) tz = _resolve_tz(tz_str) now = datetime.datetime.now(tz) # We just rolled into a new day in user-local time. The day that # just ended is yesterday's calendar date regardless of whether # rollover_hour is 0 or 4 — APScheduler fires precisely at the # configured hour so no clock-skew correction is needed. yesterday = now.date() - datetime.timedelta(days=1) from fabledassistant.services.journal_closeout import run_for_user await run_for_user(user_id=user_id, yesterday=yesterday) except Exception: logger.exception("Closeout failed for user %d", user_id) def _run_closeout_threadsafe(user_id: int) -> None: if _loop is None: return asyncio.run_coroutine_threadsafe(_do_closeout(user_id), _loop) async def _closeout_catchup(user_id: int) -> None: """On startup, run yesterday's closeout once if the slot already passed and no entry for yesterday exists in observations_raw. """ try: tz_str = await get_user_timezone(user_id) tz = _resolve_tz(tz_str) config = await get_journal_config(user_id) if not config.get("closeout_enabled", True): return rollover_hour = int(config.get("day_rollover_hour", 4)) now = datetime.datetime.now(tz) # Slot hasn't passed yet today → wait for the cron. if now.hour < rollover_hour: return yesterday = (now - datetime.timedelta(days=1)).date() from fabledassistant.services.user_profile import get_profile profile = await get_profile(user_id) existing_dates = { (e or {}).get("date") for e in (profile.observations_raw or []) } if yesterday.isoformat() in existing_dates: return from fabledassistant.services.journal_closeout import run_for_user await run_for_user(user_id=user_id, yesterday=yesterday) except Exception: logger.exception("Closeout catch-up failed for user %d", user_id) async def update_user_schedule(user_id: int) -> None: """Add or replace this user's daily-prep + closeout jobs from current config.""" if _scheduler is None: return config = await get_journal_config(user_id) tz_str = await get_user_timezone(user_id) tz = _resolve_tz(tz_str) # ── Prep job ────────────────────────────────────────────────────────── prep_job_id = f"journal_prep_{user_id}" if _scheduler.get_job(prep_job_id): _scheduler.remove_job(prep_job_id) if config.get("prep_enabled", True): prep_hour = int(config.get("prep_hour", 5)) prep_minute = int(config.get("prep_minute", 0)) _scheduler.add_job( _run_daily_prep_threadsafe, trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz), args=[user_id], id=prep_job_id, replace_existing=True, ) # ── Closeout job ────────────────────────────────────────────────────── closeout_job_id = f"journal_closeout_{user_id}" if _scheduler.get_job(closeout_job_id): _scheduler.remove_job(closeout_job_id) if config.get("closeout_enabled", True): rollover_hour = int(config.get("day_rollover_hour", 4)) _scheduler.add_job( _run_closeout_threadsafe, trigger=CronTrigger(hour=rollover_hour, minute=0, timezone=tz), args=[user_id], id=closeout_job_id, replace_existing=True, ) async def _register_all_user_jobs() -> None: async with async_session() as session: users = (await session.execute(select(User))).scalars().all() for user in users: await update_user_schedule(user.id) # Fire catch-up asynchronously so a slow LLM call doesn't block startup if _loop is not None: asyncio.run_coroutine_threadsafe(_closeout_catchup(user.id), _loop) def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None: global _scheduler, _loop if _scheduler is not None: return _loop = loop _scheduler = BackgroundScheduler() _scheduler.start() asyncio.run_coroutine_threadsafe(_register_all_user_jobs(), loop) logger.info("Journal scheduler started") def stop_journal_scheduler() -> None: global _scheduler if _scheduler is not None: _scheduler.shutdown(wait=False) _scheduler = None logger.info("Journal scheduler stopped")