370 lines
16 KiB
Python
370 lines
16 KiB
Python
"""
|
|
Semantic analyzer for time expressions that evaluates the AST and produces datetime objects.
|
|
"""
|
|
|
|
import datetime
|
|
import calendar
|
|
from typing import Optional
|
|
|
|
from .ptime_ast import (
|
|
TimeExpressionNode, DateNode, TimeNode,
|
|
RelativeDateNode, RelativeTimeNode, WeekdayNode, NumberNode
|
|
)
|
|
from .err import TokenUnhandledException
|
|
|
|
|
|
class SemanticAnalyzer:
|
|
"""Semantic analyzer that evaluates time expression ASTs."""
|
|
|
|
def __init__(self, now: Optional[datetime.datetime] = None):
|
|
self.now = now or datetime.datetime.now()
|
|
|
|
def evaluate_number(self, node: NumberNode) -> int:
|
|
"""Evaluate a number node."""
|
|
return node.value
|
|
|
|
def evaluate_date(self, node: DateNode) -> datetime.date:
|
|
"""Evaluate a date node."""
|
|
year = self.now.year
|
|
month = 1
|
|
day = 1
|
|
|
|
if node.year is not None:
|
|
year = self.evaluate_number(node.year)
|
|
if node.month is not None:
|
|
month = self.evaluate_number(node.month)
|
|
if node.day is not None:
|
|
day = self.evaluate_number(node.day)
|
|
|
|
return datetime.date(year, month, day)
|
|
|
|
def evaluate_time(self, node: TimeNode) -> datetime.time:
|
|
"""Evaluate a time node."""
|
|
hour = 0
|
|
minute = 0
|
|
second = 0
|
|
|
|
if node.hour is not None:
|
|
hour = self.evaluate_number(node.hour)
|
|
if node.minute is not None:
|
|
minute = self.evaluate_number(node.minute)
|
|
if node.second is not None:
|
|
second = self.evaluate_number(node.second)
|
|
|
|
# Handle 24-hour vs 12-hour format
|
|
if not node.is_24hour and node.period is not None:
|
|
if node.period == "AM":
|
|
if hour == 12:
|
|
hour = 0
|
|
elif node.period == "PM":
|
|
if hour != 12 and hour <= 12:
|
|
hour += 12
|
|
|
|
# Validate time values
|
|
if not (0 <= hour <= 23):
|
|
raise TokenUnhandledException(f"Invalid hour: {hour}")
|
|
if not (0 <= minute <= 59):
|
|
raise TokenUnhandledException(f"Invalid minute: {minute}")
|
|
if not (0 <= second <= 59):
|
|
raise TokenUnhandledException(f"Invalid second: {second}")
|
|
|
|
return datetime.time(hour, minute, second)
|
|
|
|
def evaluate_relative_date(self, node: RelativeDateNode) -> datetime.timedelta:
|
|
"""Evaluate a relative date node."""
|
|
# Start with current time
|
|
result = self.now
|
|
|
|
# Special case: If weeks contains a target day (hacky way to pass target day info)
|
|
# This is for patterns like "下个月五号"
|
|
if node.weeks > 0 and node.weeks <= 31: # Valid day range
|
|
target_day = node.weeks
|
|
|
|
# Calculate the target month
|
|
if node.months != 0:
|
|
# Handle month arithmetic carefully
|
|
total_months = result.month + node.months - 1
|
|
new_year = result.year + total_months // 12
|
|
new_month = total_months % 12 + 1
|
|
|
|
# Handle day overflow (e.g., Jan 31 + 1 month = Feb 28/29)
|
|
max_day_in_target_month = calendar.monthrange(new_year, new_month)[1]
|
|
target_day = min(target_day, max_day_in_target_month)
|
|
|
|
try:
|
|
result = result.replace(year=new_year, month=new_month, day=target_day)
|
|
except ValueError:
|
|
# Handle edge cases
|
|
result = result.replace(year=new_year, month=new_month, day=max_day_in_target_month)
|
|
|
|
# Return the difference between the new date and the original date
|
|
return result - self.now
|
|
|
|
# Apply years
|
|
if node.years != 0:
|
|
# Handle year arithmetic carefully due to leap years
|
|
new_year = result.year + node.years
|
|
try:
|
|
result = result.replace(year=new_year)
|
|
except ValueError:
|
|
# Handle leap year edge case (Feb 29 -> Feb 28)
|
|
result = result.replace(year=new_year, month=2, day=28)
|
|
|
|
# Apply months
|
|
if node.months != 0:
|
|
# Check if this is a special marker for absolute month (negative offset)
|
|
if node.months < 0:
|
|
# This is an absolute month specification (e.g., from "明年五月")
|
|
absolute_month = node.months + 100
|
|
if 1 <= absolute_month <= 12:
|
|
result = result.replace(year=result.year, month=absolute_month, day=result.day)
|
|
else:
|
|
# Handle month arithmetic carefully
|
|
total_months = result.month + node.months - 1
|
|
new_year = result.year + total_months // 12
|
|
new_month = total_months % 12 + 1
|
|
|
|
# Handle day overflow (e.g., Jan 31 + 1 month = Feb 28/29)
|
|
new_day = min(result.day, calendar.monthrange(new_year, new_month)[1])
|
|
|
|
result = result.replace(year=new_year, month=new_month, day=new_day)
|
|
|
|
# Apply weeks and days
|
|
if node.weeks != 0 or node.days != 0:
|
|
delta_days = node.weeks * 7 + node.days
|
|
result = result + datetime.timedelta(days=delta_days)
|
|
|
|
return result - self.now
|
|
|
|
def evaluate_relative_time(self, node: RelativeTimeNode) -> datetime.timedelta:
|
|
"""Evaluate a relative time node."""
|
|
# Convert all values to seconds for precise calculation
|
|
total_seconds = (
|
|
node.hours * 3600 +
|
|
node.minutes * 60 +
|
|
node.seconds
|
|
)
|
|
|
|
return datetime.timedelta(seconds=total_seconds)
|
|
|
|
def evaluate_weekday(self, node: WeekdayNode) -> datetime.timedelta:
|
|
"""Evaluate a weekday node."""
|
|
current_weekday = self.now.weekday() # 0=Monday, 6=Sunday
|
|
target_weekday = node.weekday
|
|
|
|
if node.scope == "current":
|
|
delta = target_weekday - current_weekday
|
|
elif node.scope == "last":
|
|
delta = target_weekday - current_weekday - 7
|
|
elif node.scope == "next":
|
|
delta = target_weekday - current_weekday + 7
|
|
else:
|
|
delta = target_weekday - current_weekday
|
|
|
|
return datetime.timedelta(days=delta)
|
|
|
|
def infer_smart_time(self, hour: int, minute: int = 0, second: int = 0, base_time: Optional[datetime.datetime] = None) -> datetime.datetime:
|
|
"""
|
|
Smart time inference based on current time.
|
|
|
|
For example:
|
|
- If now is 14:30 and user says "3点", interpret as 15:00
|
|
- If now is 14:30 and user says "1点", interpret as next day 01:00
|
|
- If now is 8:00 and user says "3点", interpret as 15:00
|
|
- If now is 8:00 and user says "9点", interpret as 09:00
|
|
"""
|
|
# Use base_time if provided, otherwise use self.now
|
|
now = base_time if base_time is not None else self.now
|
|
|
|
# Handle 24-hour format directly (13-23)
|
|
if 13 <= hour <= 23:
|
|
candidate = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
|
if candidate <= now:
|
|
candidate += datetime.timedelta(days=1)
|
|
return candidate
|
|
|
|
# Handle 12 (noon/midnight)
|
|
if hour == 12:
|
|
# For 12 specifically, we need to be more careful
|
|
# Try noon first
|
|
noon_candidate = now.replace(hour=12, minute=minute, second=second, microsecond=0)
|
|
midnight_candidate = now.replace(hour=0, minute=minute, second=second, microsecond=0)
|
|
|
|
# Special case: If it's afternoon or evening, "十二点" likely means next day midnight
|
|
if now.hour >= 12:
|
|
result = midnight_candidate + datetime.timedelta(days=1)
|
|
return result
|
|
|
|
# If noon is in the future and closer than midnight, use it
|
|
if noon_candidate > now and (midnight_candidate <= now or noon_candidate < midnight_candidate):
|
|
return noon_candidate
|
|
# If midnight is in the future, use it
|
|
elif midnight_candidate > now:
|
|
return midnight_candidate
|
|
# Both are in the past, use the closer one
|
|
elif noon_candidate > midnight_candidate:
|
|
return noon_candidate
|
|
# Otherwise use midnight next day
|
|
else:
|
|
result = midnight_candidate + datetime.timedelta(days=1)
|
|
return result
|
|
|
|
# Handle 1-11 (12-hour format)
|
|
if 1 <= hour <= 11:
|
|
# Calculate 12-hour format candidates
|
|
pm_hour = hour + 12
|
|
pm_candidate = now.replace(hour=pm_hour, minute=minute, second=second, microsecond=0)
|
|
am_candidate = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
|
|
|
# Special case: If it's afternoon (12:00-18:00) and the hour is 1-6,
|
|
# user might mean either PM today or AM tomorrow.
|
|
# But if PM is in the future, that's more likely what they mean.
|
|
if 12 <= now.hour <= 18 and 1 <= hour <= 6:
|
|
if pm_candidate > now:
|
|
return pm_candidate
|
|
else:
|
|
# PM is in the past, so use AM tomorrow
|
|
result = am_candidate + datetime.timedelta(days=1)
|
|
return result
|
|
|
|
# Special case: If it's late evening (after 22:00) and user specifies early morning hours (1-5),
|
|
# user likely means next day early morning
|
|
if now.hour >= 22 and 1 <= hour <= 5:
|
|
result = am_candidate + datetime.timedelta(days=1)
|
|
return result
|
|
|
|
# Special case: In the morning (0-12:00)
|
|
if now.hour < 12:
|
|
# In the morning, for hours 1-11, generally prefer AM interpretation
|
|
# unless it's a very early hour that's much earlier than current time
|
|
# Only push to next day for very early hours (1-2) that are significantly earlier
|
|
if hour <= 2 and hour < now.hour and now.hour - hour >= 6:
|
|
# Very early morning hour that's significantly earlier, use next day
|
|
result = am_candidate + datetime.timedelta(days=1)
|
|
return result
|
|
else:
|
|
# For morning, generally prefer AM if it's in the future
|
|
if am_candidate > now:
|
|
return am_candidate
|
|
# If PM is in the future, use it
|
|
elif pm_candidate > now:
|
|
return pm_candidate
|
|
# Both are in the past, prefer AM if it's closer
|
|
elif am_candidate > pm_candidate:
|
|
return am_candidate
|
|
# Otherwise use PM next day
|
|
else:
|
|
result = pm_candidate + datetime.timedelta(days=1)
|
|
return result
|
|
else:
|
|
# General case: choose the one that's in the future and closer
|
|
if pm_candidate > now and (am_candidate <= now or pm_candidate < am_candidate):
|
|
return pm_candidate
|
|
elif am_candidate > now:
|
|
return am_candidate
|
|
# Both are in the past, use the closer one
|
|
elif pm_candidate > am_candidate:
|
|
return pm_candidate
|
|
# Otherwise use AM next day
|
|
else:
|
|
result = am_candidate + datetime.timedelta(days=1)
|
|
return result
|
|
|
|
# Handle 0 (midnight)
|
|
if hour == 0:
|
|
candidate = now.replace(hour=0, minute=minute, second=second, microsecond=0)
|
|
if candidate <= now:
|
|
candidate += datetime.timedelta(days=1)
|
|
return candidate
|
|
|
|
# Default case (should not happen with valid input)
|
|
candidate = now.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
|
if candidate <= now:
|
|
candidate += datetime.timedelta(days=1)
|
|
return candidate
|
|
|
|
def evaluate(self, node: TimeExpressionNode) -> datetime.datetime:
|
|
"""Evaluate a complete time expression node."""
|
|
result = self.now
|
|
|
|
# Apply relative date (should set time to 00:00:00 for dates)
|
|
if node.relative_date is not None:
|
|
delta = self.evaluate_relative_date(node.relative_date)
|
|
result = result + delta
|
|
# For relative dates like "今天", "明天", set time to 00:00:00
|
|
# But only for cases where we're dealing with days, not years/months
|
|
if (node.date is None and node.time is None and node.weekday is None and
|
|
node.relative_date.years == 0 and node.relative_date.months == 0):
|
|
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
# Apply weekday
|
|
if node.weekday is not None:
|
|
delta = self.evaluate_weekday(node.weekday)
|
|
result = result + delta
|
|
# For weekdays, set time to 00:00:00
|
|
if node.date is None and node.time is None:
|
|
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
# Apply relative time
|
|
if node.relative_time is not None:
|
|
delta = self.evaluate_relative_time(node.relative_time)
|
|
result = result + delta
|
|
|
|
# Apply absolute date
|
|
if node.date is not None:
|
|
date = self.evaluate_date(node.date)
|
|
result = result.replace(year=date.year, month=date.month, day=date.day)
|
|
# For absolute dates without time, set time to 00:00:00
|
|
if node.time is None:
|
|
result = result.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
# Apply time
|
|
if node.time is not None:
|
|
time = self.evaluate_time(node.time)
|
|
|
|
# Handle explicit period or student-friendly expressions
|
|
if node.time.is_24hour or node.time.period is not None:
|
|
# Handle explicit period
|
|
if not node.time.is_24hour and node.time.period is not None:
|
|
hour = time.hour
|
|
minute = time.minute
|
|
second = time.second
|
|
|
|
if node.time.period == "AM":
|
|
if hour == 12:
|
|
hour = 0
|
|
elif node.time.period == "PM":
|
|
# Special case: "晚上十二点" should be interpreted as next day 00:00
|
|
if hour == 12 and minute == 0 and second == 0:
|
|
# Move to next day at 00:00:00
|
|
result = result.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1)
|
|
# Skip the general replacement since we've already handled it
|
|
skip_general_replacement = True
|
|
else:
|
|
# For other PM times, convert to 24-hour format
|
|
if hour != 12 and hour <= 12:
|
|
hour += 12
|
|
|
|
# Validate hour
|
|
if not (0 <= hour <= 23):
|
|
raise TokenUnhandledException(f"Invalid hour: {hour}")
|
|
|
|
# Only do general replacement if we haven't handled it specially
|
|
if not locals().get('skip_general_replacement', False):
|
|
result = result.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
|
else:
|
|
# Already in 24-hour format
|
|
result = result.replace(hour=time.hour, minute=time.minute, second=time.second, microsecond=0)
|
|
else:
|
|
# Use smart time inference for regular times
|
|
# But if we have an explicit date, treat the time as 24-hour format
|
|
if node.date is not None or node.relative_date is not None:
|
|
# For explicit dates, treat time as 24-hour format
|
|
result = result.replace(hour=time.hour, minute=time.minute or 0, second=time.second or 0, microsecond=0)
|
|
else:
|
|
# Use smart time inference for regular times
|
|
smart_time = self.infer_smart_time(time.hour, time.minute, time.second, base_time=result)
|
|
result = smart_time
|
|
|
|
return result
|