738245af5c
- cleanup_old_conversations now excludes briefing conversations (was silently deleting briefing history after the retention window) - list_conversations response now includes rag_project_id, matching the shape returned by the single-conversation GET endpoint - create_conversation_from_article: removed duplicate async_session import (_session2 was a copy of the same import); consolidated into one - MAX_TOOL_ROUNDS fixed from 5→6 to match the actual range(6) loop; loop updated to range(MAX_TOOL_ROUNDS) so the constant is accurate - Chat retention cleanup moved from per-request (every GET /conversations) to a daily scheduled job in event_scheduler.py; route no longer runs a DB write on every read Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
187 lines
6.5 KiB
Python
187 lines
6.5 KiB
Python
"""Scheduler jobs for background maintenance tasks.
|
|
|
|
- Reminder notifications: checks every 5 minutes for due event reminders.
|
|
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
|
|
- Chat retention cleanup: runs daily, deleting old conversations per user setting.
|
|
|
|
Uses the same BackgroundScheduler pattern as briefing_scheduler.py.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
from apscheduler.triggers.interval import IntervalTrigger
|
|
from sqlalchemy import select
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.event import Event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_scheduler: BackgroundScheduler | None = None
|
|
_loop: asyncio.AbstractEventLoop | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reminder job
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _fire_reminders() -> None:
|
|
"""Find events with reminders due in the next 5 minutes and fire push notifications."""
|
|
now = datetime.now(timezone.utc)
|
|
window_end = now + timedelta(minutes=5)
|
|
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(
|
|
Event.reminder_minutes.isnot(None),
|
|
Event.reminder_sent_at.is_(None),
|
|
Event.start_dt > now, # event hasn't started yet
|
|
# reminder fires when now >= start_dt - reminder_minutes
|
|
# i.e. start_dt <= now + reminder_minutes (approximated by window_end check)
|
|
)
|
|
)
|
|
candidates = list(result.scalars().all())
|
|
|
|
to_notify: list[Event] = []
|
|
for event in candidates:
|
|
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
|
|
if reminder_dt <= window_end:
|
|
to_notify.append(event)
|
|
|
|
if not to_notify:
|
|
return
|
|
|
|
async with async_session() as session:
|
|
for event in to_notify:
|
|
try:
|
|
from fabledassistant.services.push import send_push_notification # noqa: PLC0415
|
|
start_local = event.start_dt.strftime("%H:%M")
|
|
await send_push_notification(
|
|
user_id=event.user_id,
|
|
title=f"Reminder: {event.title}",
|
|
body=f"Starting at {start_local} UTC",
|
|
)
|
|
except Exception:
|
|
logger.warning("Failed to send reminder push for event %d", event.id, exc_info=True)
|
|
|
|
# Mark as sent regardless of push success to avoid re-firing
|
|
result = await session.execute(
|
|
select(Event).where(Event.id == event.id)
|
|
)
|
|
ev = result.scalar_one_or_none()
|
|
if ev:
|
|
ev.reminder_sent_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
|
|
|
|
def _run_reminders(loop: asyncio.AbstractEventLoop) -> None:
|
|
asyncio.run_coroutine_threadsafe(_fire_reminders(), loop)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CalDAV pull sync job
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _run_caldav_sync() -> None:
|
|
from fabledassistant.services.caldav_sync import sync_all_users # noqa: PLC0415
|
|
try:
|
|
await sync_all_users()
|
|
except Exception:
|
|
logger.warning("CalDAV pull sync job failed", exc_info=True)
|
|
|
|
|
|
def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
|
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chat retention cleanup job
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _run_chat_retention_cleanup() -> None:
|
|
"""Delete old conversations for all users according to their retention setting."""
|
|
from sqlalchemy import select as sa_select # noqa: PLC0415
|
|
|
|
from fabledassistant.models.user import User # noqa: PLC0415
|
|
from fabledassistant.services.chat import cleanup_old_conversations # noqa: PLC0415
|
|
from fabledassistant.services.settings import get_setting # noqa: PLC0415
|
|
|
|
async with async_session() as session:
|
|
result = await session.execute(sa_select(User.id))
|
|
user_ids = [row[0] for row in result.all()]
|
|
|
|
total_deleted = 0
|
|
for user_id in user_ids:
|
|
try:
|
|
retention_str = await get_setting(user_id, "chat_retention_days", "90")
|
|
try:
|
|
retention_days = int(retention_str)
|
|
except (ValueError, TypeError):
|
|
retention_days = 90
|
|
if retention_days > 0:
|
|
deleted = await cleanup_old_conversations(user_id, retention_days)
|
|
total_deleted += deleted
|
|
except Exception:
|
|
logger.warning("Chat retention cleanup failed for user %d", user_id, exc_info=True)
|
|
|
|
if total_deleted:
|
|
logger.info("Chat retention cleanup: deleted %d conversation(s)", total_deleted)
|
|
|
|
|
|
def _run_chat_retention_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
|
asyncio.run_coroutine_threadsafe(_run_chat_retention_cleanup(), loop)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
|
global _scheduler, _loop
|
|
if _scheduler is not None:
|
|
return
|
|
_loop = loop
|
|
_scheduler = BackgroundScheduler()
|
|
|
|
# Check reminders every 5 minutes
|
|
_scheduler.add_job(
|
|
_run_reminders,
|
|
trigger=IntervalTrigger(minutes=5),
|
|
args=[loop],
|
|
id="event_reminders",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# CalDAV pull sync every hour
|
|
_scheduler.add_job(
|
|
_run_caldav_sync_threadsafe,
|
|
trigger=IntervalTrigger(hours=1),
|
|
args=[loop],
|
|
id="caldav_pull_sync",
|
|
replace_existing=True,
|
|
)
|
|
|
|
# Chat retention cleanup once per day
|
|
_scheduler.add_job(
|
|
_run_chat_retention_threadsafe,
|
|
trigger=IntervalTrigger(hours=24),
|
|
args=[loop],
|
|
id="chat_retention_cleanup",
|
|
replace_existing=True,
|
|
)
|
|
|
|
_scheduler.start()
|
|
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h, chat cleanup every 24h)")
|
|
|
|
|
|
def stop_event_scheduler() -> None:
|
|
global _scheduler
|
|
if _scheduler is not None:
|
|
_scheduler.shutdown(wait=False)
|
|
_scheduler = None
|
|
logger.info("Event scheduler stopped")
|