Files
FabledGrimoire/src/fabledassistant/services/tools/calendar.py
T
bvandeusenandClaude Opus 4.6 e4e1d1da49 fix(tz): interpret calendar and briefing dates in user's local timezone
Two related bugs where the server defaulted naive datetimes to UTC instead
of the configured user timezone, causing all-day events to land on the
previous day and briefings to "disappear" at UTC midnight.

- New services/tz.py helpers: get_user_tz, user_today, user_briefing_date
  (the briefing day flips at 4am local to align with the compilation slot,
  so the 00:00-04:00 local window still shows yesterday's briefing until
  the new one is generated).
- calendar create/list/update tools now parse naive datetimes in the
  user's TZ before converting to UTC for storage, and tool descriptions
  tell the model to pass plain local dates.
- briefing_conversations.get_or_create_today_conversation and the
  reset-today route use user_briefing_date so the in-progress briefing
  doesn't get replaced at 19:00 NY / UTC midnight.
- _run_profile_closeout targets user-local "yesterday" for consistency.

Regression tests added for the TZ helpers and the calendar tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 15:35:27 -04:00

251 lines
12 KiB
Python

"""Calendar and event tools."""
from __future__ import annotations
from datetime import datetime, timezone
from fabledassistant.services.events import (
create_event as events_create_event,
delete_event as events_delete_event,
find_events_by_query,
list_events as events_list_events,
search_events as events_search_events,
update_event as events_update_event,
)
from fabledassistant.services.tools._helpers import resolve_project
from fabledassistant.services.tools._registry import tool
from fabledassistant.services.tz import get_user_tz
async def _parse_datetime_in_user_tz(
user_id: int, value: str
) -> tuple[datetime, bool]:
"""Parse a date/datetime string from the model into a UTC-aware datetime.
Naive inputs are interpreted in the **user's local timezone** and then
converted to UTC for storage. Never default to UTC for naive inputs —
that's how all-day events landed on the wrong day for non-UTC users.
Returns ``(utc_datetime, was_date_only)``.
"""
was_date_only = "T" not in value and " " not in value
if was_date_only:
value = f"{value}T00:00:00"
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
user_tz = await get_user_tz(user_id)
dt = dt.replace(tzinfo=user_tz)
return dt.astimezone(timezone.utc), was_date_only
@tool(
name="create_event",
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event. Pass dates and datetimes in the user's local time — a bare date like '2026-09-30' is interpreted as that day in the user's configured timezone.",
parameters={
"title": {"type": "string", "description": "A descriptive event title"},
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:MM) in the user's local time. Include a timezone offset only if the user explicitly mentions one."},
"end": {"type": "string", "description": "Optional end date or datetime in the user's local time"},
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)"},
"description": {"type": "string", "description": "Optional event description"},
"location": {"type": "string", "description": "Optional event location"},
"color": {"type": "string", "description": "Optional hex color for the event (e.g. '#6366f1')"},
"all_day": {"type": "boolean", "description": "True for all-day events (birthdays, holidays, deadlines)"},
"recurrence": {"type": "string", "description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)"},
"reminder_minutes": {"type": "integer", "description": "Optional reminder N minutes before the event (e.g. 30 for 30 minutes before)"},
"attendees": {"type": "array", "items": {"type": "string"}, "description": "Optional list of attendee email addresses"},
"calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."},
"project": {"type": "string", "description": "Optional project name to associate this event with"},
},
required=["title", "start"],
)
async def create_event_tool(*, user_id, arguments, **_ctx):
start_str = arguments["start"]
end_str = arguments.get("end")
all_day = arguments.get("all_day", False)
try:
# Naive dates/datetimes are interpreted in the user's local
# timezone, not UTC. Storing UTC for naive inputs caused all-day
# events to land on the previous day for negative-offset users.
start_dt, start_was_date_only = await _parse_datetime_in_user_tz(
user_id, start_str
)
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
if start_was_date_only:
all_day = True
end_dt = None
if end_str:
try:
end_dt, _ = await _parse_datetime_in_user_tz(user_id, end_str)
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
project_id = None
project_name = arguments.get("project")
if project_name:
proj = await resolve_project(user_id, project_name)
if proj:
project_id = proj.id
event = await events_create_event(
user_id=user_id,
title=arguments.get("title", "Untitled Event"),
start_dt=start_dt,
end_dt=end_dt,
all_day=all_day,
description=arguments.get("description") or "",
location=arguments.get("location") or "",
color=arguments.get("color") or "",
recurrence=arguments.get("recurrence"),
project_id=project_id,
duration=arguments.get("duration"),
reminder_minutes=arguments.get("reminder_minutes"),
attendees=arguments.get("attendees"),
calendar_name=arguments.get("calendar_name"),
)
return {"success": True, "type": "event", "data": event.to_dict()}
@tool(
name="list_events",
description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Pass plain local dates (YYYY-MM-DD) — the server interprets them in the user's timezone and expands to a full local day.",
parameters={
"date_from": {"type": "string", "description": "Start of range as a local date (YYYY-MM-DD) or local datetime. Interpreted in the user's timezone."},
"date_to": {"type": "string", "description": "End of range as a local date (YYYY-MM-DD) or local datetime. A bare date is expanded to 23:59:59 local."},
},
required=["date_from", "date_to"],
read_only=True,
briefing=True,
)
async def list_events_tool(*, user_id, arguments, **_ctx):
# Bare local dates are expanded to a full local day in the user's TZ:
# date_from → 00:00 local, date_to → 23:59:59 local, both converted to
# UTC before the DB query. Previously the tool description told the
# model to pass UTC ranges, which missed events for non-UTC users.
try:
date_from, _ = await _parse_datetime_in_user_tz(
user_id, arguments["date_from"]
)
date_to_str = arguments["date_to"]
if "T" not in date_to_str and " " not in date_to_str:
date_to_str = f"{date_to_str}T23:59:59"
date_to, _ = await _parse_datetime_in_user_tz(user_id, date_to_str)
except (ValueError, TypeError, KeyError) as exc:
return {"success": False, "error": f"Invalid date range: {exc}"}
events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to)
return {
"success": True,
"type": "events",
"data": {"count": len(events), "events": events},
}
@tool(
name="search_events",
description="Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.",
parameters={
"query": {"type": "string", "description": "Search keyword to match against event titles, locations, and descriptions"},
"include_past": {"type": "boolean", "description": "Set to true to include past events in results (default: future events only)"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def search_events_tool(*, user_id, arguments, **_ctx):
events = await events_search_events(
user_id=user_id,
query=arguments.get("query", ""),
include_past=arguments.get("include_past", False),
)
return {
"success": True,
"type": "events",
"data": {
"query": arguments.get("query", ""),
"count": len(events),
"events": [e.to_dict() for e in events],
},
}
@tool(
name="update_event",
description="Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.",
parameters={
"query": {"type": "string", "description": "Search term to find the event to update (matches against title)"},
"title": {"type": "string", "description": "New title for the event"},
"start": {"type": "string", "description": "New start datetime in ISO 8601 format"},
"end": {"type": "string", "description": "New end datetime in ISO 8601 format"},
"all_day": {"type": "boolean", "description": "Whether the event is all-day"},
"description": {"type": "string", "description": "New event description"},
"location": {"type": "string", "description": "New event location"},
"color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"},
"recurrence": {"type": "string", "description": "New iCalendar RRULE"},
"reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."},
},
required=["query"],
)
async def update_event_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return {"success": False, "error": f"No event found matching '{query}'."}
event_to_update = matches[0]
fields: dict = {}
for str_field in ("title", "description", "location", "color", "recurrence"):
if arguments.get(str_field) is not None:
fields[str_field] = arguments[str_field]
if arguments.get("all_day") is not None:
fields["all_day"] = arguments["all_day"]
if "reminder_minutes" in arguments:
rm = arguments["reminder_minutes"]
fields["reminder_minutes"] = None if rm == 0 else rm
for dt_field, key in (("start_dt", "start"), ("end_dt", "end")):
val = arguments.get(key)
if val:
try:
# Naive datetimes are user-local, not UTC — see
# ``_parse_datetime_in_user_tz`` docstring.
dt, _ = await _parse_datetime_in_user_tz(user_id, val)
fields[dt_field] = dt
except (ValueError, TypeError):
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields)
if updated is None:
return {"success": False, "error": "Event not found or update failed."}
return {"success": True, "type": "event_updated", "data": updated.to_dict()}
@tool(
name="delete_event",
description="Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
parameters={
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title)"},
},
required=["query"],
)
async def delete_event_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return {"success": False, "error": f"No event found matching '{query}'."}
event_to_delete = matches[0]
await events_delete_event(user_id=user_id, event_id=event_to_delete.id)
return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}}
@tool(
name="list_calendars",
description="List all available calendars. Use this when the user asks which calendars they have.",
parameters={},
read_only=True,
requires="caldav",
)
async def list_calendars_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.caldav import list_calendars
calendars = await list_calendars(user_id=user_id)
return {
"success": True,
"type": "calendars",
"data": {"count": len(calendars), "calendars": calendars},
}