91bafb641f
Mega-commit. Strips all server-side LLM machinery now that Phase 7 has
removed the corresponding UI surfaces and the MCP HTTP endpoint is the
sole assistant interface.
Deleted (services/):
chat, generation_buffer, generation_log, generation_task, llm, tools/
(entire package), stt, tts, voice_config, voice_library, push,
journal_closeout, journal_pipeline, journal_prep, journal_scheduler,
journal_search, curator, curator_scheduler, consolidation,
tag_suggestions, research, weather, article_fetcher, pending_actions,
moments, assist, wikipedia.
Deleted (routes/):
chat, voice, push, journal, quick_capture, fable_mcp_dist.
Deleted (models/):
conversation, generation_tool_log, push_subscription,
pending_curator_action, moment, weather_cache.
Deleted (tests/):
test_generation_log, test_journal_*, test_consolidation, test_lookup_tool,
test_notes_consolidation_trigger, test_record_moment_guards,
test_research_pipeline, test_tools_*, test_tool_use_fixes,
test_voice_library, test_weather_service, test_calendar_tool_tz,
test_wikipedia.
Deleted (top-level):
fable-mcp/ (legacy standalone stdio package — wheel-build pipeline
also removed from Dockerfile).
app.py:
- blueprint registrations for the 6 deleted routes
- startup hook trimmed: no more Ollama warmup, KV-cache priming,
journal/curator schedulers, voice model loading
- shutdown hook simplified
- httpx import dropped (was for Ollama calls)
pyproject.toml:
- removed deps: pywebpush, feedparser, html2text, trafilatura
- removed [voice] extras entirely
- description updated for the MCP-first architecture
Dockerfile:
- removed faster-whisper / piper-tts install steps
- removed bundled piper voice download stage
- removed fable-mcp wheel build stage
Surviving-file edits:
- services/auth.py: drop Conversation table claim on first-user setup
- services/backup.py: drop conversation / push-subscription export+restore;
v1/v2 restore now silently skip pre-pivot conversation data
- services/notes.py: drop maybe_consolidate trigger on task done/cancelled;
drop _maybe_trigger_project_summary (LLM auto-summary)
- services/projects.py: drop generate_project_summary + backfill_project_summaries
(both LLM-driven)
- services/user_profile.py: drop append_observations / consolidate /
clear_learned_data (curator-tied) and build_profile_context
(was LLM system-prompt builder)
- services/notifications.py: stub out _fire_push_notif (was send_push_notification)
- services/event_scheduler.py: drop event-reminder push + chat-retention
cleanup job; keep CalDAV pull-sync + reminders job (in-app)
- services/diagnostics.py: _curator_busy() always False
- routes/notes.py: drop /assist, /assist/stream, /suggest-tags endpoints
- routes/tasks.py: drop /<id>/consolidate endpoint
- routes/settings.py: drop /models, KV-cache-prime-on-save, journal-schedule
timezone hook, and the SearXNG search-test endpoint; inline _is_private_url
(was in services/llm.py)
- routes/admin.py: drop /voice, /voice/reload endpoints
- routes/profile.py: drop /consolidate, /observations (GET, DELETE)
- models/__init__.py: drop the 6 dead model imports
Frontend cascade:
- stores/push.ts: deleted entirely (no callers after Phase 7)
- stores/settings.ts: drop checkVoiceStatus + voice-status state
- views/SettingsView.vue: drop Locations section + journalConfig state
(was tied to /api/journal/config); drop JournalConfig + journal/voice
api/client imports
- frontend/api/client.ts: orphaned voice/journal/profile-observation/
fable-mcp-dist exports are left as dead but harmless (call them and
they 404; type-check is clean).
Pre-existing v1 backups that contained conversations/messages still
restore — those tables are silently dropped from the import path.
Anyone pulling the new image with a populated database will need the
Phase 9 migration to drop the dead tables (coming next).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
4.4 KiB
Python
133 lines
4.4 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:
|
|
# Push delivery removed alongside the chat subsystem in Phase 8.
|
|
# Event reminders are still flagged via in-app notifications
|
|
# (see services/notifications.py).
|
|
|
|
# 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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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,
|
|
)
|
|
|
|
_scheduler.start()
|
|
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)")
|
|
|
|
|
|
def stop_event_scheduler() -> None:
|
|
global _scheduler
|
|
if _scheduler is not None:
|
|
_scheduler.shutdown(wait=False)
|
|
_scheduler = None
|
|
logger.info("Event scheduler stopped")
|