taha-the-data-scientist commited on
Commit
465fb49
Β·
verified Β·
1 Parent(s): f34722e

Upload 4 files

Browse files
tools/current_datetime_tool.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agentpro.tools import Tool
2
+ from typing import Any
3
+ import datetime
4
+ import pytz
5
+
6
+ class CurrentDateTimeTool(Tool):
7
+ # 1) Class attributes (Pydantic fields)
8
+ name: str = "Current DateTime"
9
+ description: str = "Get the current day, time, year, and month"
10
+ action_type: str = "current_date_time_pst"
11
+ input_format: str = "Any input; this tool returns the current date/time."
12
+
13
+ def run(self, input_text: Any) -> str:
14
+ """
15
+ Ignores the input_text and returns:
16
+ β€’ Current weekday name (e.g., Monday)
17
+ β€’ Current time (HH:MM:SS) in Pakistan Standard Time
18
+ β€’ Current year (YYYY)
19
+ β€’ Current month name (e.g., June)
20
+ """
21
+
22
+ now_pkt = datetime.datetime.now()
23
+
24
+ weekday = now_pkt.strftime("%A") # e.g., "Friday"
25
+ time_str = now_pkt.strftime("%I:%M %p") # e.g., "07:45 PM"
26
+ year_str = now_pkt.strftime("%Y") # e.g., "2025"
27
+ month = now_pkt.strftime("%B") # e.g., "June"
28
+ date_str = now_pkt.strftime("%d %B %Y") # e.g., "05 June 2025"
29
+
30
+ return (
31
+ f"Day of week: {weekday}\n"
32
+ f"Current time: {time_str}\n"
33
+ f"Date: {date_str}\n"
34
+ f"Year: {year_str}\n"
35
+ f"Month: {month}"
36
+ )
tools/daily_event_summary_tool.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agentpro.tools import Tool
2
+ from typing import Any
3
+ import datetime
4
+ import dateutil.parser
5
+ import re
6
+
7
+ class DailyEventSummaryTool(Tool):
8
+ # ── 1) Class attributes (Pydantic fields) ───────────────────────────
9
+ name: str = "Daily Event Summary"
10
+ description: str = (
11
+ "Provide a narrative summary of Google Calendar events for any requested day. "
12
+ "You can ask for today, tomorrow, day after tomorrow, last day of this year, "
13
+ "a weekday of the current week (e.g., 'Wednesday'), or an explicit date like 'June 10, 2025'."
14
+ )
15
+ action_type: str = "daily_event_summary"
16
+ input_format: str = (
17
+ "Natural language calendar query. Examples:\n"
18
+ " β€’ \"What’s on my calendar today?\"\n"
19
+ " β€’ \"Show me my schedule tomorrow.\"\n"
20
+ " β€’ \"Plan for day after tomorrow.\"\n"
21
+ " β€’ \"Events on the last day of this year.\"\n"
22
+ " β€’ \"What do I have on Wednesday?\"\n"
23
+ " β€’ \"What do I have on June 10, 2025?\"\n"
24
+ )
25
+
26
+ # ── 2) We expect a Google Calendar β€œservice” to be passed in at instantiation ──
27
+ service: Any
28
+
29
+ def run(self, input_text: Any) -> str:
30
+ """
31
+ Determine which calendar day the user wants and return a single-sentence
32
+ narrative describing each event's start time, end time, and duration.
33
+
34
+ Supported queries:
35
+ - β€œtoday”
36
+ - β€œtomorrow”
37
+ - β€œday after tomorrow”
38
+ - β€œlast day of this year” (Dec 31, current year)
39
+ - any weekday of the current week (e.g., β€œMonday”, β€œFriday”)
40
+ - explicit dates (e.g., β€œJune 10, 2025” or β€œ2025-06-10”)
41
+ """
42
+ text = str(input_text).lower()
43
+
44
+ # ────────────────────────────────────────────────────────────────────
45
+ # A) Handle relative-day keywords and weekdays of this week
46
+ # ────────────────────────────────────────────────────────────────────
47
+ today_utc = datetime.datetime.utcnow().date()
48
+ target_date = None
49
+
50
+ # 1) β€œtoday”
51
+ if "today" in text:
52
+ target_date = today_utc
53
+
54
+ # 2) β€œtomorrow” (but not β€œday after tomorrow”)
55
+ elif "tomorrow" in text and "day after" not in text:
56
+ target_date = today_utc + datetime.timedelta(days=1)
57
+
58
+ # 3) β€œday after tomorrow”
59
+ elif "day after tomorrow" in text:
60
+ target_date = today_utc + datetime.timedelta(days=2)
61
+
62
+ # 4) β€œlast day of this year” or β€œlast day of the year”
63
+ elif "last day of this year" in text or "last day of the year" in text:
64
+ year = today_utc.year
65
+ target_date = datetime.date(year, 12, 31)
66
+
67
+ else:
68
+ # 5) Try to match a weekday name in the current week
69
+ weekdays = {
70
+ "monday": 1,
71
+ "tuesday": 2,
72
+ "wednesday": 3,
73
+ "thursday": 4,
74
+ "friday": 5,
75
+ "saturday": 6,
76
+ "sunday": 7
77
+ }
78
+ for name, iso_num in weekdays.items():
79
+ if name in text:
80
+ # Compute offset from today's ISO weekday to the requested one
81
+ today_iso = today_utc.isoweekday() # Monday=1 ... Sunday=7
82
+ delta_days = iso_num - today_iso
83
+ target_date = today_utc + datetime.timedelta(days=delta_days)
84
+ break
85
+
86
+ # 6) If still None, try to parse an explicit date
87
+ if target_date is None and re.search(r"\d", text):
88
+ try:
89
+ parsed_dt = dateutil.parser.parse(text, fuzzy=True)
90
+ target_date = parsed_dt.date()
91
+ except (ValueError, OverflowError):
92
+ target_date = None
93
+
94
+ # If we still don't have a date, return fallback instructions
95
+ if target_date is None:
96
+ return (
97
+ "Sorry, I couldn’t figure out which day you mean.\n"
98
+ "Please ask for:\n"
99
+ " β€’ β€œtoday”\n"
100
+ " β€’ β€œtomorrow”\n"
101
+ " β€’ β€œday after tomorrow”\n"
102
+ " β€’ β€œlast day of this year”\n"
103
+ " β€’ a weekday this week (e.g., β€œWednesday”)\n"
104
+ " β€’ or specify an explicit date (e.g., β€œJune 10, 2025”)."
105
+ )
106
+
107
+ # ────────────────────────────────────────────────────────────────────
108
+ # B) Build UTC‐based timestamps for that entire day: [00:00 β†’ next 00:00)
109
+ # ────────────────────────────────────────────────────────────────────
110
+ start_of_day = datetime.datetime.combine(target_date, datetime.time.min).isoformat() + "Z"
111
+ end_of_day = (
112
+ datetime.datetime.combine(target_date, datetime.time.min)
113
+ + datetime.timedelta(days=1)
114
+ ).isoformat() + "Z"
115
+
116
+ # ────────────────────────────────────────────────────────────────────
117
+ # C) Query Google Calendar API for events in that window
118
+ # ────────────────────────────────────────────────────────────────────
119
+ events_res = (
120
+ self.service.events()
121
+ .list(
122
+ calendarId="primary",
123
+ timeMin=start_of_day,
124
+ timeMax=end_of_day,
125
+ singleEvents=True,
126
+ orderBy="startTime"
127
+ )
128
+ .execute()
129
+ )
130
+ items = events_res.get("items", [])
131
+ if not items:
132
+ return f"You have no events scheduled for {target_date.strftime('%B %d, %Y')}."
133
+
134
+ # ────────────────────────────────────────────────────────────────────
135
+ # D) Build narrative: β€œOn {date}, first meeting is β€˜X’, which will start at {h:mm AM/PM}
136
+ # and end at {h:mm AM/PM} (Duration: {N hours M minutes}), and second meeting is ….”
137
+ # ────────────────────────────────────────────────────────────────────
138
+ ordinals = [
139
+ "first", "second", "third", "fourth", "fifth",
140
+ "sixth", "seventh", "eighth", "ninth", "tenth"
141
+ ]
142
+
143
+ narrative_parts = []
144
+ for idx, ev in enumerate(items):
145
+ start_raw = ev["start"].get("dateTime")
146
+ end_raw = ev["end"].get("dateTime")
147
+ summary = ev.get("summary", "(no title)")
148
+
149
+ if start_raw and end_raw:
150
+ # Timed event
151
+ start_dt = datetime.datetime.fromisoformat(start_raw.replace("Z", "+00:00"))
152
+ end_dt = datetime.datetime.fromisoformat(end_raw.replace("Z", "+00:00"))
153
+
154
+ # Format β€œH:MM AM/PM”
155
+ start_str = start_dt.strftime("%I:%M %p").lstrip("0")
156
+ end_str = end_dt.strftime("%I:%M %p").lstrip("0")
157
+
158
+ # Compute duration
159
+ duration_ts = end_dt - start_dt
160
+ total_seconds = int(duration_ts.total_seconds())
161
+ hours = total_seconds // 3600
162
+ minutes = (total_seconds % 3600) // 60
163
+
164
+ if hours and minutes:
165
+ duration_str = f"{hours} hours {minutes} minutes"
166
+ elif hours:
167
+ duration_str = f"{hours} hours"
168
+ else:
169
+ duration_str = f"{minutes} minutes"
170
+
171
+ ordinal = ordinals[idx] if idx < len(ordinals) else f"{idx+1}th"
172
+ part = (
173
+ f"{ordinal} meeting is β€œ{summary},” which will start at {start_str} "
174
+ f"and end at {end_str} (Duration: {duration_str})"
175
+ )
176
+ else:
177
+ # All-day event
178
+ start_date = ev["start"].get("date")
179
+ ordinal = ordinals[idx] if idx < len(ordinals) else f"{idx+1}th"
180
+ part = f"{ordinal} event is β€œ{summary},” which is an all-day event on {start_date}"
181
+
182
+ narrative_parts.append(part)
183
+
184
+ # Join all parts with β€œ, and …”
185
+ joined = ", and ".join(narrative_parts)
186
+ date_str = target_date.strftime("%B %d, %Y")
187
+
188
+ return f"On {date_str}, {joined}."
tools/modify_event_tool.py ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agentpro.tools import Tool
2
+ from typing import Any, List, Tuple, Optional
3
+ import datetime
4
+ import dateutil.parser
5
+ import re
6
+
7
+
8
+ class ModifyEventTool(Tool):
9
+ """
10
+ Tool to change the date, time, and/or duration of an existing Google Calendar event.
11
+ Accepts natural‐language commands such as:
12
+ β€’ β€œShift the first meeting on Saturday to Monday”
13
+ β€’ β€œShift the first meeting on Saturday to 5 AM”
14
+ β€’ β€œReschedule all my meetings from Saturday to Sunday.”
15
+ β€’ β€œReschedule the second meeting from Monday to Wednesday at 10 AM.”
16
+ β€’ β€œShift the first meeting on 7th June to 9th June.”
17
+ β€’ β€œChange my first meeting on June 10, 2025, to June 12, 2025, at 2 PM.”
18
+ β€’ β€œReschedule all of my tomorrow meetings to Sunday.”
19
+ β€’ β€œMove my β€˜Team Sync’ meeting today from 3 PM to 4 PM.”
20
+ β€’ β€œChange β€˜Project Discussion’ meeting tomorrow to 2:30 PM.”
21
+ β€’ β€œReschedule my third appointment tomorrow to Friday at 11 AM.”
22
+ """
23
+
24
+ # ── 1) Class attributes ─────────────────────────────────────────────────────────────────
25
+ name: str = "Modify Event"
26
+ description: str = (
27
+ "Change the date, time, and/or duration of an existing Google Calendar event. "
28
+ "Supports natural‐language like 'Shift the first meeting on Saturday to 5 AM.'"
29
+ )
30
+ action_type: str = "modify_event"
31
+ input_format: str = (
32
+ "Natural‐language command describing which event(s) to modify and how.\n"
33
+ "Examples:\n"
34
+ " β€’ β€œShift the first meeting on Saturday to Monday.”\n"
35
+ " β€’ β€œShift the first meeting on Saturday to 5 AM.”\n"
36
+ " β€’ β€œShift the first meeting on 7th June to 9th June.”\n"
37
+ " β€’ β€œReschedule all of my tomorrow meetings to Sunday.”\n"
38
+ " β€’ β€œMove my β€˜Team Sync’ meeting today from 3 PM to 4 PM.”\n"
39
+ " β€’ β€œChange β€˜Project Discussion’ meeting tomorrow to 2:30 PM.”\n"
40
+ " β€’ β€œReschedule the second meeting from Monday to Wednesday at 10 AM.”\n"
41
+ " β€’ β€œChange my first meeting on June 10, 2025, to June 12, 2025, at 2 PM.”\n"
42
+ " β€’ β€œReschedule my third appointment tomorrow to Friday at 11 AM.”\n"
43
+ )
44
+
45
+ # ── 2) We expect a Google Calendar β€œservice” to be passed in at instantiation ───────────
46
+ service: Any
47
+
48
+ # ── 3) Main entry point ─────────────────────────────────────────────────────────────────
49
+ def run(self, input_text: Any) -> str:
50
+ """
51
+ Parse a natural‐language modification command, identify target event(s), and update them.
52
+ Returns a human‐readable confirmation or an error message if something goes wrong.
53
+ """
54
+ text = str(input_text).strip()
55
+
56
+ # 1) Split into β€œsource” (which event(s) to modify) vs β€œtarget” (new date/time)
57
+ source_part, target_part = self._split_source_target(text)
58
+ if source_part is None or target_part is None:
59
+ return (
60
+ "Sorry, I couldn't identify which part of your command specifies the modification. "
61
+ "Please use a format like β€œShift the first meeting on Saturday to 5 AM.”"
62
+ )
63
+
64
+ # 2) From source_part, extract ordinal (β€œfirst”, β€œsecond”, β€œlast”, β€œall”), title (if any),
65
+ # and source_date_spec (weekday/explicit date/today/tomorrow).
66
+ ordinal = self._extract_ordinal(source_part)
67
+ title = self._extract_title(source_part)
68
+ source_date_spec = self._extract_date_spec(source_part)
69
+
70
+ if source_date_spec is None:
71
+ return (
72
+ "Sorry, I couldn't determine which day or date you meant. "
73
+ "Please specify a weekday (e.g., 'Monday'), 'today', 'tomorrow', or an explicit date (e.g., 'June 10, 2025')."
74
+ )
75
+
76
+ # 3) Resolve source_date_spec β†’ a concrete `datetime.date`
77
+ source_date = self._resolve_date(source_date_spec)
78
+ if source_date is None:
79
+ return f"Sorry, I couldn’t parse the date '{source_date_spec}'."
80
+
81
+ # 4) Fetch all non‐all‐day events on that source_date
82
+ events = self._fetch_events_on_date(source_date)
83
+ if not events:
84
+ return f"You have no non‐all‐day events on {source_date.strftime('%B %d, %Y')}."
85
+
86
+ # 5) Select which events to modify based on ordinal/title
87
+ target_events = self._select_target_events(events, ordinal, title)
88
+ if isinstance(target_events, str):
89
+ # an error message string
90
+ return target_events
91
+ if not target_events:
92
+ return f"No events found matching that specification on {source_date.strftime('%B %d, %Y')}."
93
+
94
+ # 6) Parse the β€œtarget” spec to determine new_date_spec and new_time_spec
95
+ new_date_spec, new_time_spec = self._parse_target_part(target_part)
96
+
97
+ # 7) Resolve new_date_spec β†’ `datetime.date` (if given). If omitted, keep original date.
98
+ new_date: Optional[datetime.date] = None
99
+ if new_date_spec:
100
+ new_date = self._resolve_date(new_date_spec)
101
+ if new_date is None:
102
+ return f"Sorry, I couldn’t parse the target date '{new_date_spec}'."
103
+
104
+ # 8) Resolve new_time_spec β†’ either (new_start_time, new_end_time) or (new_start_time, None).
105
+ new_start_time: Optional[datetime.time] = None
106
+ new_end_time: Optional[datetime.time] = None
107
+ if new_time_spec:
108
+ parsed = self._resolve_time_spec(new_time_spec)
109
+ if parsed is None:
110
+ return (
111
+ f"Sorry, I couldn’t parse the target time '{new_time_spec}'. "
112
+ f"Please specify like 'at 2 PM', 'to 4 PM', or 'from 3 PM to 5 PM'."
113
+ )
114
+ new_start_time, new_end_time = parsed
115
+
116
+ # 9) For each selected event, compute new start/end datetimes (preserving duration logic)
117
+ updates: List[Tuple[str, datetime.datetime, datetime.datetime, datetime.datetime, datetime.datetime]] = []
118
+ # Each tuple: (event_summary, old_start_dt, old_end_dt, new_start_dt, new_end_dt)
119
+ for ev in target_events:
120
+ old_start_dt = datetime.datetime.fromisoformat(
121
+ ev["start"]["dateTime"].replace("Z", "+00:00")
122
+ )
123
+ old_end_dt = datetime.datetime.fromisoformat(
124
+ ev["end"]["dateTime"].replace("Z", "+00:00")
125
+ )
126
+ original_duration = old_end_dt - old_start_dt
127
+
128
+ # Determine which date to apply: either new_date or old_start_dt.date()
129
+ apply_date = new_date if new_date else old_start_dt.date()
130
+
131
+ # Keep the same tzinfo as the original event
132
+ tzinfo = old_start_dt.tzinfo
133
+
134
+ if new_start_time:
135
+ # Case A: New time is provided (could be start-only or start+end)
136
+ new_start_dt = datetime.datetime.combine(apply_date, new_start_time, tzinfo=tzinfo)
137
+ if new_end_time:
138
+ new_end_dt = datetime.datetime.combine(apply_date, new_end_time, tzinfo=tzinfo)
139
+ else:
140
+ # Single new_start: preserve original duration
141
+ new_end_dt = new_start_dt + original_duration
142
+ else:
143
+ # Case B: No new time provided β†’ keep original start/end times but shift date if needed
144
+ original_start_time = old_start_dt.time()
145
+ original_end_time = old_end_dt.time()
146
+ new_start_dt = datetime.datetime.combine(apply_date, original_start_time, tzinfo=tzinfo)
147
+ new_end_dt = datetime.datetime.combine(apply_date, original_end_time, tzinfo=tzinfo)
148
+
149
+ # 10) Update the event in Google Calendar
150
+ _ = self._update_event(
151
+ event_id=ev["id"],
152
+ new_start_iso=new_start_dt.isoformat(),
153
+ new_end_iso=new_end_dt.isoformat(),
154
+ )
155
+ updates.append((ev.get("summary", "(no title)"), old_start_dt, old_end_dt, new_start_dt, new_end_dt))
156
+
157
+ # 11) Return a confirmation message
158
+ return self._format_confirmation(updates)
159
+
160
+ # ────────────────────────────────────────────────────────────────────────────────────────────
161
+
162
+ def _split_source_target(self, text: str) -> Tuple[Optional[str], Optional[str]]:
163
+ """
164
+ Naively split the input_text into source_part (which events) vs target_part (new date/time).
165
+ We look for the first occurrence of ' to ' that is not part of a 'from X to Y' time range.
166
+ """
167
+ lowered = text.lower()
168
+ # If there's a "from X to Y" time‐range, skip that " to " and find the next " to "
169
+ time_range_match = re.search(
170
+ r"\bfrom\s+\d{1,2}(:\d{2})?\s*(am|pm)?\s+to\s+\d{1,2}(:\d{2})?\s*(am|pm)?",
171
+ lowered
172
+ )
173
+ if time_range_match:
174
+ span_start, span_end = time_range_match.span()
175
+ next_to = lowered.find(" to ", span_end)
176
+ if next_to != -1:
177
+ before = text[:next_to]
178
+ after = text[next_to + len(" to "):]
179
+ return before.strip(), after.strip()
180
+
181
+ # Fallback: split on the first ' to '
182
+ parts = re.split(r"\s+to\s+", text, maxsplit=1)
183
+ if len(parts) == 2:
184
+ return parts[0].strip(), parts[1].strip()
185
+
186
+ return None, None
187
+
188
+ def _extract_ordinal(self, text: str) -> Optional[str]:
189
+ """
190
+ Extract ordinal keyword (first, second, third, ..., last, all).
191
+ Returns the matched keyword (lowercased) or None if none found.
192
+ """
193
+ match = re.search(
194
+ r"\b(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|last|all)\b",
195
+ text,
196
+ re.IGNORECASE
197
+ )
198
+ return match.group(1).lower() if match else None
199
+
200
+ def _extract_title(self, text: str) -> Optional[str]:
201
+ """
202
+ If the user specified an event title in quotes (single or double),
203
+ return that substring (without quotes). Otherwise, None.
204
+ """
205
+ match = re.search(r"[β€˜'β€œ\"]([^β€˜'”\"]+)[’'”\"]", text)
206
+ if match:
207
+ return match.group(1).strip()
208
+ return None
209
+
210
+ def _extract_date_spec(self, text: str) -> Optional[str]:
211
+ """
212
+ Return the substring that indicates a source date: 'today', 'tomorrow',
213
+ a weekday name, or an explicit date. Otherwise, None.
214
+ """
215
+ lowered = text.lower()
216
+
217
+ if "today" in lowered:
218
+ return "today"
219
+ if "tomorrow" in lowered:
220
+ return "tomorrow"
221
+
222
+ # Weekday names
223
+ weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
224
+ for wd in weekdays:
225
+ if re.search(rf"\b{wd}\b", lowered):
226
+ return wd
227
+
228
+ # Check if there's an explicit date phrase: must contain a month name or ISO format
229
+ if re.search(r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b", lowered) \
230
+ or re.search(r"\b\d{4}-\d{2}-\d{2}\b", text) \
231
+ 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):
232
+ # We'll hand the full text to dateutil.parse later
233
+ return text
234
+
235
+ return None
236
+
237
+ def _resolve_date(self, spec: str) -> Optional[datetime.date]:
238
+ """
239
+ Convert specifications like 'today', 'tomorrow', 'Saturday',
240
+ '7th June', 'June 10, 2025' into a datetime.date object.
241
+ """
242
+ spec_lower = spec.strip().lower()
243
+ today = datetime.date.today()
244
+
245
+ if spec_lower == "today":
246
+ return today
247
+ if spec_lower == "tomorrow":
248
+ return today + datetime.timedelta(days=1)
249
+
250
+ # Weekday resolution: find next occurrence (this week or next)
251
+ weekdays_map = {
252
+ "monday": 0,
253
+ "tuesday": 1,
254
+ "wednesday": 2,
255
+ "thursday": 3,
256
+ "friday": 4,
257
+ "saturday": 5,
258
+ "sunday": 6,
259
+ }
260
+ if spec_lower in weekdays_map:
261
+ target_wd = weekdays_map[spec_lower]
262
+ today_wd = today.weekday() # Monday=0 ... Sunday=6
263
+ if target_wd >= today_wd:
264
+ delta = target_wd - today_wd
265
+ else:
266
+ delta = 7 - (today_wd - target_wd)
267
+ return today + datetime.timedelta(days=delta)
268
+
269
+ # Try explicit date parsing (assume current year if omitted)
270
+ try:
271
+ parsed = dateutil.parser.parse(
272
+ spec,
273
+ fuzzy=True,
274
+ default=datetime.datetime(today.year, 1, 1),
275
+ )
276
+ return parsed.date()
277
+ except (ValueError, OverflowError):
278
+ return None
279
+
280
+ def _fetch_events_on_date(self, date_obj: datetime.date) -> List[dict]:
281
+ """
282
+ Fetch all non‐all‐day events on the provided date (UTC midnight to next midnight).
283
+ Returns a list of event dicts (as returned by Google Calendar API).
284
+ """
285
+ start_of_day = datetime.datetime.combine(date_obj, datetime.time.min).isoformat() + "Z"
286
+ end_of_day = (datetime.datetime.combine(date_obj, datetime.time.min)
287
+ + datetime.timedelta(days=1)).isoformat() + "Z"
288
+
289
+ events_res = (
290
+ self.service.events()
291
+ .list(
292
+ calendarId="primary",
293
+ timeMin=start_of_day,
294
+ timeMax=end_of_day,
295
+ singleEvents=True,
296
+ orderBy="startTime"
297
+ )
298
+ .execute()
299
+ )
300
+ items = events_res.get("items", [])
301
+ # Filter out all‐day events (those have 'start.date' instead of 'start.dateTime')
302
+ non_all_day = [ev for ev in items if ev.get("start", {}).get("dateTime")]
303
+ return non_all_day
304
+
305
+ def _select_target_events(
306
+ self,
307
+ events: List[dict],
308
+ ordinal: Optional[str],
309
+ title: Optional[str]
310
+ ) -> Any:
311
+ """
312
+ Given a list of events on the same date, choose which to modify based on:
313
+ - If title is provided: filter by case‐insensitive substring match.
314
+ β€’ If exactly one match β†’ return [that_event].
315
+ β€’ If multiple:
316
+ - If ordinal == 'first' or 'last' β†’ pick earliest or latest among those matches.
317
+ - Else β†’ return an error string prompting clarification.
318
+ β€’ If no match β†’ return [].
319
+ - If no title but ordinal provided:
320
+ - Sort all events by start time.
321
+ - 'first' β†’ [earliest], 'second' β†’ [second-earliest], 'last' β†’ [latest], 'all' β†’ all.
322
+ - If the specified ordinal index is out of range β†’ return [].
323
+ - If neither title nor ordinal β†’ return an error string asking for clarification.
324
+ """
325
+ # Title-based selection
326
+ if title:
327
+ matches = [
328
+ ev for ev in events
329
+ if title.lower() in (ev.get("summary", "") or "").lower()
330
+ ]
331
+ if not matches:
332
+ return []
333
+ if len(matches) == 1:
334
+ return matches
335
+
336
+ # Multiple matches and ordinal present?
337
+ if ordinal in ("first", "last"):
338
+ sorted_by_time = sorted(
339
+ matches,
340
+ key=lambda ev: datetime.datetime.fromisoformat(
341
+ ev["start"]["dateTime"].replace("Z", "+00:00")
342
+ )
343
+ )
344
+ return [sorted_by_time[0]] if ordinal == "first" else [sorted_by_time[-1]]
345
+
346
+ return (
347
+ f"Multiple events match '{title}'. Which one did you mean? "
348
+ f"You can say 'first {title}' or 'last {title}'."
349
+ )
350
+
351
+ # No title; rely on ordinal
352
+ if ordinal:
353
+ sorted_all = sorted(
354
+ events,
355
+ key=lambda ev: datetime.datetime.fromisoformat(
356
+ ev["start"]["dateTime"].replace("Z", "+00:00")
357
+ )
358
+ )
359
+ if ordinal == "all":
360
+ return sorted_all
361
+ if ordinal == "first":
362
+ return [sorted_all[0]] if sorted_all else []
363
+ if ordinal == "last":
364
+ return [sorted_all[-1]] if sorted_all else []
365
+ ord_map = {
366
+ "second": 1,
367
+ "third": 2,
368
+ "fourth": 3,
369
+ "fifth": 4,
370
+ "sixth": 5,
371
+ "seventh": 6,
372
+ "eighth": 7,
373
+ "ninth": 8,
374
+ "tenth": 9
375
+ }
376
+ if ordinal in ord_map:
377
+ idx = ord_map[ordinal]
378
+ return [sorted_all[idx]] if idx < len(sorted_all) else []
379
+ return []
380
+
381
+ # Neither title nor ordinal β†’ ambiguous
382
+ return (
383
+ "Please specify which event(s) to modify (e.g., 'first meeting', "
384
+ "'last appointment', 'all meetings', or include the title in quotes)."
385
+ )
386
+
387
+ def _parse_target_part(self, text: str) -> Tuple[Optional[str], Optional[str]]:
388
+ """
389
+ Given the target_part (everything after 'to'), determine:
390
+ - new_date_spec (like 'Monday', 'June 12, 2025', 'Friday') OR None if no date.
391
+ - new_time_spec (like '5 AM', '2:30 PM', '3 PM to 4 PM') OR None if no time.
392
+
393
+ Strategy:
394
+ 1) Look explicitly for date keywords first:
395
+ β€’ 'today' or 'tomorrow'
396
+ β€’ weekday names
397
+ β€’ explicit date phrases containing a month name or ISO format (YYYY-MM-DD)
398
+ 2) Only if one of those appears do we set new_date_spec. Otherwise, new_date_spec stays None.
399
+ 3) Independently, look for time‐range patterns or single times ("at 5 PM", "5 AM", etc.).
400
+ 4) Return (date_spec, time_spec). If neither is found, return (None, None).
401
+ """
402
+ lowered = text.lower()
403
+
404
+ # 1) Identify new_date_spec (only if a date keyword is present)
405
+ new_date_spec: Optional[str] = None
406
+ if "today" in lowered:
407
+ new_date_spec = "today"
408
+ elif "tomorrow" in lowered:
409
+ new_date_spec = "tomorrow"
410
+ else:
411
+ # Weekday names
412
+ weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
413
+ for wd in weekdays:
414
+ if re.search(rf"\b{wd}\b", lowered):
415
+ new_date_spec = wd
416
+ break
417
+
418
+ # If still None, check for explicit date phrase: must contain a month name or ISO format
419
+ if new_date_spec is None:
420
+ if re.search(r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\b", lowered) \
421
+ or re.search(r"\b\d{4}-\d{2}-\d{2}\b", text) \
422
+ 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):
423
+ new_date_spec = text
424
+
425
+ # 2) Identify new_time_spec (if any)
426
+ # Look for 'from X to Y' or single time patterns like 'at X', 'X AM/PM', etc.
427
+ time_pattern = re.compile(
428
+ r"(?P<from>from\s+)?"
429
+ r"(?P<h1>\d{1,2}(:\d{2})?\s*(am|pm))"
430
+ r"\s*(to\s*(?P<h2>\d{1,2}(:\d{2})?\s*(am|pm)))?",
431
+ re.IGNORECASE
432
+ )
433
+ time_match = time_pattern.search(text)
434
+ new_time_spec: Optional[str] = time_match.group(0) if time_match else None
435
+
436
+ return new_date_spec, new_time_spec
437
+
438
+ def _resolve_time_spec(self, spec: str) -> Optional[Tuple[datetime.time, Optional[datetime.time]]]:
439
+ """
440
+ Convert strings like '3 PM to 4 PM', 'at 2:30 PM', '2 PM', 'from 3 PM to 5 PM'
441
+ into (new_start_time, new_end_time) where new_end_time may be None (meaning preserve original duration).
442
+ """
443
+ spec = spec.strip().lower()
444
+ # Find all time tokens in the spec
445
+ times = re.findall(r"(\d{1,2}(:\d{2})?\s*(am|pm))", spec, re.IGNORECASE)
446
+ parsed_times: List[datetime.time] = []
447
+ for ttuple in times:
448
+ tstr = ttuple[0]
449
+ try:
450
+ dt = dateutil.parser.parse(tstr)
451
+ parsed_times.append(dt.time())
452
+ except (ValueError, OverflowError):
453
+ continue
454
+
455
+ if not parsed_times:
456
+ return None
457
+ if len(parsed_times) == 1:
458
+ # Single new time: assume new start; preserve duration later
459
+ return parsed_times[0], None
460
+ # Two times: first is new_start, second is new_end
461
+ return parsed_times[0], parsed_times[1]
462
+
463
+ def _update_event(self, event_id: str, new_start_iso: str, new_end_iso: str) -> dict:
464
+ """
465
+ Call Google Calendar API to patch the event's start and end times.
466
+ Returns the patched event resource.
467
+ """
468
+ updated = (
469
+ self.service.events()
470
+ .patch(
471
+ calendarId="primary",
472
+ eventId=event_id,
473
+ body={
474
+ "start": {"dateTime": new_start_iso},
475
+ "end": {"dateTime": new_end_iso},
476
+ }
477
+ )
478
+ .execute()
479
+ )
480
+ return updated
481
+
482
+ def _format_confirmation(
483
+ self,
484
+ updates: List[Tuple[str, datetime.datetime, datetime.datetime, datetime.datetime, datetime.datetime]]
485
+ ) -> str:
486
+ """
487
+ Given a list of tuples:
488
+ (event_summary, old_start_dt, old_end_dt, new_start_dt, new_end_dt)
489
+ produce a combined, human‐readable confirmation string.
490
+ """
491
+ lines: List[str] = []
492
+ for summary, old_start, old_end, new_start, new_end in updates:
493
+ old_fmt = (
494
+ f"{old_start.strftime('%B %d, %Y %I:%M %p').lstrip('0')}–"
495
+ f"{old_end.strftime('%I:%M %p').lstrip('0')}"
496
+ )
497
+ new_fmt = (
498
+ f"{new_start.strftime('%B %d, %Y %I:%M %p').lstrip('0')}–"
499
+ f"{new_end.strftime('%I:%M %p').lstrip('0')}"
500
+ )
501
+ lines.append(
502
+ f"The meeting \"{summary}\" originally scheduled for {old_fmt} has been rescheduled to {new_fmt}."
503
+ )
504
+ return " ".join(lines)
tools/weekly_event_summary_tool.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agentpro.tools import Tool
2
+ from typing import Any, List, Tuple, Optional
3
+ import datetime
4
+ import dateutil.parser
5
+ import re
6
+
7
+
8
+ class WeeklyEventSummaryTool(Tool):
9
+ """
10
+ Tool to provide a narrative summary of Google Calendar events for an entire week.
11
+ Users can ask for β€œthis week,” β€œnext week,” β€œlast week,” or specify a date (e.g., β€œweek of June 10, 2025”).
12
+ The tool will gather events from Monday through Sunday of the chosen week and return a day-by-day narrative.
13
+ """
14
+
15
+ # ── 1) Class attributes ─────────────────────────────────────────────────────────────────
16
+ name: str = "Weekly Event Summary"
17
+ description: str = (
18
+ "Provide a narrative summary of Google Calendar events for any requested week. "
19
+ "You can ask for:\n"
20
+ " β€’ β€œMy events this week.”\n"
21
+ " β€’ β€œShow my schedule next week.”\n"
22
+ " β€’ β€œWeekly summary for last week.”\n"
23
+ " β€’ β€œWhat do I have the week of June 10, 2025?”\n"
24
+ " β€’ β€œEvents for week of 2025-06-10.”\n"
25
+ "If no explicit week is mentioned, defaults to this week (Monday→Sunday)."
26
+ )
27
+ action_type: str = "weekly_event_summary"
28
+ input_format: str = (
29
+ "Natural-language calendar query specifying a week. Examples:\n"
30
+ " β€’ β€œWhat’s on my calendar this week?”\n"
31
+ " β€’ β€œShow me my schedule next week.”\n"
32
+ " β€’ β€œWeekly summary for last week.”\n"
33
+ " β€’ β€œEvents for the week of June 10, 2025.”\n"
34
+ " β€’ β€œWeek of 2025-06-10.”"
35
+ )
36
+
37
+ # ── 2) We expect a Google Calendar β€œservice” to be passed in at instantiation ───────────
38
+ service: Any
39
+
40
+ # ── 3) Main entry point ─────────────────────────────────────────────────────────────────
41
+ def run(self, input_text: Any) -> str:
42
+ """
43
+ Parse a natural-language weekly summary command, identify target week, and return a narrative summary.
44
+ """
45
+ text = str(input_text).strip().lower()
46
+
47
+ # 1) Determine which week the user means
48
+ week_start = self._resolve_week_start(text)
49
+ if week_start is None:
50
+ return (
51
+ "Sorry, I couldn't determine which week you meant. "
52
+ "Please ask for 'this week', 'next week', 'last week', or 'week of <date>'."
53
+ )
54
+
55
+ # 2) Build a list of dates from Monday through Sunday
56
+ dates = [week_start + datetime.timedelta(days=i) for i in range(7)]
57
+ week_end = dates[-1]
58
+
59
+ # 3) For each day in the week, fetch events and build narrative parts
60
+ narrative_parts: List[str] = []
61
+ for day in dates:
62
+ events = self._fetch_events_on_date(day)
63
+ day_str = day.strftime("%A, %B %d, %Y")
64
+ if not events:
65
+ narrative_parts.append(f"On {day_str}, you have no events scheduled.")
66
+ else:
67
+ # Build a single sentence listing each event’s start time, end time, and title
68
+ sentences: List[str] = []
69
+ for idx, ev in enumerate(events):
70
+ start_raw = ev["start"].get("dateTime")
71
+ end_raw = ev["end"].get("dateTime")
72
+ summary = ev.get("summary", "(no title)")
73
+
74
+ if start_raw and end_raw:
75
+ start_dt = datetime.datetime.fromisoformat(start_raw.replace("Z", "+00:00"))
76
+ end_dt = datetime.datetime.fromisoformat(end_raw.replace("Z", "+00:00"))
77
+ start_str = start_dt.strftime("%I:%M %p").lstrip("0")
78
+ end_str = end_dt.strftime("%I:%M %p").lstrip("0")
79
+ sentences.append(f"β€œ{summary}” from {start_str} to {end_str}")
80
+ else:
81
+ # Should not happen for non-all-day events since we filter them
82
+ sentences.append(f"β€œ{summary}” (all-day)")
83
+
84
+ # Join individual event descriptions with β€œ; ”
85
+ day_events_str = "; ".join(sentences)
86
+ narrative_parts.append(f"On {day_str}, you have: {day_events_str}.")
87
+
88
+ # 4) Combine into one multiline narrative
89
+ week_range_str = f"{week_start.strftime('%B %d, %Y')} to {week_end.strftime('%B %d, %Y')}"
90
+ header = f"Weekly summary for {week_range_str}:"
91
+ body = " ".join(narrative_parts)
92
+ return f"{header}\n\n{body}"
93
+
94
+ # ────────────────────────────────────────────────────────────────────────────────────────────
95
+
96
+ def _resolve_week_start(self, text: str) -> Optional[datetime.date]:
97
+ """
98
+ Determine the Monday (week_start) of the requested week.
99
+ Supports 'this week', 'next week', 'last week', or 'week of <date>'.
100
+ If no keyword found, defaults to this week.
101
+ """
102
+ today = datetime.date.today()
103
+ weekday = today.weekday() # Monday=0 ... Sunday=6
104
+ monday_this_week = today - datetime.timedelta(days=weekday)
105
+
106
+ # 1) Check for 'this week'
107
+ if "this week" in text:
108
+ return monday_this_week
109
+
110
+ # 2) 'next week'
111
+ if "next week" in text:
112
+ return monday_this_week + datetime.timedelta(days=7)
113
+
114
+ # 3) 'last week'
115
+ if "last week" in text:
116
+ return monday_this_week - datetime.timedelta(days=7)
117
+
118
+ # 4) 'week of <date>'
119
+ # Look for a date substring to parse
120
+ # e.g., "week of june 10, 2025" or "week of 2025-06-10"
121
+ match = re.search(
122
+ r"week\s+of\s+(.+)", text
123
+ )
124
+ if match:
125
+ date_part = match.group(1).strip()
126
+ try:
127
+ parsed = dateutil.parser.parse(date_part, fuzzy=True)
128
+ target_date = parsed.date()
129
+ # Find Monday of that week
130
+ wd = target_date.weekday()
131
+ return target_date - datetime.timedelta(days=wd)
132
+ except (ValueError, OverflowError):
133
+ return None
134
+
135
+ # 5) If no explicit keyword, default to this week
136
+ return monday_this_week
137
+
138
+ def _fetch_events_on_date(self, date_obj: datetime.date) -> List[dict]:
139
+ """
140
+ Fetch all non-all-day events on the provided date (UTC midnight β†’ next midnight).
141
+ Returns a list of event dicts (as returned by Google Calendar API), sorted by start time.
142
+ """
143
+ start_of_day = datetime.datetime.combine(date_obj, datetime.time.min).isoformat() + "Z"
144
+ end_of_day = (datetime.datetime.combine(date_obj, datetime.time.min)
145
+ + datetime.timedelta(days=1)).isoformat() + "Z"
146
+
147
+ events_res = (
148
+ self.service.events()
149
+ .list(
150
+ calendarId="primary",
151
+ timeMin=start_of_day,
152
+ timeMax=end_of_day,
153
+ singleEvents=True,
154
+ orderBy="startTime"
155
+ )
156
+ .execute()
157
+ )
158
+ items = events_res.get("items", [])
159
+ # Filter out all-day events (they have 'start.date' instead of 'start.dateTime')
160
+ non_all_day = [ev for ev in items if ev.get("start", {}).get("dateTime")]
161
+ return sorted(
162
+ non_all_day,
163
+ key=lambda ev: datetime.datetime.fromisoformat(ev["start"]["dateTime"].replace("Z", "+00:00"))
164
+ )