"""RRULE Builder — Deterministic Python translation of structured recurrence JSON. The LLM outputs a simple recurrence dict (frequency, days, interval, until_date). This module translates that dict into a valid RFC 5545 RRULE string for the Google Calendar API. DESIGN PRINCIPLE: The LLM never generates RRULE strings directly. LLM hallucinations would break the API payload. Instead, the LLM outputs simple structured JSON, and this deterministic code builds the RRULE. Supported recurrence patterns: - DAILY, WEEKLY, MONTHLY, YEARLY - Specific days of week (MO, TU, WE, TH, FR, SA, SU) - Intervals (every 2 weeks, every 3 months) - End conditions: count (N occurrences) or until_date (stop date) - Ongoing (no end — Google Calendar defaults to ~2 years of instances) """ from datetime import date, datetime from zoneinfo import ZoneInfo from family_assistant.config import CHICAGO_TZ # Valid values the LLM can output VALID_FREQUENCIES = {"daily", "weekly", "monthly", "yearly"} VALID_DAYS = {"mo", "tu", "we", "th", "fr", "sa", "su"} # Map common day names to RRULE two-letter codes DAY_NAME_MAP = { "monday": "MO", "tuesday": "TU", "wednesday": "WE", "thursday": "TH", "friday": "FR", "saturday": "SA", "sunday": "SU", "mon": "MO", "tue": "TU", "tu": "TU", "wed": "WE", "we": "WE", "thu": "TH", "th": "TH", "fri": "FR", "fr": "FR", "sat": "SA", "sa": "SA", "sun": "SU", "su": "SU", } def build_rrule(recurrence): """Build an RFC 5545 RRULE string from structured recurrence JSON. Args: recurrence: Dict with the following fields (all optional except frequency): - frequency (str): "daily", "weekly", "monthly", "yearly" — REQUIRED - interval (int): 1 = every, 2 = every other, etc. Default 1. - days (list[str]): Days of week, e.g. ["tu", "th"] or ["Tuesday", "Thursday"] - count (int): Number of occurrences total. Mutually exclusive with until_date. - until_date (str): ISO date string "YYYY-MM-DD" — stop generating after this date. - by_month_day (int): Day of month for monthly recurrence (1-31). - by_set_pos (int): For "nth day of month" patterns (e.g., 2nd Thursday = by_set_pos=2). Returns: str: Valid RRULE string, e.g. "FREQ=WEEKLY;BYDAY=TU,TH;INTERVAL=2;COUNT=10" None: If recurrence is invalid or missing required fields. Raises: ValueError: If the recurrence dict contains invalid values. """ if not recurrence or not isinstance(recurrence, dict): return None frequency = recurrence.get("frequency", "").lower().strip() if frequency not in VALID_FREQUENCIES: return None parts = [f"FREQ={frequency.upper()}"] # Interval (default 1 = every week/day/etc.) interval = recurrence.get("interval", 1) if not isinstance(interval, int) or interval < 1: interval = 1 if interval > 1: parts.append(f"INTERVAL={interval}") # Days of week (BYDAY) — mainly for WEEKLY days = recurrence.get("days", []) if days: rrule_days = [] for d in days: d_lower = d.lower().strip() if d_lower in VALID_DAYS: rrule_days.append(d_lower.upper()) elif d_lower in DAY_NAME_MAP: rrule_days.append(DAY_NAME_MAP[d_lower]) # else: silently skip invalid day names if rrule_days: # Deduplicate while preserving order seen = set() unique_days = [] for d in rrule_days: if d not in seen: seen.add(d) unique_days.append(d) parts.append(f"BYDAY={','.join(unique_days)}") # Monthly: day of month by_month_day = recurrence.get("by_month_day") if by_month_day is not None: if isinstance(by_month_day, int) and 1 <= by_month_day <= 31: parts.append(f"BYMONTHDAY={by_month_day}") # Monthly: nth weekday (e.g., 2nd Thursday = BYDAY=2TH) by_set_pos = recurrence.get("by_set_pos") if by_set_pos is not None and days: if isinstance(by_set_pos, int) and 1 <= by_set_pos <= 5: # Combine set position with the day code rrule_days = [] for d in days: d_lower = d.lower().strip() if d_lower in DAY_NAME_MAP: rrule_days.append(DAY_NAME_MAP[d_lower]) elif d_lower.upper() in {"MO", "TU", "WE", "TH", "FR", "SA", "SU"}: rrule_days.append(d_lower.upper()) if rrule_days: # e.g., "2TH" for "second Thursday" parts_before = parts[:-1] if parts[-1].startswith("BYDAY") else parts byday_values = [f"{by_set_pos}{d}" for d in rrule_days] # Replace the last BYDAY if we already added one from days, # otherwise append if parts[-1].startswith("BYDAY="): parts[-1] = f"BYDAY={','.join(byday_values)}" else: parts.append(f"BYDAY={','.join(byday_values)}") # End condition: COUNT or UNTIL (mutually exclusive) count = recurrence.get("count") until_date = recurrence.get("until_date") if count and until_date: # COUNT takes precedence — safest for bounded series if isinstance(count, int) and count > 0: parts.append(f"COUNT={count}") elif count: if isinstance(count, int) and count > 0: parts.append(f"COUNT={count}") elif until_date: # Parse and format as UTC per RFC 5545 until = _parse_until_date(until_date) if until: # RFC 5545 UNTIL must be UTC and match DTSTART format # For all-day events, use DATE; for timed events, use DATETIME parts.append(f"UNTIL={until}") return ";".join(parts) def _parse_until_date(until_str): """Parse an until_date string into RFC 5545 format. Args: until_str: ISO date string "YYYY-MM-DD" or datetime string Returns: str: Formatted UNTIL value (YYYYMMDDTHHMMSSZ for timed, YYYYMMDD for all-day), or None if invalid. """ if not until_str: return None try: # Try date-only first d = date.fromisoformat(str(until_str)[:10]) # Default: end of that day in Chicago time → UTC # This ensures events on the until_date are included dt = datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=CHICAGO_TZ) return dt.strftime("%Y%m%dT%H%M%SZ") except (ValueError, TypeError): return None def validate_recurrence(recurrence): """Validate a recurrence dict from the LLM before building an RRULE. Returns a list of validation errors (empty = valid). """ errors = [] if not recurrence or not isinstance(recurrence, dict): return ["recurrence must be a dict"] frequency = recurrence.get("frequency", "").lower().strip() if not frequency: errors.append("frequency is required") elif frequency not in VALID_FREQUENCIES: errors.append(f"invalid frequency '{frequency}', must be one of: {', '.join(sorted(VALID_FREQUENCIES))}") interval = recurrence.get("interval", 1) if not isinstance(interval, int) or interval < 1: errors.append(f"interval must be a positive integer, got {interval}") days = recurrence.get("days", []) if days: for d in days: d_lower = d.lower().strip() if d_lower not in VALID_DAYS and d_lower not in DAY_NAME_MAP: errors.append(f"invalid day '{d}', must be a day name or 2-letter code") count = recurrence.get("count") if count is not None and (not isinstance(count, int) or count < 1): errors.append(f"count must be a positive integer, got {count}") until_date = recurrence.get("until_date") if until_date is not None: try: date.fromisoformat(str(until_date)[:10]) except (ValueError, TypeError): errors.append(f"invalid until_date '{until_date}', must be YYYY-MM-DD") by_month_day = recurrence.get("by_month_day") if by_month_day is not None: if not isinstance(by_month_day, int) or by_month_day < 1 or by_month_day > 31: errors.append(f"by_month_day must be 1-31, got {by_month_day}") by_set_pos = recurrence.get("by_set_pos") if by_set_pos is not None: if not isinstance(by_set_pos, int) or by_set_pos < 1 or by_set_pos > 5: errors.append(f"by_set_pos must be 1-5, got {by_set_pos}") return errors # --------------------------------------------------------------------------- # Recurrence Examples (for prompt documentation and testing) # --------------------------------------------------------------------------- EXAMPLES = { "weekly_tuesday": { "input": {"frequency": "weekly", "days": ["tu"]}, "rrule": "FREQ=WEEKLY;BYDAY=TU", }, "biweekly_tuesday_thursday": { "input": {"frequency": "weekly", "interval": 2, "days": ["tu", "th"]}, "rrule": "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH", }, "monthly_15th": { "input": {"frequency": "monthly", "by_month_day": 15}, "rrule": "FREQ=MONTHLY;BYMONTHDAY=15", }, "second_thursday_monthly": { "input": {"frequency": "monthly", "days": ["thursday"], "by_set_pos": 2}, "rrule": "FREQ=MONTHLY;BYDAY=2TH", }, "weekly_10_sessions": { "input": {"frequency": "weekly", "days": ["tu"], "count": 10}, "rrule": "FREQ=WEEKLY;BYDAY=TU;COUNT=10", }, "weekly_until_date": { "input": {"frequency": "weekly", "days": ["we"], "until_date": "2026-06-30"}, "rrule": "FREQ=WEEKLY;BYDAY=WE;UNTIL=20260630T235959Z", }, "daily_interval_3": { "input": {"frequency": "daily", "interval": 3}, "rrule": "FREQ=DAILY;INTERVAL=3", }, "yearly": { "input": {"frequency": "yearly"}, "rrule": "FREQ=YEARLY", }, }