Files
FabledScribe/src/fabledassistant/services/journal_scheduler.py
T
bvandeusen d9ab538ef4 feat(journal): backend services (moments, search, prep, scheduler, pipeline)
- services/moments.py — CRUD with embedding sync + entity-link helpers
- services/journal_search.py — three-mode search (temporal / entity / semantic)
  with notes-RAG isolation; cosine-similarity helper unit-tested
- services/journal_prep.py — gathers tasks/events/weather/news/projects/
  recent-moments/open-threads into a structured prep block
- services/journal_scheduler.py — per-user APScheduler cron for daily prep,
  follows the BackgroundScheduler + threadsafe-async pattern from event_scheduler
- services/journal_pipeline.py — system prompt (persona + calibration rules)
  with last-48h ambient moments injection
- app.py: wire journal scheduler start/stop hooks
- routes/settings.py: re-add live-reschedule on timezone change (now via
  journal_scheduler.update_user_schedule)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:37:39 -04:00

141 lines
4.2 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,
}
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 update_user_schedule(user_id: int) -> None:
"""Add or replace this user's daily-prep job using their current config."""
if _scheduler is None:
return
job_id = f"journal_prep_{user_id}"
if _scheduler.get_job(job_id):
_scheduler.remove_job(job_id)
config = await get_journal_config(user_id)
if not config.get("prep_enabled", True):
return
tz_str = await get_user_timezone(user_id)
tz = _resolve_tz(tz_str)
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=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)
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")