Spaces:
Runtime error
Runtime error
from agentpro.tools import Tool | |
from typing import Any, List, Tuple, Optional | |
import datetime | |
import dateutil.parser | |
import re | |
class ModifyEventTool(Tool): | |
""" | |
Tool to change the date, time, and/or duration of an existing Google Calendar event. | |
Accepts natural‐language commands such as: | |
• “Shift the first meeting on Saturday to Monday” | |
• “Shift the first meeting on Saturday to 5 AM” | |
• “Reschedule all my meetings from Saturday to Sunday.” | |
• “Reschedule the second meeting from Monday to Wednesday at 10 AM.” | |
• “Shift the first meeting on 7th June to 9th June.” | |
• “Change my first meeting on June 10, 2025, to June 12, 2025, at 2 PM.” | |
• “Reschedule all of my tomorrow meetings to Sunday.” | |
• “Move my ‘Team Sync’ meeting today from 3 PM to 4 PM.” | |
• “Change ‘Project Discussion’ meeting tomorrow to 2:30 PM.” | |
• “Reschedule my third appointment tomorrow to Friday at 11 AM.” | |
""" | |
# ── 1) Class attributes ───────────────────────────────────────────────────────────────── | |
name: str = "Modify Event" | |
description: str = ( | |
"Change the date, time, and/or duration of an existing Google Calendar event. " | |
"Supports natural‐language like 'Shift the first meeting on Saturday to 5 AM.'" | |
) | |
action_type: str = "modify_event" | |
input_format: str = ( | |
"Natural‐language command describing which event(s) to modify and how.\n" | |
"Examples:\n" | |
" • “Shift the first meeting on Saturday to Monday.”\n" | |
" • “Shift the first meeting on Saturday to 5 AM.”\n" | |
" • “Shift the first meeting on 7th June to 9th June.”\n" | |
" • “Reschedule all of my tomorrow meetings to Sunday.”\n" | |
" • “Move my ‘Team Sync’ meeting today from 3 PM to 4 PM.”\n" | |
" • “Change ‘Project Discussion’ meeting tomorrow to 2:30 PM.”\n" | |
" • “Reschedule the second meeting from Monday to Wednesday at 10 AM.”\n" | |
" • “Change my first meeting on June 10, 2025, to June 12, 2025, at 2 PM.”\n" | |
" • “Reschedule my third appointment tomorrow to Friday at 11 AM.”\n" | |
) | |
# ── 2) We expect a Google Calendar “service” to be passed in at instantiation ─────────── | |
service: Any | |
# ── 3) Main entry point ───────────────────────────────────────────────────────────────── | |
def run(self, input_text: Any) -> str: | |
""" | |
Parse a natural‐language modification command, identify target event(s), and update them. | |
Returns a human‐readable confirmation or an error message if something goes wrong. | |
""" | |
text = str(input_text).strip() | |
# 1) Split into “source” (which event(s) to modify) vs “target” (new date/time) | |
source_part, target_part = self._split_source_target(text) | |
if source_part is None or target_part is None: | |
return ( | |
"Sorry, I couldn't identify which part of your command specifies the modification. " | |
"Please use a format like “Shift the first meeting on Saturday to 5 AM.”" | |
) | |
# 2) From source_part, extract ordinal (“first”, “second”, “last”, “all”), title (if any), | |
# and source_date_spec (weekday/explicit date/today/tomorrow). | |
ordinal = self._extract_ordinal(source_part) | |
title = self._extract_title(source_part) | |
source_date_spec = self._extract_date_spec(source_part) | |
if source_date_spec is None: | |
return ( | |
"Sorry, I couldn't determine which day or date you meant. " | |
"Please specify a weekday (e.g., 'Monday'), 'today', 'tomorrow', or an explicit date (e.g., 'June 10, 2025')." | |
) | |
# 3) Resolve source_date_spec → a concrete `datetime.date` | |
source_date = self._resolve_date(source_date_spec) | |
if source_date is None: | |
return f"Sorry, I couldn’t parse the date '{source_date_spec}'." | |
# 4) Fetch all non‐all‐day events on that source_date | |
events = self._fetch_events_on_date(source_date) | |
if not events: | |
return f"You have no non‐all‐day events on {source_date.strftime('%B %d, %Y')}." | |
# 5) Select which events to modify based on ordinal/title | |
target_events = self._select_target_events(events, ordinal, title) | |
if isinstance(target_events, str): | |
# an error message string | |
return target_events | |
if not target_events: | |
return f"No events found matching that specification on {source_date.strftime('%B %d, %Y')}." | |
# 6) Parse the “target” spec to determine new_date_spec and new_time_spec | |
new_date_spec, new_time_spec = self._parse_target_part(target_part) | |
# 7) Resolve new_date_spec → `datetime.date` (if given). If omitted, keep original date. | |
new_date: Optional[datetime.date] = None | |
if new_date_spec: | |
new_date = self._resolve_date(new_date_spec) | |
if new_date is None: | |
return f"Sorry, I couldn’t parse the target date '{new_date_spec}'." | |
# 8) Resolve new_time_spec → either (new_start_time, new_end_time) or (new_start_time, None). | |
new_start_time: Optional[datetime.time] = None | |
new_end_time: Optional[datetime.time] = None | |
if new_time_spec: | |
parsed = self._resolve_time_spec(new_time_spec) | |
if parsed is None: | |
return ( | |
f"Sorry, I couldn’t parse the target time '{new_time_spec}'. " | |
f"Please specify like 'at 2 PM', 'to 4 PM', or 'from 3 PM to 5 PM'." | |
) | |
new_start_time, new_end_time = parsed | |
# 9) For each selected event, compute new start/end datetimes (preserving duration logic) | |
updates: List[Tuple[str, datetime.datetime, datetime.datetime, datetime.datetime, datetime.datetime]] = [] | |
# Each tuple: (event_summary, old_start_dt, old_end_dt, new_start_dt, new_end_dt) | |
for ev in target_events: | |
old_start_dt = datetime.datetime.fromisoformat( | |
ev["start"]["dateTime"].replace("Z", "+00:00") | |
) | |
old_end_dt = datetime.datetime.fromisoformat( | |
ev["end"]["dateTime"].replace("Z", "+00:00") | |
) | |
original_duration = old_end_dt - old_start_dt | |
# Determine which date to apply: either new_date or old_start_dt.date() | |
apply_date = new_date if new_date else old_start_dt.date() | |
# Keep the same tzinfo as the original event | |
tzinfo = old_start_dt.tzinfo | |
if new_start_time: | |
# Case A: New time is provided (could be start-only or start+end) | |
new_start_dt = datetime.datetime.combine(apply_date, new_start_time, tzinfo=tzinfo) | |
if new_end_time: | |
new_end_dt = datetime.datetime.combine(apply_date, new_end_time, tzinfo=tzinfo) | |
else: | |
# Single new_start: preserve original duration | |
new_end_dt = new_start_dt + original_duration | |
else: | |
# Case B: No new time provided → keep original start/end times but shift date if needed | |
original_start_time = old_start_dt.time() | |
original_end_time = old_end_dt.time() | |
new_start_dt = datetime.datetime.combine(apply_date, original_start_time, tzinfo=tzinfo) | |
new_end_dt = datetime.datetime.combine(apply_date, original_end_time, tzinfo=tzinfo) | |
# 10) Update the event in Google Calendar | |
_ = self._update_event( | |
event_id=ev["id"], | |
new_start_iso=new_start_dt.isoformat(), | |
new_end_iso=new_end_dt.isoformat(), | |
) | |
updates.append((ev.get("summary", "(no title)"), old_start_dt, old_end_dt, new_start_dt, new_end_dt)) | |
# 11) Return a confirmation message | |
return self._format_confirmation(updates) | |
# ──────────────────────────────────────────────────────────────────────────────────────────── | |
def _split_source_target(self, text: str) -> Tuple[Optional[str], Optional[str]]: | |
""" | |
Naively split the input_text into source_part (which events) vs target_part (new date/time). | |
We look for the first occurrence of ' to ' that is not part of a 'from X to Y' time range. | |
""" | |
lowered = text.lower() | |
# If there's a "from X to Y" time‐range, skip that " to " and find the next " to " | |
time_range_match = re.search( | |
r"\bfrom\s+\d{1,2}(:\d{2})?\s*(am|pm)?\s+to\s+\d{1,2}(:\d{2})?\s*(am|pm)?", | |
lowered | |
) | |
if time_range_match: | |
span_start, span_end = time_range_match.span() | |
next_to = lowered.find(" to ", span_end) | |
if next_to != -1: | |
before = text[:next_to] | |
after = text[next_to + len(" to "):] | |
return before.strip(), after.strip() | |
# Fallback: split on the first ' to ' | |
parts = re.split(r"\s+to\s+", text, maxsplit=1) | |
if len(parts) == 2: | |
return parts[0].strip(), parts[1].strip() | |
return None, None | |
def _extract_ordinal(self, text: str) -> Optional[str]: | |
""" | |
Extract ordinal keyword (first, second, third, ..., last, all). | |
Returns the matched keyword (lowercased) or None if none found. | |
""" | |
match = re.search( | |
r"\b(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|all)\b", | |
text, | |
re.IGNORECASE | |
) | |
return match.group(1).lower() if match else None | |
def _extract_title(self, text: str) -> Optional[str]: | |
""" | |
If the user specified an event title in quotes (single or double), | |
return that substring (without quotes). Otherwise, None. | |
""" | |
match = re.search(r"[‘'“\"]([^‘'”\"]+)[’'”\"]", text) | |
if match: | |
return match.group(1).strip() | |
return None | |
def _extract_date_spec(self, text: str) -> Optional[str]: | |
""" | |
Return the substring that indicates a source date: 'today', 'tomorrow', | |
a weekday name, or an explicit date. Otherwise, None. | |
""" | |
lowered = text.lower() | |
if "today" in lowered: | |
return "today" | |
if "tomorrow" in lowered: | |
return "tomorrow" | |
# Weekday names | |
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] | |
for wd in weekdays: | |
if re.search(rf"\b{wd}\b", lowered): | |
return wd | |
# Check if there's an explicit date phrase: must contain a month name or ISO format | |
if re.search(r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b", lowered) \ | |
or re.search(r"\b\d{4}-\d{2}-\d{2}\b", text) \ | |
or re.search(r"\b\d{1,2}(st|nd|rd|th)?\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b", lowered): | |
# We'll hand the full text to dateutil.parse later | |
return text | |
return None | |
def _resolve_date(self, spec: str) -> Optional[datetime.date]: | |
""" | |
Convert specifications like 'today', 'tomorrow', 'Saturday', | |
'7th June', 'June 10, 2025' into a datetime.date object. | |
""" | |
spec_lower = spec.strip().lower() | |
today = datetime.date.today() | |
if spec_lower == "today": | |
return today | |
if spec_lower == "tomorrow": | |
return today + datetime.timedelta(days=1) | |
# Weekday resolution: find next occurrence (this week or next) | |
weekdays_map = { | |
"monday": 0, | |
"tuesday": 1, | |
"wednesday": 2, | |
"thursday": 3, | |
"friday": 4, | |
"saturday": 5, | |
"sunday": 6, | |
} | |
if spec_lower in weekdays_map: | |
target_wd = weekdays_map[spec_lower] | |
today_wd = today.weekday() # Monday=0 ... Sunday=6 | |
if target_wd >= today_wd: | |
delta = target_wd - today_wd | |
else: | |
delta = 7 - (today_wd - target_wd) | |
return today + datetime.timedelta(days=delta) | |
# Try explicit date parsing (assume current year if omitted) | |
try: | |
parsed = dateutil.parser.parse( | |
spec, | |
fuzzy=True, | |
default=datetime.datetime(today.year, 1, 1), | |
) | |
return parsed.date() | |
except (ValueError, OverflowError): | |
return None | |
def _fetch_events_on_date(self, date_obj: datetime.date) -> List[dict]: | |
""" | |
Fetch all non‐all‐day events on the provided date (UTC midnight to next midnight). | |
Returns a list of event dicts (as returned by Google Calendar API). | |
""" | |
start_of_day = datetime.datetime.combine(date_obj, datetime.time.min).isoformat() + "Z" | |
end_of_day = (datetime.datetime.combine(date_obj, datetime.time.min) | |
+ datetime.timedelta(days=1)).isoformat() + "Z" | |
events_res = ( | |
self.service.events() | |
.list( | |
calendarId="primary", | |
timeMin=start_of_day, | |
timeMax=end_of_day, | |
singleEvents=True, | |
orderBy="startTime" | |
) | |
.execute() | |
) | |
items = events_res.get("items", []) | |
# Filter out all‐day events (those have 'start.date' instead of 'start.dateTime') | |
non_all_day = [ev for ev in items if ev.get("start", {}).get("dateTime")] | |
return non_all_day | |
def _select_target_events( | |
self, | |
events: List[dict], | |
ordinal: Optional[str], | |
title: Optional[str] | |
) -> Any: | |
""" | |
Given a list of events on the same date, choose which to modify based on: | |
- If title is provided: filter by case‐insensitive substring match. | |
• If exactly one match → return [that_event]. | |
• If multiple: | |
- If ordinal == 'first' or 'last' → pick earliest or latest among those matches. | |
- Else → return an error string prompting clarification. | |
• If no match → return []. | |
- If no title but ordinal provided: | |
- Sort all events by start time. | |
- 'first' → [earliest], 'second' → [second-earliest], 'last' → [latest], 'all' → all. | |
- If the specified ordinal index is out of range → return []. | |
- If neither title nor ordinal → return an error string asking for clarification. | |
""" | |
# Title-based selection | |
if title: | |
matches = [ | |
ev for ev in events | |
if title.lower() in (ev.get("summary", "") or "").lower() | |
] | |
if not matches: | |
return [] | |
if len(matches) == 1: | |
return matches | |
# Multiple matches and ordinal present? | |
if ordinal in ("first", "last"): | |
sorted_by_time = sorted( | |
matches, | |
key=lambda ev: datetime.datetime.fromisoformat( | |
ev["start"]["dateTime"].replace("Z", "+00:00") | |
) | |
) | |
return [sorted_by_time[0]] if ordinal == "first" else [sorted_by_time[-1]] | |
return ( | |
f"Multiple events match '{title}'. Which one did you mean? " | |
f"You can say 'first {title}' or 'last {title}'." | |
) | |
# No title; rely on ordinal | |
if ordinal: | |
sorted_all = sorted( | |
events, | |
key=lambda ev: datetime.datetime.fromisoformat( | |
ev["start"]["dateTime"].replace("Z", "+00:00") | |
) | |
) | |
if ordinal == "all": | |
return sorted_all | |
if ordinal == "first": | |
return [sorted_all[0]] if sorted_all else [] | |
if ordinal == "last": | |
return [sorted_all[-1]] if sorted_all else [] | |
ord_map = { | |
"second": 1, | |
"third": 2, | |
"fourth": 3, | |
"fifth": 4, | |
"sixth": 5, | |
"seventh": 6, | |
"eighth": 7, | |
"ninth": 8, | |
"tenth": 9 | |
} | |
if ordinal in ord_map: | |
idx = ord_map[ordinal] | |
return [sorted_all[idx]] if idx < len(sorted_all) else [] | |
return [] | |
# Neither title nor ordinal → ambiguous | |
return ( | |
"Please specify which event(s) to modify (e.g., 'first meeting', " | |
"'last appointment', 'all meetings', or include the title in quotes)." | |
) | |
def _parse_target_part(self, text: str) -> Tuple[Optional[str], Optional[str]]: | |
""" | |
Given the target_part (everything after 'to'), determine: | |
- new_date_spec (like 'Monday', 'June 12, 2025', 'Friday') OR None if no date. | |
- new_time_spec (like '5 AM', '2:30 PM', '3 PM to 4 PM') OR None if no time. | |
Strategy: | |
1) Look explicitly for date keywords first: | |
• 'today' or 'tomorrow' | |
• weekday names | |
• explicit date phrases containing a month name or ISO format (YYYY-MM-DD) | |
2) Only if one of those appears do we set new_date_spec. Otherwise, new_date_spec stays None. | |
3) Independently, look for time‐range patterns or single times ("at 5 PM", "5 AM", etc.). | |
4) Return (date_spec, time_spec). If neither is found, return (None, None). | |
""" | |
lowered = text.lower() | |
# 1) Identify new_date_spec (only if a date keyword is present) | |
new_date_spec: Optional[str] = None | |
if "today" in lowered: | |
new_date_spec = "today" | |
elif "tomorrow" in lowered: | |
new_date_spec = "tomorrow" | |
else: | |
# Weekday names | |
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] | |
for wd in weekdays: | |
if re.search(rf"\b{wd}\b", lowered): | |
new_date_spec = wd | |
break | |
# If still None, check for explicit date phrase: must contain a month name or ISO format | |
if new_date_spec is None: | |
if re.search(r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b", lowered) \ | |
or re.search(r"\b\d{4}-\d{2}-\d{2}\b", text) \ | |
or re.search(r"\b\d{1,2}(st|nd|rd|th)?\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b", lowered): | |
new_date_spec = text | |
# 2) Identify new_time_spec (if any) | |
# Look for 'from X to Y' or single time patterns like 'at X', 'X AM/PM', etc. | |
time_pattern = re.compile( | |
r"(?P<from>from\s+)?" | |
r"(?P<h1>\d{1,2}(:\d{2})?\s*(am|pm))" | |
r"\s*(to\s*(?P<h2>\d{1,2}(:\d{2})?\s*(am|pm)))?", | |
re.IGNORECASE | |
) | |
time_match = time_pattern.search(text) | |
new_time_spec: Optional[str] = time_match.group(0) if time_match else None | |
return new_date_spec, new_time_spec | |
def _resolve_time_spec(self, spec: str) -> Optional[Tuple[datetime.time, Optional[datetime.time]]]: | |
""" | |
Convert strings like '3 PM to 4 PM', 'at 2:30 PM', '2 PM', 'from 3 PM to 5 PM' | |
into (new_start_time, new_end_time) where new_end_time may be None (meaning preserve original duration). | |
""" | |
spec = spec.strip().lower() | |
# Find all time tokens in the spec | |
times = re.findall(r"(\d{1,2}(:\d{2})?\s*(am|pm))", spec, re.IGNORECASE) | |
parsed_times: List[datetime.time] = [] | |
for ttuple in times: | |
tstr = ttuple[0] | |
try: | |
dt = dateutil.parser.parse(tstr) | |
parsed_times.append(dt.time()) | |
except (ValueError, OverflowError): | |
continue | |
if not parsed_times: | |
return None | |
if len(parsed_times) == 1: | |
# Single new time: assume new start; preserve duration later | |
return parsed_times[0], None | |
# Two times: first is new_start, second is new_end | |
return parsed_times[0], parsed_times[1] | |
def _update_event(self, event_id: str, new_start_iso: str, new_end_iso: str) -> dict: | |
""" | |
Call Google Calendar API to patch the event's start and end times. | |
Returns the patched event resource. | |
""" | |
updated = ( | |
self.service.events() | |
.patch( | |
calendarId="primary", | |
eventId=event_id, | |
body={ | |
"start": {"dateTime": new_start_iso}, | |
"end": {"dateTime": new_end_iso}, | |
} | |
) | |
.execute() | |
) | |
return updated | |
def _format_confirmation( | |
self, | |
updates: List[Tuple[str, datetime.datetime, datetime.datetime, datetime.datetime, datetime.datetime]] | |
) -> str: | |
""" | |
Given a list of tuples: | |
(event_summary, old_start_dt, old_end_dt, new_start_dt, new_end_dt) | |
produce a combined, human‐readable confirmation string. | |
""" | |
lines: List[str] = [] | |
for summary, old_start, old_end, new_start, new_end in updates: | |
old_fmt = ( | |
f"{old_start.strftime('%B %d, %Y %I:%M %p').lstrip('0')}–" | |
f"{old_end.strftime('%I:%M %p').lstrip('0')}" | |
) | |
new_fmt = ( | |
f"{new_start.strftime('%B %d, %Y %I:%M %p').lstrip('0')}–" | |
f"{new_end.strftime('%I:%M %p').lstrip('0')}" | |
) | |
lines.append( | |
f"The meeting \"{summary}\" originally scheduled for {old_fmt} has been rescheduled to {new_fmt}." | |
) | |
return " ".join(lines) |