From c39d7356ed0d07a00359107395259f1ef124a200 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 19:28:36 -0400 Subject: [PATCH] chore(dead-code): fix prod image, drop orphaned code, correct delete_rule doc Drift-audit Group 7 (renamed/removed lingers) + Group 5 #9: - docker-compose.prod.yml pulled fabledassistant:latest, a tag CI stopped publishing after the rename. Point it at fabledscribe:latest (the name CI and quickstart use). The internal DB name stays fabledassistant by design. - Remove the unused hard-delete delete_note imports from the notes and tasks route modules (they delete via trash; the import was an attractive nuisance that bypassed soft-delete). - delete_rule MCP tool: docstring/warning said 'permanently delete' but the body moves the rule to recoverable trash. Corrected to match. - Delete services/calendar_sync.py: fully orphaned (zero importers) and it read Config attrs that no longer exist, so any re-wiring would crash. - Remove dead services: notes.search_notes_for_context and logging.log_generation (zero callers; log_generation wrote a 'generation' category no stats/UI surface). Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.prod.yml | 2 +- src/fabledassistant/mcp/tools/rulebooks.py | 6 +- src/fabledassistant/routes/notes.py | 1 - src/fabledassistant/routes/tasks.py | 1 - src/fabledassistant/services/calendar_sync.py | 312 ------------------ src/fabledassistant/services/logging.py | 20 -- src/fabledassistant/services/notes.py | 31 -- 7 files changed, 4 insertions(+), 369 deletions(-) delete mode 100644 src/fabledassistant/services/calendar_sync.py diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6150a66..5a89b8e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: app: - image: git.fabledsword.com/bvandeusen/fabledassistant:latest + image: git.fabledsword.com/bvandeusen/fabledscribe:latest environment: DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant" SECRET_KEY: "${SECRET_KEY}" diff --git a/src/fabledassistant/mcp/tools/rulebooks.py b/src/fabledassistant/mcp/tools/rulebooks.py index fabb55e..d95d81a 100644 --- a/src/fabledassistant/mcp/tools/rulebooks.py +++ b/src/fabledassistant/mcp/tools/rulebooks.py @@ -322,7 +322,7 @@ async def update_rule( async def delete_rule(rule_id: int, confirmed: bool = False) -> dict: - """Permanently delete a rule. Requires confirmed=True.""" + """Move a rule to the trash (recoverable). Requires confirmed=True.""" uid = current_user_id() rule = await rulebooks_svc.get_rule(rule_id, uid) if rule is None: @@ -330,8 +330,8 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict: if not confirmed: return { "warning": ( - f"Rule {rule_id} ('{rule.title}') will be permanently deleted. " - f"Pass confirmed=True to proceed." + f"Rule {rule_id} ('{rule.title}') will be moved to the trash " + f"(recoverable via restore). Pass confirmed=True to proceed." ), "confirmed_required": True, } diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py index 7b56d4d..50a3f2b 100644 --- a/src/fabledassistant/routes/notes.py +++ b/src/fabledassistant/routes/notes.py @@ -14,7 +14,6 @@ from fabledassistant.services.notes import ( convert_note_to_task, convert_task_to_note, create_note, - delete_note, get_all_tags, get_backlinks, get_note, diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py index c008453..87c8bd0 100644 --- a/src/fabledassistant/routes/tasks.py +++ b/src/fabledassistant/routes/tasks.py @@ -10,7 +10,6 @@ from fabledassistant.services.access import can_write_note from fabledassistant.services.embeddings import upsert_note_embedding from fabledassistant.services.notes import ( create_note, - delete_note, get_note, get_note_for_user, list_notes, diff --git a/src/fabledassistant/services/calendar_sync.py b/src/fabledassistant/services/calendar_sync.py deleted file mode 100644 index 0300556..0000000 --- a/src/fabledassistant/services/calendar_sync.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Calendar sync service: bridges the DB Event table with local Radicale. - -Each user's calendars live at: http://radicale:5232/user_{user_id}/calendar/ -The app connects without auth (Radicale runs with auth.type = none inside Docker). - -External clients (iOS, macOS, Thunderbird) connect directly to Radicale on port 5232. -Their CalDAV URL is: http://:5232/user_{user_id}/calendar/ -""" - -import asyncio -import logging -import uuid -from datetime import datetime, timezone - -import httpx -import icalendar -from sqlalchemy import select - -from fabledassistant.config import Config -from fabledassistant.models import async_session -from fabledassistant.models.event import Event - -logger = logging.getLogger(__name__) - -# Internal Radicale URL (Docker service name) -_RADICALE_INTERNAL = "http://radicale:5232" - - -def _user_calendar_path(user_id: int) -> str: - return f"/user_{user_id}/calendar/" - - -def _user_calendar_url(user_id: int) -> str: - base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL - return f"{base}/user_{user_id}/calendar/" - - -async def setup_user_calendar(user_id: int) -> bool: - """Create Radicale collection for user if it doesn't exist. - - Returns True on success or if already exists, False on error. - """ - base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL - user_url = f"{base}/user_{user_id}/" - cal_url = f"{base}/user_{user_id}/calendar/" - - try: - async with httpx.AsyncClient(timeout=10.0) as client: - # Create user home collection - await client.request( - "MKCOL", - user_url, - headers={"Content-Type": "text/xml"}, - content=""" - - -""", - ) - # Create calendar collection - resp = await client.request( - "MKCOL", - cal_url, - headers={"Content-Type": "text/xml"}, - content=""" - - - - - Fabled Scribe - - -""", - ) - if resp.status_code in (201, 405): # 405 = already exists - logger.info("Calendar collection ready for user %d", user_id) - return True - logger.warning("Unexpected status %d creating calendar for user %d", resp.status_code, user_id) - return False - except Exception: - logger.warning("Failed to setup Radicale calendar for user %d", user_id, exc_info=True) - return False - - -def _make_vcalendar(event: Event) -> str: - """Build iCalendar string from DB Event.""" - cal = icalendar.Calendar() - cal.add("prodid", "-//FabledAssistant//EN") - cal.add("version", "2.0") - - vevent = icalendar.Event() - vevent.add("uid", event.uid) - vevent.add("summary", event.title) - - if event.all_day: - vevent.add("dtstart", event.start_dt.date()) - if event.end_dt: - vevent.add("dtend", event.end_dt.date()) - else: - vevent.add("dtstart", event.start_dt) - if event.end_dt: - vevent.add("dtend", event.end_dt) - - if event.description: - vevent.add("description", event.description) - if event.location: - vevent.add("location", event.location) - if event.recurrence: - rrule_parts: dict = {} - for part in event.recurrence.split(";"): - if "=" in part: - key, value = part.split("=", 1) - rrule_parts[key.strip().lower()] = value.strip() - if rrule_parts: - vevent.add("rrule", rrule_parts) - - vevent.add("dtstamp", datetime.now(timezone.utc)) - cal.add_component(vevent) - return cal.to_ical().decode("utf-8") - - -async def push_event_to_radicale(user_id: int, event: Event) -> bool: - """Write a DB Event to Radicale. Creates or updates the resource. - - Returns True on success. - """ - base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL - resource_url = f"{base}/user_{user_id}/calendar/{event.uid}.ics" - ical_data = _make_vcalendar(event) - - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.put( - resource_url, - content=ical_data.encode("utf-8"), - headers={"Content-Type": "text/calendar; charset=utf-8"}, - ) - if resp.status_code in (200, 201, 204): - return True - logger.warning("Radicale PUT returned %d for event %s", resp.status_code, event.uid) - return False - except Exception: - logger.warning("Failed to push event %s to Radicale", event.uid, exc_info=True) - return False - - -async def delete_event_from_radicale(user_id: int, uid: str) -> bool: - """Delete an event from Radicale by UID.""" - base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL - resource_url = f"{base}/user_{user_id}/calendar/{uid}.ics" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.delete(resource_url) - return resp.status_code in (200, 204, 404) # 404 is ok (already gone) - except Exception: - logger.warning("Failed to delete event %s from Radicale", uid, exc_info=True) - return False - - -async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None: - """Fetch an event from Radicale and upsert into the DB Event table. - - Returns the upserted Event or None on error. - """ - base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL - resource_url = f"{base}/user_{user_id}/calendar/{ical_uid}.ics" - - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(resource_url) - if resp.status_code == 404: - return None - resp.raise_for_status() - ical_data = resp.text - except Exception: - logger.warning("Failed to fetch event %s from Radicale", ical_uid, exc_info=True) - return None - - try: - cal = icalendar.Calendar.from_ical(ical_data) - for component in cal.walk(): - if component.name != "VEVENT": - continue - title = str(component.get("SUMMARY", "")) - dtstart = component.get("DTSTART") - dtend = component.get("DTEND") - description = str(component.get("DESCRIPTION", "")) - location = str(component.get("LOCATION", "")) - rrule = component.get("RRULE") - recurrence = rrule.to_ical().decode("utf-8") if rrule else None - - all_day = False - if dtstart and isinstance(dtstart.dt, datetime): - start_dt = dtstart.dt if dtstart.dt.tzinfo else dtstart.dt.replace(tzinfo=timezone.utc) - elif dtstart: - from datetime import date - d = dtstart.dt - start_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc) - all_day = True - else: - continue - - end_dt = None - if dtend and isinstance(dtend.dt, datetime): - end_dt = dtend.dt if dtend.dt.tzinfo else dtend.dt.replace(tzinfo=timezone.utc) - elif dtend: - from datetime import date - d = dtend.dt - end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc) - - # Storage uses duration, not end_dt. Convert iCal DTEND to a - # minute count anchored on DTSTART. Treat invalid (end <= start) - # incoming data as a point event rather than rejecting; we - # don't control external CalDAV writers. - duration_minutes = None - if end_dt is not None and end_dt > start_dt: - duration_minutes = int((end_dt - start_dt).total_seconds() // 60) - - async with async_session() as session: - result = await session.execute( - select(Event).where(Event.user_id == user_id, Event.uid == ical_uid) - ) - existing = result.scalars().first() - if existing: - existing.title = title - existing.start_dt = start_dt - existing.duration_minutes = duration_minutes - existing.all_day = all_day - existing.description = description - existing.location = location - existing.recurrence = recurrence - existing.updated_at = datetime.now(timezone.utc) - await session.commit() - await session.refresh(existing) - return existing - else: - event_obj = Event( - user_id=user_id, - uid=ical_uid, - title=title, - start_dt=start_dt, - duration_minutes=duration_minutes, - all_day=all_day, - description=description, - location=location, - recurrence=recurrence, - ) - session.add(event_obj) - await session.commit() - await session.refresh(event_obj) - return event_obj - except Exception: - logger.warning("Failed to parse/upsert Radicale event %s", ical_uid, exc_info=True) - return None - - -async def sync_all_events_from_radicale(user_id: int) -> int: - """Full sync: fetch all events from Radicale calendar and upsert into DB. - - Returns number of events synced. - """ - base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL - cal_url = f"{base}/user_{user_id}/calendar/" - - try: - async with httpx.AsyncClient(timeout=30.0) as client: - # PROPFIND to get all .ics resources - resp = await client.request( - "PROPFIND", - cal_url, - headers={"Depth": "1", "Content-Type": "text/xml"}, - content=""" -""", - ) - if resp.status_code == 404: - logger.info("No calendar found for user %d — setting up", user_id) - await setup_user_calendar(user_id) - return 0 - if resp.status_code != 207: - logger.warning("PROPFIND returned %d for user %d", resp.status_code, user_id) - return 0 - # Extract UIDs from href paths - import re - hrefs = re.findall(r"<[Dd]:[Hh]ref>([^<]+\.ics)", resp.text) - uids = [h.rstrip("/").split("/")[-1].removesuffix(".ics") for h in hrefs] - except Exception: - logger.warning("Failed to list Radicale events for user %d", user_id, exc_info=True) - return 0 - - synced = 0 - for uid in uids: - if uid: - event = await sync_event_to_db(user_id, uid) - if event: - synced += 1 - - logger.info("Synced %d events from Radicale for user %d", synced, user_id) - return synced - - -def get_external_caldav_url(user_id: int) -> str: - """Return the CalDAV URL for external clients (iOS, macOS, Thunderbird). - - Uses BASE_URL from Config with port 5232 substituted, or RADICALE_EXTERNAL_URL. - """ - external = Config.RADICALE_EXTERNAL_URL - if external: - return f"{external.rstrip('/')}/user_{user_id}/calendar/" - # Fall back to BASE_URL host with port 5232 - from urllib.parse import urlparse - parsed = urlparse(Config.BASE_URL) - host = parsed.hostname or "localhost" - return f"http://{host}:5232/user_{user_id}/calendar/" diff --git a/src/fabledassistant/services/logging.py b/src/fabledassistant/services/logging.py index 0c70f6e..df8dac3 100644 --- a/src/fabledassistant/services/logging.py +++ b/src/fabledassistant/services/logging.py @@ -59,26 +59,6 @@ async def log_usage( await session.commit() -async def log_generation( - user_id: int, - conv_id: int, - model: str, - timing: dict, -) -> None: - """Persist per-generation timing breakdown to app_logs for benchmarking.""" - async with async_session() as session: - log = AppLog( - category="generation", - user_id=user_id, - action="generation", - endpoint=f"/chat/conversations/{conv_id}", - duration_ms=timing.get("total_ms"), - details=json.dumps({"model": model, "conv_id": conv_id, **timing}), - ) - session.add(log) - await session.commit() - - async def log_error( user_id: int | None = None, username: str | None = None, diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 90ee994..042cfff 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -409,37 +409,6 @@ async def convert_task_to_note(user_id: int, note_id: int) -> Note: return note -async def search_notes_for_context( - user_id: int, - keywords: list[str], - exclude_ids: set[int] | None = None, - limit: int = 3, - project_id: int | None = None, - orphan_only: bool = False, -) -> list[Note]: - """Search notes by keywords with OR logic. Optimized for context building — no count query.""" - async with async_session() as session: - keyword_filters = [] - for kw in keywords: - escaped = kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - pattern = f"%{escaped}%" - keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern))) - - query = select(Note).where( - Note.user_id == user_id, or_(*keyword_filters), Note.deleted_at.is_(None) - ) - if orphan_only: - query = query.where(Note.project_id.is_(None)) - elif project_id is not None: - query = query.where(Note.project_id == project_id) - if exclude_ids: - query = query.where(Note.id.notin_(exclude_ids)) - query = query.order_by(Note.updated_at.desc()).limit(limit) - - result = await session.execute(query) - return list(result.scalars().all()) - - async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]: """Batch fetch notes by ID list. Returns {note_id: Note}.""" if not note_ids: