""" Natural language date/time parser for ClearSkies Analytics Agent Converts user queries like "yesterday", "Sept 25", "between 10:00 and 12:00 on Sept 24" into datetime objects NO EXTERNAL DEPENDENCIES - works with just standard library """ from datetime import datetime, timedelta from typing import Tuple, Optional import re class DateTimeParser: """Parse natural language date/time expressions""" def __init__(self, reference_date: Optional[datetime] = None): """ Initialize parser Args: reference_date: Reference date for relative expressions (default: now) """ self.reference_date = reference_date or datetime.now() # Month name mapping self.month_map = { 'jan': 1, 'january': 1, 'feb': 2, 'february': 2, 'mar': 3, 'march': 3, 'apr': 4, 'april': 4, 'may': 5, 'jun': 6, 'june': 6, 'jul': 7, 'july': 7, 'aug': 8, 'august': 8, 'sep': 9, 'sept': 9, 'september': 9, 'oct': 10, 'october': 10, 'nov': 11, 'november': 11, 'dec': 12, 'december': 12 } def parse(self, text: str) -> Tuple[Optional[datetime], Optional[datetime]]: """ Parse natural language date/time into start and end datetime Args: text: User input text containing date/time references Returns: Tuple of (start_datetime, end_datetime) """ text = text.lower().strip() # Priority 1: Time range on specific date (most specific) result = self._try_time_range_on_date(text) if result: return result # Priority 2: Specific date patterns result = self._try_specific_patterns(text) if result: return result # Default: return None if no date found return None, None def _try_time_range_on_date(self, text: str) -> Optional[Tuple[datetime, datetime]]: """ Handle patterns like: - "between 10:00 and 12:00 on Sept 24" - "from 10:00 to 12:00 on Sept 24" - "10:00 to 12:00 on Sept 24" """ # Pattern 1: "between HH:MM and HH:MM on DATE" pattern1 = r'between\s+(\d{1,2}):(\d{2})\s+and\s+(\d{1,2}):(\d{2})\s+on\s+(.+?)$' match = re.search(pattern1, text, re.IGNORECASE) if match: start_hour = int(match.group(1)) start_min = int(match.group(2)) end_hour = int(match.group(3)) end_min = int(match.group(4)) date_str = match.group(5).strip() # Parse the date part (just the date, ignore times in date string) base_date = self._parse_date_only(date_str) if base_date: start = datetime(base_date.year, base_date.month, base_date.day, start_hour, start_min, 0, 0) end = datetime(base_date.year, base_date.month, base_date.day, end_hour, end_min, 59, 999999) return start, end # Pattern 2: "from HH:MM to HH:MM on DATE" pattern2 = r'from\s+(\d{1,2}):(\d{2})\s+to\s+(\d{1,2}):(\d{2})\s+on\s+(.+?)$' match = re.search(pattern2, text, re.IGNORECASE) if match: start_hour = int(match.group(1)) start_min = int(match.group(2)) end_hour = int(match.group(3)) end_min = int(match.group(4)) date_str = match.group(5).strip() base_date = self._parse_date_only(date_str) if base_date: start = datetime(base_date.year, base_date.month, base_date.day, start_hour, start_min, 0, 0) end = datetime(base_date.year, base_date.month, base_date.day, end_hour, end_min, 59, 999999) return start, end # Pattern 3: "HH:MM to HH:MM on DATE" (without "from") pattern3 = r'(\d{1,2}):(\d{2})\s+to\s+(\d{1,2}):(\d{2})\s+on\s+(.+?)$' match = re.search(pattern3, text, re.IGNORECASE) if match: start_hour = int(match.group(1)) start_min = int(match.group(2)) end_hour = int(match.group(3)) end_min = int(match.group(4)) date_str = match.group(5).strip() base_date = self._parse_date_only(date_str) if base_date: start = datetime(base_date.year, base_date.month, base_date.day, start_hour, start_min, 0, 0) end = datetime(base_date.year, base_date.month, base_date.day, end_hour, end_min, 59, 999999) return start, end return None def _parse_date_only(self, date_str: str) -> Optional[datetime]: """Parse a date string like 'Sept 24' or '9/24' into a datetime (time will be 00:00:00)""" # Try month name + day month_day_pattern = r'(jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)[a-z]*\s+(\d{1,2})' match = re.search(month_day_pattern, date_str, re.IGNORECASE) if match: month_str = match.group(1).lower() month = self.month_map.get(month_str) day = int(match.group(2)) year = self.reference_date.year # If the date is in the future, assume it was last year try: test_date = datetime(year, month, day) if test_date > self.reference_date: year -= 1 return datetime(year, month, day, 0, 0, 0) except ValueError: return None # Try MM/DD or M/D md_pattern = r'\b(\d{1,2})[/-](\d{1,2})\b' match = re.search(md_pattern, date_str) if match: month = int(match.group(1)) day = int(match.group(2)) year = self.reference_date.year try: return datetime(year, month, day, 0, 0, 0) except ValueError: return None # Try YYYY-MM-DD ymd_pattern = r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})' match = re.search(ymd_pattern, date_str) if match: year = int(match.group(1)) month = int(match.group(2)) day = int(match.group(3)) try: return datetime(year, month, day, 0, 0, 0) except ValueError: return None return None def _try_specific_patterns(self, text: str) -> Optional[Tuple[datetime, datetime]]: """Try to match specific common patterns""" # Month name + day (Sept 25, September 25th) month_day_pattern = r'(jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)[a-z]*\s+(\d{1,2})' match = re.search(month_day_pattern, text, re.IGNORECASE) if match: month_str = match.group(1).lower() month = self.month_map.get(month_str) day = int(match.group(2)) year = self.reference_date.year # If the date is in the future, assume it was last year try: test_date = datetime(year, month, day) if test_date > self.reference_date: year -= 1 start = datetime(year, month, day, 0, 0, 0) end = datetime(year, month, day, 23, 59, 59) return start, end except ValueError: pass # YYYY-MM-DD or YYYY/MM/DD ymd_pattern = r'(\d{4})[/-](\d{1,2})[/-](\d{1,2})' match = re.search(ymd_pattern, text) if match: try: year = int(match.group(1)) month = int(match.group(2)) day = int(match.group(3)) start = datetime(year, month, day, 0, 0, 0) end = datetime(year, month, day, 23, 59, 59) return start, end except ValueError: pass # MM/DD or M/D md_pattern = r'\b(\d{1,2})[/-](\d{1,2})\b' match = re.search(md_pattern, text) if match: try: month = int(match.group(1)) day = int(match.group(2)) year = self.reference_date.year start = datetime(year, month, day, 0, 0, 0) end = datetime(year, month, day, 23, 59, 59) return start, end except ValueError: pass # Today if 'today' in text: start = self.reference_date.replace(hour=0, minute=0, second=0, microsecond=0) end = self.reference_date.replace(hour=23, minute=59, second=59, microsecond=999999) return start, end # Yesterday if 'yesterday' in text: yesterday = self.reference_date - timedelta(days=1) start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) end = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999) return start, end # Last N hours hours_match = re.search(r'last (\d+) hour', text) if hours_match: hours = int(hours_match.group(1)) end = self.reference_date start = end - timedelta(hours=hours) return start, end # Last N days days_match = re.search(r'last (\d+) day', text) if days_match: days = int(days_match.group(1)) end = self.reference_date start = end - timedelta(days=days) return start, end # This week if 'this week' in text: start = self.reference_date - timedelta(days=self.reference_date.weekday()) start = start.replace(hour=0, minute=0, second=0, microsecond=0) end = self.reference_date return start, end # Last week if 'last week' in text: end = self.reference_date - timedelta(days=self.reference_date.weekday()) start = end - timedelta(days=7) start = start.replace(hour=0, minute=0, second=0, microsecond=0) end = end.replace(hour=23, minute=59, second=59, microsecond=999999) return start, end # This month if 'this month' in text: start = self.reference_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0) end = self.reference_date return start, end # Time range patterns: "from X to Y" range_pattern = r'from\s+(.+?)\s+to\s+(.+?)(?:\s|$)' match = re.search(range_pattern, text, re.IGNORECASE) if match: start_str = match.group(1).strip() end_str = match.group(2).strip() # Try to parse each part recursively start_result = self.parse(start_str) end_result = self.parse(end_str) if start_result[0] and end_result[1]: return start_result[0], end_result[1] return None def extract_time_window(self, text: str) -> Optional[Tuple[datetime, datetime]]: """ Extract time window from query, with helpful error messages Args: text: User query Returns: (start_time, end_time) or None if no time found """ start, end = self.parse(text) if start and end: return start, end return None # Test function if __name__ == "__main__": print("Testing DateTimeParser...") parser = DateTimeParser(reference_date=datetime(2025, 9, 26, 14, 30)) test_queries = [ "yesterday", "today", "last 3 hours", "last 5 days", "Sept 25", "September 25th", "between 10:00 and 12:00 on Sept 24", "from 10:00 to 12:00 on Sept 24", "10:00 to 12:00 on Sept 24", "from Sept 24 to Sept 25", "this week", "last week", "9/25", "2025-09-25", ] print("\nTest queries:") for query in test_queries: start, end = parser.parse(query) if start and end: print(f"✅ '{query}'") print(f" Start: {start}") print(f" End: {end}") else: print(f"❌ '{query}' - Could not parse") print()