Files
FabledScribe/src/fabledassistant/services/journal_scheduler.py
T
2026-05-12 14:36:29 -04:00

211 lines
7.4 KiB
Python

"""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")