from src.do_tool.tool_can_use.base_tool import BaseTool from src.common.logger import get_module_logger from typing import Dict, Any, Callable, Optional, List, Tuple, Literal import asyncio import dateparser from datetime import datetime, timezone, timedelta import traceback import random import inspect import re logger = get_module_logger("schedule_task_tool") # --- 定义 UTC+8 时区 --- # # 使用 datetime.timezone 创建一个固定的 UTC+8 时区对象 UTC_PLUS_8 = timezone(timedelta(hours=8), name='UTC+8') # --- 中文数字映射 --- # 用于将中文数字时间描述转换为阿拉伯数字 CHINESE_NUMERALS = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10, '十一': 11, '十二': 12, '十三': 13, '十四': 14, '十五': 15, '十六': 16, '十七': 17, '十八': 18, '十九': 19, '二十': 20, '二十一': 21, '二十二': 22, '二十三': 23, '二十四': 24, '零': 0, } # --- 任务添加结果类型 --- # 定义 add_task 方法可能返回的状态字面量 AddTaskStatus = Literal["ADDED", "OVERWRITTEN", "FAILED"] # --- 自定义时间解析器类型定义 --- # 定义自定义解析器的结构:(关键词列表, 解析函数) # 解析函数接收 (时间描述字符串, UTC+8 时区对象) -> 可选的 UTC datetime 对象 # CustomTimeParser = Tuple[List[str], Callable[[str, timezone], Optional[datetime]]] # 旧定义,不再使用 # --- 全局变量存储回调函数 --- # 用于存储任务到期时执行的异步回调函数 _task_callback: Optional[Callable[[str, Dict[str, Any]], Any]] = None # --- 辅助函数:检查未来指示词 --- def _contains_future_keyword(text: str) -> bool: """检查文本是否包含明确的未来日期/时间指示词。 用于常理判断,避免将过去的时间点误解为明天。 """ # 可以根据需要扩展这个列表 future_keywords = [ "明天", "后天", "大后天", "下周", "下月", "下年", "星期", "周", # 如果后面跟数字,通常指未来 "礼拜", "next", "tomorrow", "future", "明晚", "明早", "后晚", "后早", r"\d+\s*天后", r"\d+\s*小时后", r"\d+\s*分钟后", # 匹配 "数字+单位+后" r"\d{4}[-/年]", # 匹配明确的年份 r"\d{1,2}[-/月]", # 匹配明确的月份 ] text_lower = text.lower() for keyword in future_keywords: # 对正则表达式模式进行搜索,对普通字符串进行包含检查 try: if re.search(keyword, text_lower): return True except re.error: # 如果 keyword 不是有效的正则表达式,按普通字符串处理 if keyword in text_lower: return True # 特殊处理:如果包含 "今天" 但也包含 "早上/上午/中午/下午/晚上" 且那个时间段未过,也算 # Note: 这个检查在常理判断中更精细地处理,这里可以简化或移除 # if "今天" in text_lower or "今日" in text_lower or "today" in text_lower: # return True # 明确指定了今天,我们相信用户 return False # --- 默认任务处理函数 --- async def _default_task_handler(task_description: str, callback_info: Dict[str, Any]): """ 默认的任务处理器,在没有通过 set_task_callback 设置外部回调时使用。 仅记录日志和打印信息,表明任务已到期。 """ logger.warning("正在使用默认任务处理器执行计划任务(未设置特定回调)。") logger.info(f"[默认处理器] 任务描述: '{task_description}'") logger.info(f"[默认处理器] 任务上下文: {callback_info}") print("-" * 30) print(f"[计划任务执行 - 默认处理器]") print(f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print(f"任务: {task_description}") print(f"信息: {callback_info}") print("-" * 30) # --- 设置回调函数 (允许覆盖默认) --- def set_task_callback(callback_func: Callable[[str, Dict[str, Any]], Any]): """ 设置当任务到期时要执行的回调函数。 此函数必须是异步函数 (async def)。 允许覆盖默认的 _default_task_handler。 """ global _task_callback if not inspect.iscoroutinefunction(callback_func): raise TypeError("提供的回调函数必须是 'async def' 定义的协程函数。") _task_callback = callback_func logger.info(f"任务执行回调函数已设置为: {getattr(callback_func, '__name__', repr(callback_func))}") # --- 自定义时间处理函数 --- # # 这些函数优先于 dateparser 被调用,用于处理特定或模糊的时间描述。 def parse_a_moment(time_description: str, local_tz: timezone) -> Optional[datetime]: """解析 "一会" 等描述为 3-5 分钟后的随机时间""" delay_seconds = random.uniform(180, 300)# 个人理解,并且后续麦麦说过疑问的话,可能会自己进行二次决策,比如某次测试麦麦一会理解为半小时。。。 now_local = datetime.now(local_tz) # 使用传入的 UTC+8 时区获取当前时间 target_dt_local = now_local + timedelta(seconds=delay_seconds) target_dt_utc = target_dt_local.astimezone(timezone.utc) logger.info(f"自定义解析器 'parse_a_moment' 处理 '{time_description}',解析为随机 {delay_seconds:.2f} 秒后: {target_dt_utc.isoformat()}") return target_dt_utc def parse_day_after_tomorrow(time_description: str, local_tz: timezone) -> Optional[datetime]: """解析 "大后天" 等描述为三天后的特定时间(带时间段推断)""" now_local = datetime.now(local_tz) # 使用传入的 UTC+8 时区获取当前时间 target_date_local = (now_local + timedelta(days=3)).date() hour, minute = 9, 0 # 默认上午9点 time_description_lower = time_description.lower() # 根据时间段词语调整小时 if "早上" in time_description_lower or "上午" in time_description_lower or "morning" in time_description_lower: hour = 9 elif "中午" in time_description_lower or "noon" in time_description_lower: hour = 12 elif "下午" in time_description_lower or "afternoon" in time_description_lower: hour = 14 elif "晚上" in time_description_lower or "evening" in time_description_lower or "night" in time_description_lower: hour = 20 # 组合日期和时间时,附加传入的 UTC+8 时区 target_dt_local = datetime.combine(target_date_local, datetime.min.time().replace(hour=hour, minute=minute), tzinfo=local_tz) if target_dt_local <= now_local: logger.warning(f"计算出的目标时间 {target_dt_local.isoformat()} 早于或等于当前时间 {now_local.isoformat()}。") target_dt_utc = target_dt_local.astimezone(timezone.utc) try: # 尝试用本地 (UTC+8) 时区格式化日志 log_time_str = target_dt_local.strftime('%Y-%m-%d %H:%M:%S %z') except Exception: log_time_str = target_dt_local.isoformat() # 回退到本地 ISO 格式 logger.debug(f"自定义解析器 'parse_day_after_tomorrow' 处理 '{time_description}', 解析为: {log_time_str}") return target_dt_utc def parse_day_before_yesterday(time_description: str, local_tz: timezone) -> Optional[datetime]: """解析 "大前天" 等描述为三天前的特定时间(带时间段推断)""" now_local = datetime.now(local_tz) # 使用传入的 UTC+8 时区获取当前时间 target_date_local = (now_local - timedelta(days=3)).date() hour, minute = 9, 0 # 默认上午9点 time_description_lower = time_description.lower() # 根据时间段词语调整小时 if "早上" in time_description_lower or "上午" in time_description_lower or "morning" in time_description_lower: hour = 9 elif "中午" in time_description_lower or "noon" in time_description_lower: hour = 12 elif "下午" in time_description_lower or "afternoon" in time_description_lower: hour = 14 elif "晚上" in time_description_lower or "evening" in time_description_lower or "night" in time_description_lower: hour = 20 # 组合日期和时间时,附加传入的 UTC+8 时区 target_dt_local = datetime.combine(target_date_local, datetime.min.time().replace(hour=hour, minute=minute), tzinfo=local_tz) # 大前天必然早于当前,仅记录调试日志 if target_dt_local <= now_local: logger.debug(f"计算出的目标时间 {target_dt_local.isoformat()} (大前天) 早于当前。") target_dt_utc = target_dt_local.astimezone(timezone.utc) try: # 尝试用本地 (UTC+8) 时区格式化日志 log_time_str = target_dt_local.strftime('%Y-%m-%d %H:%M:%S %z') except Exception: log_time_str = target_dt_local.isoformat() # 回退到本地 ISO 格式 logger.debug(f"自定义解析器 'parse_day_before_yesterday' 处理 '{time_description}', 解析为: {log_time_str}") return target_dt_utc # 注意:返回的是过去的时间 def parse_specific_oclock(time_description: str, local_tz: timezone) -> Optional[datetime]: """解析 "X点" 或 "[时段]X点" 描述为下一个未来的整点时间 (支持中文和阿拉伯数字)。包含常理判断。""" now_local = datetime.now(local_tz) # 使用传入的 UTC+8 时区获取当前时间 target_hour: Optional[int] = None hour_part = None # 用于记录提取出的小时部分 (如 "八", "8") # 使用正则表达式匹配,允许可选的前缀 pattern = r"^(?:(早上|上午|中午|下午|晚上|morning|afternoon|evening|night))?([一二三四五六七八九十零]|[0-9]{1,2})[点時]$" match = re.match(pattern, time_description.lower().strip()) if not match: logger.debug(f"'parse_specific_oclock' 未匹配到模式: '{time_description}'") return None # period_part = match.group(1) # 获取时间段部分 (仍然可以获取,但不再用于 PM 推断逻辑) hour_part = match.group(2) # 获取小时部分 if hour_part in CHINESE_NUMERALS: num = CHINESE_NUMERALS[hour_part] # 支持 0 点到 24 点 (24点视为次日0点) if 0 <= num <= 24: target_hour = 0 if num == 24 else num else: try: num = int(hour_part) # 支持 0 点到 24 点 (24点视为次日0点) if 0 <= num <= 24: target_hour = 0 if num == 24 else num except ValueError: pass # 非数字,忽略 # 如果无法解析出小时,返回 None if target_hour is None: logger.warning(f"无法从 '{time_description}' 的小时部分 '{hour_part}' 解析出有效小时 (0-24)。") return None # --- 根据上下文推断 PM --- # original_target_hour = target_hour # 保存原始解析的小时 if now_local.hour >= 12 and 1 <= target_hour <= 6: # 如果当前是中午或之后,且小时是 1-6 点,则假定用户指的是 PM target_hour += 12 logger.debug(f"当前>=12点,将小时 {original_target_hour} 推断为 PM ({target_hour}) 处理") # --- 推断结束 --- # # --- 常理判断 --- # # 使用(可能调整后的)target_hour 计算今天的时间点 potential_time_today = now_local.replace(hour=target_hour, minute=0, second=0, microsecond=0) is_today_past = potential_time_today <= now_local # 检查时间今天是否已过,且原始描述中没有明确的未来指示词 if is_today_past and not _contains_future_keyword(time_description): # 检查原始描述 logger.warning(f"时间 '{time_description}' (推断/解析为 {potential_time_today.strftime('%H:%M')}) 今天已过去,且未明确指定未来日期,根据常理判断拒绝解析。") return None # --- 常理判断结束 --- # # 处理 24点/二十四点 作为次日 0点 # 注意:这里的 target_hour 可能是调整过的 (e.g., 13),但原始 hour_part 仍然是 24 或 二十四 if original_target_hour == 0 and hour_part and ('24' in hour_part or '二十四' in hour_part): potential_time_tomorrow = (now_local + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) target_dt_local = potential_time_tomorrow else: # 如果今天的时间点没过,就用今天;否则用明天的时间点 potential_time_tomorrow = (now_local + timedelta(days=1)).replace(hour=target_hour, minute=0, second=0, microsecond=0) target_dt_local = potential_time_today if not is_today_past else potential_time_tomorrow if target_dt_local is None: logger.error(f"计算下一个 '{time_description}' 时间点失败 (逻辑错误)。"); return None target_dt_utc = target_dt_local.astimezone(timezone.utc) try: # 尝试用本地 (UTC+8) 时区格式化日志 log_time_str = target_dt_local.strftime('%Y-%m-%d %H:%M:%S %z') except Exception: log_time_str = target_dt_local.isoformat() # 回退到本地 ISO 格式 logger.info(f"自定义解析器 'parse_specific_oclock' 处理 '{time_description}', 解析为: {log_time_str}") return target_dt_utc def parse_specific_oclock_half(time_description: str, local_tz: timezone) -> Optional[datetime]: """解析 "X点半" 或 "[时段]X点半" 描述为下一个未来的半点时间 (支持中文和阿拉伯数字)。包含常理判断。""" now_local = datetime.now(local_tz) # 使用传入的 UTC+8 时区获取当前时间 target_hour: Optional[int] = None hour_part = None # 用于记录提取出的小时部分 (如 "八", "8") # 使用正则表达式匹配,允许可选的前缀 match = re.match(r"^(?:早上|上午|中午|下午|晚上|morning|afternoon|evening|night)?([一二三四五六七八九十零]|[0-9]{1,2})[点時]半$", time_description.lower().strip()) if not match: logger.debug(f"'parse_specific_oclock_half' 未匹配到模式: '{time_description}'") return None hour_part = match.group(1) # The part representing the hour (e.g., "八", "8") # Convert hour part to integer (0-23 for half-hour) if hour_part in CHINESE_NUMERALS: num = CHINESE_NUMERALS[hour_part] if 0 <= num <= 23: target_hour = num else: try: num = int(hour_part) if 0 <= num <= 23: target_hour = num except ValueError: pass if target_hour is None: logger.warning(f"无法从 '{time_description}' 的小时部分 '{hour_part}' 解析出有效小时 (0-23)。") return None # --- 常理判断 --- # potential_time_today = now_local.replace(hour=target_hour, minute=30, second=0, microsecond=0) is_today_past = potential_time_today <= now_local if is_today_past and not _contains_future_keyword(time_description): # 检查原始描述 logger.warning(f"时间 '{time_description}' ({potential_time_today.strftime('%H:%M')}) 今天已过去,且未明确指定未来日期,根据常理判断拒绝解析。") return None # --- 常理判断结束 --- # potential_time_tomorrow = (now_local + timedelta(days=1)).replace(hour=target_hour, minute=30, second=0, microsecond=0) target_dt_local = potential_time_today if not is_today_past else potential_time_tomorrow target_dt_utc = target_dt_local.astimezone(timezone.utc) try: # 尝试用本地 (UTC+8) 时区格式化日志 log_time_str = target_dt_local.strftime('%Y-%m-%d %H:%M:%S %z') except Exception: log_time_str = target_dt_local.isoformat() # Fallback to local ISO logger.info(f"自定义解析器 'parse_specific_oclock_half' 处理 '{time_description}', 解析为: {log_time_str}") return target_dt_utc def parse_specific_hour_minute(time_description: str, local_tz: timezone) -> Optional[datetime]: """解析 "X点Y分" 或 "X点Y十" 等描述为下一个未来的具体时间。包含常理判断。""" now_local = datetime.now(local_tz) target_hour: Optional[int] = None target_minute: Optional[int] = None # 正则表达式尝试匹配多种格式: X点Y十(Z), X点零Y, X点Y (默认 Y < 10) # 分组: (1: 时间段)? (2: 小时) (3: 分钟-几十)? (4: 分钟-个位/零几)? pattern = r"^(?:(早上|上午|中午|下午|晚上|morning|afternoon|evening|night))?([一二三四五六七八九十零]|[0-9]{1,2})[点時]" pattern += r"(?:([一二三四五六七八九])十([一二三四五六七八九分])?|零([一二三四五六七八九])分?|([一二三四五六七八九十]|[0-5]?[0-9])分?)$" match = re.match(pattern, time_description.lower().strip()) if not match: logger.debug(f"'parse_specific_hour_minute' 未匹配到模式: '{time_description}'") return None _period_part = match.group(1) hour_part = match.group(2) minute_tens_part = match.group(3) # e.g., '四' in 四十 minute_tens_unit_part = match.group(4) # e.g., '五' in 四十五 or '分' in 四十 minute_zero_part = match.group(5) # e.g., '五' in 零五 minute_single_part = match.group(6) # e.g., '五', '15', '50' # --- 解析小时 --- # if hour_part in CHINESE_NUMERALS: num = CHINESE_NUMERALS[hour_part] if 0 <= num <= 23: target_hour = num else: try: num = int(hour_part) if 0 <= num <= 23: target_hour = num except ValueError: pass if target_hour is None: logger.warning(f"无法解析小时: {hour_part}"); return None original_target_hour = target_hour # 保存原始小时用于 PM 推断 # --- 解析分钟 --- # if minute_tens_part: # 匹配 X十Y 或 X十 tens_val = CHINESE_NUMERALS.get(minute_tens_part, 0) if tens_val > 0: target_minute = tens_val * 10 if minute_tens_unit_part and minute_tens_unit_part != '分': # X十Y (Y不是'分') unit_val = CHINESE_NUMERALS.get(minute_tens_unit_part, 0) if unit_val > 0: target_minute += unit_val # 如果是 X十 或 X十分,target_minute 已经是 tens_val * 10 else: logger.warning(f"无法解析分钟十位: {minute_tens_part}"); return None elif minute_zero_part: # 匹配 零Y zero_val = CHINESE_NUMERALS.get(minute_zero_part, 0) if 0 < zero_val < 10: target_minute = zero_val else: logger.warning(f"无法解析零几分钟: {minute_zero_part}"); return None elif minute_single_part: # 匹配 Y (个位数) 或 数字分钟 if minute_single_part in CHINESE_NUMERALS and minute_single_part != '十': # 中文个位数 num = CHINESE_NUMERALS[minute_single_part] if 0 <= num < 10: target_minute = num elif minute_single_part == '十': # 特殊处理 X点十(分) target_minute = 10 else: # 阿拉伯数字分钟 try: num = int(minute_single_part) if 0 <= num <= 59: target_minute = num except ValueError: pass # 如果无法解析出分钟,则默认为 0 if target_minute is None: target_minute = 0 if not (0 <= target_minute <= 59): logger.warning(f"解析出的分钟数无效 ({target_minute}) from '{time_description}'") return None # --- 推断 PM (同 parse_specific_oclock) --- # if now_local.hour >= 12 and 1 <= original_target_hour <= 6: target_hour += 12 logger.debug(f"当前>=12点,将小时 {original_target_hour} 推断为 PM ({target_hour}) 处理") # --- 常理判断 --- # potential_time_today = now_local.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) is_today_past = potential_time_today <= now_local if is_today_past and not _contains_future_keyword(time_description): logger.warning(f"时间 '{time_description}' (解析为 {potential_time_today.strftime('%H:%M')}) 今天已过去,且未明确指定未来日期,根据常理判断拒绝解析。") return None # --- 计算最终时间 --- # potential_time_tomorrow = (now_local + timedelta(days=1)).replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) target_dt_local = potential_time_today if not is_today_past else potential_time_tomorrow target_dt_utc = target_dt_local.astimezone(timezone.utc) try: log_time_str = target_dt_local.strftime('%Y-%m-%d %H:%M:%S %z') except Exception: log_time_str = target_dt_local.isoformat() logger.info(f"自定义解析器 'parse_specific_hour_minute' 处理 '{time_description}', 解析为: {log_time_str}") return target_dt_utc # --- 自定义时间解析器配置 (新) --- # # 使用正则表达式来匹配,并关联到处理函数 CUSTOM_TIME_PARSER_CONFIG = { "a_moment": { # 精确匹配模糊时间词语 "regex": r"^(一会|一会儿|一会会|a moment|in a moment|in a bit)$", "func": parse_a_moment }, "day_after_tomorrow": { # 匹配 "大后天" 开头,允许后面跟其他词语 (如时间段) "regex": r"^(大后天|the day after tomorrow).*", "func": parse_day_after_tomorrow }, "day_before_yesterday": { # 匹配 "大前天" 开头 "regex": r"^(大前天|the day before yesterday).*", "func": parse_day_before_yesterday }, "specific_oclock": { # 匹配可选时段 + 小时(中/阿) + 点/時 "regex": r"^(?:早上|上午|中午|下午|晚上|morning|afternoon|evening|night)?([一二三四五六七八九十零]|[0-9]{1,2})[点時]$", "func": parse_specific_oclock }, "specific_oclock_half": { # 匹配可选时段 + 小时(中/阿) + 点半/時半 "regex": r"^(?:早上|上午|中午|下午|晚上|morning|afternoon|evening|night)?([一二三四五六七八九十零]|[0-9]{1,2})[点時]半$", "func": parse_specific_oclock_half }, # --- 新增解析器 --- # "specific_hour_minute": { # 匹配 X点Y分, X点Y十(Z), X点零Y "regex": r"^(?:(早上|上午|中午|下午|晚上|morning|afternoon|evening|night))?([一二三四五六七八九十零]|[0-9]{1,2})[点時](?:([一二三四五六七八九])十([一二三四五六七八九分])?|零([一二三四五六七八九])分?|([一二三四五六七八九十]|[0-5]?[0-9])分?)$", "func": parse_specific_hour_minute } # 注意: 解析顺序很重要,更具体的模式应放在前面 (如果存在重叠) } # --- TaskScheduler 类 --- # # 核心任务调度器类,采用单例模式 class TaskScheduler: _instance = None # 存储待执行的任务列表,格式为 (utc_timestamp, task_description, callback_info) scheduled_tasks: List[Tuple[float, str, Dict[str, Any]]] = [] _scheduler_task: Optional[asyncio.Task] = None # 指向后台运行的调度循环任务 @classmethod def get_instance(cls): """获取 TaskScheduler 的单例实例。首次调用时会自动初始化并启动调度循环。""" if cls._instance is None: logger.info("TaskScheduler 单例首次创建,执行自动设置...") cls._instance = cls() global _task_callback # 如果外部没有设置回调,则自动注册默认回调 if _task_callback is None: logger.info("未检测到外部任务回调,自动注册默认日志记录回调。") _task_callback = _default_task_handler logger.info(f"默认回调 '{_default_task_handler.__name__}' 已注册。") else: logger.info("检测到已设置外部任务回调。") # 启动后台调度循环 cls._instance.start() return cls._instance def has_pending_tasks(self) -> bool: """检查当前是否有待处理的任务。""" return bool(self.scheduled_tasks) def start(self): """启动后台任务调度器循环 (如果尚未运行)。""" # 检查调度任务是否未创建或已结束 if not self._scheduler_task or self._scheduler_task.done(): logger.info("后台任务调度器循环未运行或已结束,正在启动...") # 创建并启动 run_scheduler 协程作为后台任务 self._scheduler_task = asyncio.create_task(self.run_scheduler()) else: logger.debug("后台任务调度器循环已在运行中。") async def add_task(self, time_description: str, task_description: str, callback_info: Dict[str, Any], allow_overwrite: bool = False) -> Tuple[bool, AddTaskStatus, Optional[str]]: """ 尝试解析时间描述并添加新任务到调度队列。 Args: time_description: 用户提供的时间描述字符串。 task_description: 任务的描述,用于区分和取消任务。 callback_info: 传递给任务回调函数的上下文信息。 allow_overwrite: 如果设置为 true,并且找到了具有相同 task_description 的现有任务,则允许用新时间和上下文覆盖它。默认为 false,此时会拒绝添加重复描述的任务。 Returns: 一个元组: (是否成功, 添加状态["ADDED", "OVERWRITTEN", "FAILED"], 目标时间的 ISO 格式字符串 或 None)。 """ logger.debug(f"尝试添加任务: 时间='{time_description}', 任务='{task_description}', allow_overwrite={allow_overwrite}") was_overwritten = False tasks_to_keep = [] found_duplicate = False # --- 0. 检查同名任务 --- # current_tasks_snapshot = self.scheduled_tasks[:] for timestamp, desc, info in current_tasks_snapshot: if desc == task_description: found_duplicate = True if allow_overwrite: # 显示旧任务的原定 UTC+8 时间 try: old_local_time_str = datetime.fromtimestamp(timestamp, timezone.utc).astimezone(UTC_PLUS_8).strftime('%Y-%m-%d %H:%M:%S %z') except Exception: old_local_time_str = f"timestamp {timestamp}" logger.info(f"发现同名任务 '{desc}' 且允许覆盖,将移除旧任务 (原定: {old_local_time_str})") else: # 如果不允许覆盖,则保留所有任务,并直接返回失败 logger.warning(f"发现同名任务 '{desc}' 但不允许覆盖 (allow_overwrite=False)。添加任务失败。") return (False, "FAILED", None) # 返回失败,不修改任务列表 else: # 保留描述不同的任务 tasks_to_keep.append((timestamp, desc, info)) # 如果发现重复且允许覆盖,则实际执行移除旧任务的操作 if found_duplicate and allow_overwrite: self.scheduled_tasks = tasks_to_keep # 原子性地更新列表 was_overwritten = True logger.info(f"已移除描述为 '{task_description}' 的旧任务,准备添加新版本。") try: target_dt: Optional[datetime] = None time_description_lower = time_description.lower().strip() # ---- 1. 尝试自定义解析器 (使用新配置和正则) ---- # for parser_name, config in CUSTOM_TIME_PARSER_CONFIG.items(): pattern = config["regex"] parser_func = config["func"] # 使用 re.fullmatch 确保整个字符串匹配模式 match = re.fullmatch(pattern, time_description_lower) if match: logger.debug(f"时间描述 '{time_description_lower}' 匹配到自定义解析器 '{parser_name}' 的模式 '{pattern}'。") try: # 调用解析函数,传入原始描述和本地时区 target_dt = parser_func(time_description, UTC_PLUS_8) # 传入原始大小写描述 if target_dt: logger.info(f"使用自定义解析器 '{parser_name}' 成功解析。") break # 找到第一个成功的解析器就停止 else: # 解析器匹配了模式但返回 None (例如,常理判断失败) logger.warning(f"自定义解析器 '{parser_name}' 匹配模式但返回 None (可能因常理判断等)。") # 这里我们应该停止尝试其他解析器吗?如果一个特定模式匹配但解析失败(如时间已过), # 通常不应再尝试通用解析器。我们将在此处直接返回失败。 return (False, "FAILED", None) except Exception as custom_parse_err: logger.error(f"自定义解析器 '{parser_name}' 执行时出错: {custom_parse_err}", exc_info=True) # 如果自定义解析器内部出错,也视为解析失败 return (False, "FAILED", None) # else: # Debugging - 查看哪个模式不匹配 # logger.debug(f"模式 '{pattern}' 未完全匹配 '{time_description_lower}'") else: # 如果 for 循环正常结束 (没有 break,即所有自定义解析器都未成功解析) logger.debug("所有自定义解析器均未成功解析。") # 注意: target_dt 在这里仍然是 None # ---- 2. 尝试 dateparser (仅当自定义解析器未成功时) ---- # if target_dt is None: logger.debug(f"尝试使用 dateparser 解析 '{time_description}'") # 配置 dateparser: 偏好未来日期,使用 UTC+8 时区 (通过 IANA 名称 'Asia/Shanghai'),返回时区感知对象 settings = {'PREFER_DATES_FROM': 'future', 'TIMEZONE': 'Asia/Shanghai', 'RETURN_AS_TIMEZONE_AWARE': True} try: target_dt = dateparser.parse(time_description, settings=settings) if target_dt: logger.debug(f"Dateparser 成功解析。结果时区: {target_dt.tzinfo}") if target_dt.tzinfo is None: logger.warning("Dateparser 返回了无时区的 datetime,这不符合预期 (RETURN_AS_TIMEZONE_AWARE=True)。") try: target_dt = target_dt.replace(tzinfo=UTC_PLUS_8).astimezone(timezone.utc) except Exception as tz_err_dp: logger.error(f"附加 UTC+8 时区到 dateparser 结果时出错: {tz_err_dp}. 放弃解析。", exc_info=True) target_dt = None else: logger.debug(f"Dateparser 未能解析 '{time_description}'。") except Exception as dp_error: logger.warning(f"调用 dateparser 时出错: {dp_error}") target_dt = None # ---- 3. 尝试手动解析相对时间 ---- # if target_dt is None: logger.debug(f"尝试手动解析相对时间 '{time_description}'") try: delta = None match = re.search(r"(\d+(?:\.\d+)?|[一二三四五六七八九十]+)\s*(秒|分钟|小時|小时|天)(?:后|後)", time_description_lower) if match: value_str = match.group(1) unit = match.group(2) value: Optional[float] = None try: value = float(value_str) except ValueError: if value_str in CHINESE_NUMERALS: value = float(CHINESE_NUMERALS[value_str]) else: logger.warning(f"无法将手动解析提取的数字 '{value_str}' 转换为有效数值。") if value is not None: if unit == "秒": delta = timedelta(seconds=value) elif unit == "分钟": delta = timedelta(minutes=value) elif unit in ["小时", "小時"]: delta = timedelta(hours=value) elif unit == "天": delta = timedelta(days=value) else: logger.debug(f"手动解析未匹配到 '数字+单位+后' 模式。") if delta: target_dt = datetime.now(timezone.utc) + delta logger.debug(f"手动解析相对时间成功: {delta}") except (ValueError, TypeError) as parse_error: logger.warning(f"手动解析相对时间时出错: {parse_error}") # ---- 4. 检查最终解析结果 ---- # if target_dt is None: logger.warning(f"无法将 '{time_description}' 解析为有效时间。" ); return (False, "FAILED", None) # ---- 5. 确保目标时间是 UTC 时区 ---- # if target_dt.tzinfo is None or target_dt.tzinfo.utcoffset(target_dt) is None: logger.warning(f"解析结果 '{target_dt}' 缺少时区信息,将假定为 UTC+8 并转换为 UTC。") try: localized_dt = target_dt.replace(tzinfo=UTC_PLUS_8) target_dt = localized_dt.astimezone(timezone.utc) logger.debug(f"时区转换成功 (naive -> UTC+8 -> UTC): {target_dt.isoformat()}") except Exception as tz_err: logger.error(f"本地化到 UTC+8 并转换为 UTC 时出错: {tz_err}. 无法安排任务。", exc_info=True); return (False, "FAILED", None) elif target_dt.tzinfo != timezone.utc: original_tz = target_dt.tzinfo logger.debug(f"解析结果时区为 {original_tz},将转换为 UTC。") target_dt = target_dt.astimezone(timezone.utc) logger.debug(f"时区转换成功 ({original_tz} -> UTC): {target_dt.isoformat()}") else: logger.debug(f"解析结果已是 UTC 时间: {target_dt.isoformat()}") # ---- 6. 检查时间是否已过去并安排任务 ---- # target_timestamp = target_dt.timestamp() current_timestamp = datetime.now(timezone.utc).timestamp() if target_timestamp < current_timestamp - 1: logger.warning(f"目标时间 {target_dt.isoformat()} (UTC) 已过去,无法安排任务。") return (False, "FAILED", None) target_timestamp = max(target_timestamp, current_timestamp + 0.01) target_dt = datetime.fromtimestamp(target_timestamp, timezone.utc) target_dt_iso_str = target_dt.isoformat() task_entry = (target_timestamp, task_description, callback_info.copy()) task_entry[2]['original_time_description'] = time_description task_entry[2]['target_datetime_utc_iso'] = target_dt_iso_str self.scheduled_tasks.append(task_entry) self.scheduled_tasks.sort() status = "OVERWRITTEN" if was_overwritten else "ADDED" # 显示目标的 UTC+8 时间 try: target_local_time_str = target_dt.astimezone(UTC_PLUS_8).strftime('%Y-%m-%d %H:%M:%S %z') except Exception: target_local_time_str = target_dt_iso_str # Fallback to UTC ISO # logger.info(f"任务 '{task_description}' 已成功安排在 {target_dt_iso_str} (UTC) (状态: {status})") logger.info(f"任务 '{task_description}' 已成功安排在 {target_local_time_str} (状态: {status})") return (True, status, target_dt_iso_str) except Exception as e: logger.error(f"添加任务 '{task_description}' 时发生未预期错误: {e}", exc_info=True) return (False, "FAILED", None) async def cancel_task_by_description(self, description_to_cancel: str) -> int: """ 根据任务描述取消一个或多个待处理的任务。 Args: description_to_cancel: 要取消的任务的描述字符串。 Returns: 成功取消的任务数量。 """ cancelled_count = 0 tasks_to_keep = [] current_tasks_snapshot = self.scheduled_tasks[:] for timestamp, desc, info in current_tasks_snapshot: if desc != description_to_cancel: tasks_to_keep.append((timestamp, desc, info)) else: # 显示原定 UTC+8 时间 try: local_time_str = datetime.fromtimestamp(timestamp, timezone.utc).astimezone(UTC_PLUS_8).strftime('%Y-%m-%d %H:%M:%S %z') except Exception: local_time_str = f"timestamp {timestamp}" # logger.info(f"准备取消任务: '{desc}', 原定时间: {datetime.fromtimestamp(timestamp, timezone.utc).isoformat()}") logger.info(f"准备取消任务: '{desc}', 原定时间: {local_time_str}") if len(tasks_to_keep) < len(current_tasks_snapshot): cancelled_count = len(current_tasks_snapshot) - len(tasks_to_keep) self.scheduled_tasks = tasks_to_keep logger.info(f"成功移除 {cancelled_count} 个描述为 '{description_to_cancel}' 的任务。") else: logger.info(f"未找到描述为 '{description_to_cancel}' 的待执行任务。") return cancelled_count def get_scheduled_tasks_summary(self) -> List[Dict[str, Any]]: """ 获取当前所有待处理任务的摘要列表。 Returns: List[Dict[str, Any]]: 每个字典包含 'description' 和 'scheduled_time_utc8' (格式化的 UTC+8 时间字符串)。 """ summary_list = [] # 使用快照以避免迭代时修改 current_tasks_snapshot = self.scheduled_tasks[:] for timestamp, desc, info in current_tasks_snapshot: try: # 将 UTC 时间戳转换为 UTC+8 datetime 对象 scheduled_dt_utc8 = datetime.fromtimestamp(timestamp, timezone.utc).astimezone(UTC_PLUS_8) # 格式化为易读字符串 time_str = scheduled_dt_utc8.strftime('%Y-%m-%d %H:%M:%S %Z') except Exception: # 如果格式化失败,使用 UTC 时间戳作为回退 time_str = f"UTC Timestamp {timestamp}" summary_list.append({ "description": desc, "scheduled_time_utc8": time_str }) return summary_list async def run_scheduler(self): """后台调度循环,持续检查并执行到期的任务。""" logger.info("任务调度器后台循环启动...") while True: try: # 如果没有待处理任务,休眠一段时间后继续检查 if not self.scheduled_tasks: await asyncio.sleep(5); continue # 列表为空,等待5秒 # 获取下一个任务的时间戳 (列表已排序,第一个即为最早的任务) next_task_timestamp = self.scheduled_tasks[0][0] now_timestamp = datetime.now(timezone.utc).timestamp() # 如果下一个任务的时间还没到 if next_task_timestamp > now_timestamp: # 计算需要休眠的时间 sleep_duration = max(0.1, next_task_timestamp - now_timestamp) # 休眠直到下一个任务即将到期,但最长不超过60秒 (避免长时间阻塞) await asyncio.sleep(min(sleep_duration, 60)); continue # --- 执行到期任务 --- # # 从列表中移除并获取第一个任务 (已到期) timestamp, task_description, callback_info = self.scheduled_tasks.pop(0) global _task_callback # 检查回调函数是否存在且是异步函数 if _task_callback and inspect.iscoroutinefunction(_task_callback): handler_name = getattr(_task_callback, '__name__', repr(_task_callback)) # 显示原定 UTC+8 时间 try: local_time_str = datetime.fromtimestamp(timestamp, timezone.utc).astimezone(UTC_PLUS_8).strftime('%Y-%m-%d %H:%M:%S %z') except Exception: local_time_str = f"timestamp {timestamp}" # logger.info(f"执行任务: '{task_description}' (原定于 {datetime.fromtimestamp(timestamp, timezone.utc).isoformat()}) 使用处理器: {handler_name}") logger.info(f"执行任务: '{task_description}' (原定于 {local_time_str}) 使用处理器: {handler_name}") try: # 使用 asyncio.create_task 异步执行回调,避免阻塞调度循环 asyncio.create_task(_task_callback(task_description, callback_info)) except Exception as e: # 捕获并记录回调执行中的错误 logger.error(f"执行任务回调 '{task_description}' ({handler_name}) 时出错: {e}", exc_info=True) else: # 如果没有有效回调,记录严重错误 logger.error(f"CRITICAL: 任务 '{task_description}' 到期,但没有有效的异步回调函数设置! _task_callback is {_task_callback}") print(f"--- CRITICAL ERROR: NO TASK CALLBACK SET for task '{task_description}' ---") await asyncio.sleep(0.01) # 短暂休眠,避免 CPU 占用过高 except asyncio.CancelledError: # 捕获取消信号,优雅退出循环 logger.info("任务调度器后台循环被取消。"); break except Exception as e: # 捕获调度循环中的其他严重错误,记录并休眠后重试 logger.error(f"任务调度器后台循环发生严重错误: {e}", exc_info=True); await asyncio.sleep(15) # --- ScheduleTaskTool 类 (自动判断模式) --- # class ScheduleTaskTool(BaseTool): name = "schedule_task" description = ("当你需要处理未来的提醒或计划时,无论是响应别人的请求(比如'五分钟后提醒我'),还是你自己需要记住完成当前事情后做某事(比如估算完成时间后提醒别人),你必须使用这个功能。\n" "如果想更新一个已存在的任务,需要告诉我你想更新的是哪件事和新的执行时间(默认会覆盖旧安排)。\n" "如果想取消一个任务,只需要告诉我你想取消的是哪件事就行了。\n"#目前为准确描述,效果不佳,需要改进 "它能听懂很多时间说法,像是'五分钟后'、'明天早上'、'下周一'等等。") # 微调描述,暗示可基于自身状态使用 parameters = { "type": "object", "properties": { "time_description": { # 来自图片 "type": "string", "description": "描述任务应该何时执行的时间。应尽可能使用自然语言,例如 '五分钟后', '明天早上8点', '下周一上午10点'。", }, "task_description": { # 来自图片 "type": "string", "description": "对到时间时需要执行的任务或提醒内容的简短、唯一的描述。这个描述也用于后续可能需要取消任务时的标识符。", }, "context": { # 修改描述 "type": "string", "description": "为任务执行提供附加上下文信息。例如,提醒用户的具体内容,或继续某个流程所需的状态信息。", }, "update_existing": { # 修改描述并移除 default "type": "boolean", "description": "是否允许新任务覆盖具有完全相同描述的旧任务。默认为 `true` (允许覆盖)。如果设为 `false`,则添加操作会失败。", } }, "required": ["task_description"], } async def execute(self, function_args: Dict[str, Any], message_txt: str = "") -> Dict[str, Any]: """根据提供的参数自动判断并执行安排、更新或取消计划任务的动作。""" try: task_desc = function_args.get("task_description") time_desc = function_args.get("time_description") # 获取可选的时间描述 context_from_llm = function_args.get("context") # 获取可选的上下文 update_existing = function_args.get("update_existing", True) # 获取 update_existing 参数,现在默认行为是 True (允许覆盖) scheduler = TaskScheduler.get_instance() # --- 日志:记录收到的参数 --- # logger.info(f"[{self.name}] 接收到参数: task_description='{task_desc}', time_description='{time_desc}', context='{context_from_llm}', update_existing={update_existing}") if not task_desc: logger.warning(f"[{self.name}] 调用缺少必需的 task_description。") return {"name": self.name, "content": "操作失败:必须提供任务描述 (task_description)。"} # --- 判断操作模式 --- if time_desc is None: # --- 取消任务流程 --- # logger.info(f"[{self.name}] 判断意图为:[取消任务]") logger.debug(f"[{self.name}] 计划调用 cancel_task_by_description,使用精确描述: '{task_desc}'") # --- Placeholder: 这里理想情况下应该由调用者先完成LLM识别,获取精确 task_desc --- # logger.debug(f"[{self.name}] (注意:当前直接使用提供的描述进行精确匹配取消)") cancelled_count = await scheduler.cancel_task_by_description(task_desc) if cancelled_count > 0: content = f"好的,已成功取消 {cancelled_count} 个描述为 '{task_desc}' 的待执行任务。" else: content = f"抱歉,未能找到描述为 '{task_desc}' 的待执行任务,无法取消。" return {"name": self.name, "content": content} else: # --- 添加或更新任务流程 --- # intent = "更新任务" if update_existing else "添加任务 (若同名则失败)" logger.info(f"[{self.name}] 判断意图为:[{intent}] (基于 time_description 存在)") # --- Placeholder: 对于 '更新任务',这里理想情况下应该由调用者先完成LLM识别,获取精确 task_desc --- # if update_existing: logger.debug(f"[{self.name}] (注意:当前直接使用提供的描述进行精确匹配更新)") logger.debug(f"[{self.name}] 计划调用 add_task,参数: time='{time_desc}', desc='{task_desc}', allow_overwrite={update_existing}") callback_info = { "context": context_from_llm if context_from_llm is not None else "", "original_message_triggering_tool": message_txt, "tool_args": function_args.copy() } success, add_status, target_dt_iso = await scheduler.add_task( time_desc, task_desc, callback_info, allow_overwrite=update_existing ) if success: time_str_for_reply = "稍后" # 默认回退值 if target_dt_iso: try: target_dt = datetime.fromisoformat(target_dt_iso.replace('Z', '+00:00')) display_tz = UTC_PLUS_8 target_dt_display = target_dt.astimezone(display_tz) now_local = datetime.now(display_tz) delta = target_dt_display - now_local # 尝试提供更友好的时间格式 if timedelta(minutes=0) < delta <= timedelta(hours=2): # 2小时内 time_str_for_reply = f"{target_dt_display.strftime('%H:%M:%S %Z')} (大约 {int(delta.total_seconds() // 60)} 分钟后)" elif timedelta(hours=2) < delta <= timedelta(days=1) and target_dt_display.date() == now_local.date(): # 今天晚些时候 time_str_for_reply = f"今天 {target_dt_display.strftime('%H:%M:%S %Z')}" else: # 其他情况(例如明天或更远)使用标准格式 time_str_for_reply = target_dt_display.strftime("%Y-%m-%d %H:%M:%S %Z") # 使用 %Z 显示时区名称 except Exception as fmt_err: logger.warning(f"格式化回复时间 (UTC+8) 时出错: {fmt_err}. 回退到 UTC ISO。") time_str_for_reply = target_dt_iso # 回退到 UTC ISO 字符串 else: # 理论上,如果 success 为 True,target_dt_iso 不应为 None logger.error(f"任务安排成功,但 target_dt_iso 丢失!任务描述: {task_desc}") time_str_for_reply = f"在 '{time_desc}' 解析后的某个时间" if add_status == "OVERWRITTEN": content = f"好的,已将现有任务 '{task_desc}' 的执行时间更新为 {time_str_for_reply}。" else: content = f"好的,已安排新任务 '{task_desc}' 在 {time_str_for_reply} 执行。" else: content = f"抱歉,无法安排或更新任务 '{task_desc}'。可能原因:时间描述 ('{time_desc}') 无法被理解、格式错误、时间已过去,或内部解析/添加错误。" return {"name": self.name, "content": content} except Exception as e: error_details = traceback.format_exc() logger.error(f"执行 {self.name} 时发生未预期错误: {str(e)}\n{error_details}") return {"name": self.name, "content": f"处理计划任务时遇到内部错误: {str(e)}"} # --- 文件末尾说明 --- # # 重要提示: # 1. TaskScheduler 使用单例模式。首次调用 TaskScheduler.get_instance() 会自动初始化并启动后台调度循环。 # 2. 默认情况下,任务到期时仅会记录日志。要执行实际操作(如发送消息),必须在应用程序启动时调用 set_task_callback(your_async_handler) 来设置一个自定义的异步回调函数。 # 3. 此模块依赖第三方库 dateparser # 4. 此模块内部时间处理已硬编码为 UTC+8 作为本地时区,问就是未知原因使得输出总是默认的UTC时区,配置里的调用过不知道为什么没用。。。逃.jpg #现在这个工具只能实现短期任务安排,长期任务计划安排需要使用其他工具 #这里指的是单此启动的短期任务,所以。。。 #目前逻辑是在开启这个工具时候,判断三种,设置任务,更新任务,取消任务,通过llm判断是否更新或者取消(不稳定),然后执行后续操作 #过于依赖dateparser,如果dateparser无法解析,添加了一堆自定义时间来识图解析,为什么不能直接返回一个时间QAQ #很大一部分代码是自定义解决dateparser无法解析问题的,,,, #graph TD # A[用户/对话触发意图] --> B(LLM 思考); # B --> C{LLM 产生初步意图
(添加/更新/取消)
(更新/取消描述可能模糊)}; # C --> E[调用者 (如 SubMind) 处理意图]; # subgraph "识别精确描述 (调用者逻辑)" # E -- 意图是更新或取消 --> F{获取当前任务列表
(TaskScheduler.get_scheduled_tasks_summary())}; # F --> G[构建二次 Prompt
(上下文+模糊描述+任务列表)]; # G --> H(调用 LLM 进行识别); # H --> I{LLM 返回精确 task_description 或 '未找到'}; # end # I -- 精确描述 --> J[准备工具调用参数
(使用精确 task_description)]; # I -- 未找到 --> K[处理错误/告知用户]; # E -- 意图是添加 --> J[准备工具调用参数
(使用原始 task_description)]; # J --> L[执行 schedule_task 工具调用]; # L --> M(ScheduleTaskTool.execute); # M --> N{检查 time_description 是否存在?}; # N -- 是 --> O[判断为 添加/更新]; # N -- 否 --> P[判断为 取消]; # O --> Q{TaskScheduler.add_task
(使用精确 task_description 查找旧任务)}; # P --> R{TaskScheduler.cancel_task_by_description
(使用精确 task_description 查找任务)}; # Q --> S{执行成功/失败
(精确匹配成功率高)}; # R --> T{执行成功/失败
(精确匹配成功率高)}; # S --> U(返回结果给 LLM); # T --> U; # K --> U;