fc6ebf81eb
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
7.4 KiB
Python
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")
|