From d9badf438eb6a43c14403d0d0fde80ca4065e618 Mon Sep 17 00:00:00 2001 From: zihanjian Date: Thu, 16 Oct 2025 11:22:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E9=86=92=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- commands/ai_functions.py | 41 +-- commands/handlers.py | 652 +++++++++++++++++++++++------------- commands/reminder_router.py | 242 +++---------- 3 files changed, 473 insertions(+), 462 deletions(-) diff --git a/commands/ai_functions.py b/commands/ai_functions.py index f9727e8..0ff25b8 100644 --- a/commands/ai_functions.py +++ b/commands/ai_functions.py @@ -29,45 +29,26 @@ def ai_handle_reminder_hub(ctx: MessageContext, params: str) -> bool: return True action = decision.action + payload = decision.params or original_text if action == "list": return handle_list_reminders(ctx, None) if action == "delete": - at_list = ctx.msg.sender if ctx.is_group else "" - if not decision.success: - ctx.send_text(decision.message or "❌ 抱歉,无法处理删除提醒的请求。", at_list) - return True - - if decision.payload is not None: - setattr(ctx, "_reminder_delete_plan", decision.payload) - + original_content = ctx.msg.content + ctx.msg.content = f"删除提醒 {payload}".strip() try: return handle_delete_reminder(ctx, None) finally: - if hasattr(ctx, "_reminder_delete_plan"): - delattr(ctx, "_reminder_delete_plan") + ctx.msg.content = original_content - if action == "create": - at_list = ctx.msg.sender if ctx.is_group else "" - if not decision.success: - ctx.send_text(decision.message or "❌ 抱歉,暂时无法理解您的提醒请求。", at_list) - return True - - if decision.payload is not None: - setattr(ctx, "_reminder_create_plan", decision.payload) - - try: - return handle_reminder(ctx, None) - finally: - if hasattr(ctx, "_reminder_create_plan"): - delattr(ctx, "_reminder_create_plan") - - # 兜底处理:无法识别的动作 - at_list = ctx.msg.sender if ctx.is_group else "" - fallback_message = decision.message or "抱歉,暂时无法处理提醒请求,可以换一种说法吗?" - ctx.send_text(fallback_message, at_list) - return True + # 默认视为创建提醒 + original_content = ctx.msg.content + ctx.msg.content = payload if payload.startswith("提醒我") else f"提醒我{payload}" + try: + return handle_reminder(ctx, None) + finally: + ctx.msg.content = original_content # ======== Perplexity搜索功能 ======== @ai_router.register( diff --git a/commands/handlers.py b/commands/handlers.py index 74b709c..e508008 100644 --- a/commands/handlers.py +++ b/commands/handlers.py @@ -547,159 +547,236 @@ def handle_perplexity_ask(ctx: 'MessageContext', match: Optional[Match]) -> bool def handle_reminder(ctx: 'MessageContext', match: Optional[Match]) -> bool: """处理来自私聊或群聊的 '提醒' 命令,支持批量添加多个提醒""" - at_list = ctx.msg.sender if ctx.is_group else "" - raw_text = ctx.msg.content.strip() + # 2. 获取用户输入的提醒内容 (现在从完整消息获取) + raw_text = ctx.msg.content.strip() # 修改:从 ctx.msg.content 获取 + if not raw_text: # 修改:仅检查是否为空 + # 在群聊中@用户回复 + at_list = ctx.msg.sender if ctx.is_group else "" + ctx.send_text("请告诉我需要提醒什么内容和时间呀~ (例如:提醒我明天下午3点开会)", at_list) + return True - parsed_reminders = [] - plan = getattr(ctx, "_reminder_create_plan", None) - if hasattr(ctx, "_reminder_create_plan"): - delattr(ctx, "_reminder_create_plan") + # 3. 构造给 AI 的 Prompt,更新为支持批量提醒 + sys_prompt = """ +你是提醒解析助手。请仔细分析用户输入的提醒信息,**识别其中可能包含的所有独立提醒请求**。将所有成功解析的提醒严格按照以下 JSON **数组** 格式输出结果,数组中的每个元素代表一个独立的提醒: +[ + {{ + "type": "once" | "daily" | "weekly", // 提醒类型: "once" (一次性) 或 "daily" (每日重复) 或 "weekly" (每周重复) + "time": "YYYY-MM-DD HH:MM" | "HH:MM", // "once"类型必须是 'YYYY-MM-DD HH:MM' 格式, "daily"与"weekly"类型必须是 'HH:MM' 格式。时间必须是未来的。 + "content": "提醒的具体内容文本", + "weekday": 0-6, // 仅当 type="weekly" 时需要,周一=0, 周二=1, ..., 周日=6 + "extra": {{}} // 保留字段,目前为空对象即可 + }}, + // ... 可能有更多提醒对象 ... +] - if isinstance(plan, dict): - parsed_reminders = plan.get("reminders", []) or [] - raw_text = plan.get("raw_text", raw_text) - else: - if not raw_text: - ctx.send_text("请告诉我需要提醒什么内容和时间呀~ (例如:提醒我明天下午3点开会)", at_list) +**重要:** 你的回复必须仅包含有效的JSON数组,不要包含任何其他说明文字。所有JSON中的布尔值、数字应该没有引号,字符串需要有引号。 + +- **仔细分析用户输入,识别所有独立的提醒请求。** +- 对每一个识别出的提醒,判断其类型 (`once`, `daily`, `weekly`) 并计算准确时间。 +- "once"类型时间必须是 'YYYY-MM-DD HH:MM' 格式, "daily"/"weekly"类型必须是 'HH:MM' 格式。时间必须是未来的。 +- "weekly"类型必须提供 weekday (周一=0...周日=6)。 +- **将所有解析成功的提醒对象放入一个 JSON 数组中返回。** +- 如果只识别出一个提醒,返回包含单个元素的数组。 +- **如果无法识别出任何有效提醒,返回空数组 `[]`。** +- 如果用户输入的某个提醒部分信息不完整或格式错误,请尝试解析其他部分,并在最终数组中仅包含解析成功的提醒。 +- 输出结果必须是纯 JSON 数组,不包含任何其他说明文字。 + +当前准确时间是:{current_datetime} +""" + current_dt_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + formatted_prompt = sys_prompt.format(current_datetime=current_dt_str) + + # 4. 调用AI模型并解析 + q_for_ai = f"请解析以下用户提醒,识别所有独立的提醒请求:\n{raw_text}" + try: + # 检查AI模型 + if not hasattr(ctx, 'chat') or not ctx.chat: + raise ValueError("当前上下文中没有可用的AI模型") + + # 获取AI回答 + at_list = ctx.msg.sender if ctx.is_group else "" + + # 实现最多尝试3次解析AI回复的逻辑 + max_retries = 3 + retry_count = 0 + parsed_reminders = [] # 初始化为空列表 + ai_parsing_success = False + + while retry_count < max_retries and not ai_parsing_success: + # 如果是重试,更新提示信息 + if retry_count > 0: + enhanced_prompt = sys_prompt + f"\n\n**重要提示:** 这是第{retry_count+1}次尝试。你之前的回复格式有误,无法被解析为有效的JSON。请确保你的回复仅包含有效的JSON数组,没有其他任何文字。" + formatted_prompt = enhanced_prompt.format(current_datetime=current_dt_str) + # 在重试时提供更明确的信息 + retry_q = f"请再次解析以下提醒,并返回严格的JSON数组格式(第{retry_count+1}次尝试):\n{raw_text}" + q_for_ai = retry_q + + ai_response = ctx.chat.get_answer(q_for_ai, ctx.get_receiver(), system_prompt_override=formatted_prompt) + + # 尝试匹配 [...] 或 {...} (兼容单个提醒的情况,但优先列表) + json_match_list = re.search(r'\[.*\]', ai_response, re.DOTALL) + json_match_obj = re.search(r'\{.*\}', ai_response, re.DOTALL) + + json_str = None + if json_match_list: + json_str = json_match_list.group(0) + elif json_match_obj: # 如果没找到列表,尝试找单个对象 (增加兼容性) + json_str = f"[{json_match_obj.group(0)}]" # 将单个对象包装成数组 + else: + json_str = ai_response # 如果都找不到,直接尝试解析原始回复 + + try: + # 尝试解析JSON + parsed_data = json.loads(json_str) + # 确保解析结果是一个列表 + if isinstance(parsed_data, dict): + parsed_reminders = [parsed_data] # 包装成单元素列表 + elif isinstance(parsed_data, list): + parsed_reminders = parsed_data # 本身就是列表 + else: + # 解析结果不是列表也不是字典,无法处理 + raise ValueError("AI 返回的不是有效的 JSON 列表或对象") + + # 如果能到这里,说明解析成功 + ai_parsing_success = True + + except (json.JSONDecodeError, ValueError) as e: + # JSON解析失败 + retry_count += 1 + if ctx.logger: + ctx.logger.warning(f"AI 返回 JSON 解析失败(第{retry_count}次尝试): {ai_response}, 错误: {str(e)}") + + if retry_count >= max_retries: + # 达到最大重试次数,返回错误 + ctx.send_text(f"❌ 抱歉,无法理解您的提醒请求。请尝试换一种方式表达,或分开设置多个提醒。", at_list) + if ctx.logger: ctx.logger.error(f"解析AI回复失败,已达到最大重试次数({max_retries}): {ai_response}") + return True + # 否则继续下一次循环重试 + + # 检查 ReminderManager 是否存在 + if not hasattr(ctx.robot, 'reminder_manager'): + ctx.send_text("❌ 内部错误:提醒管理器未初始化。", at_list) + if ctx.logger: ctx.logger.error("handle_reminder 无法访问 ctx.robot.reminder_manager") return True - try: - from .reminder_router import reminder_router - decision = reminder_router.route(ctx, raw_text) - except Exception as exc: - if ctx.logger: - ctx.logger.error(f"handle_reminder 路由解析失败: {exc}", exc_info=True) - ctx.send_text("❌ 抱歉,暂时无法理解您的提醒请求。", at_list) - return True - - if not decision or decision.action != "create": + # 如果AI返回空列表,告知用户 + if not parsed_reminders: ctx.send_text("🤔 嗯... 我好像没太明白您想设置什么提醒,可以换种方式再说一次吗?", at_list) return True - if not decision.success: - ctx.send_text(decision.message or "❌ 抱歉,暂时无法理解您的提醒请求。", at_list) - return True + # 批量处理提醒 + results = [] # 用于存储每个提醒的处理结果 + roomid = ctx.msg.roomid if ctx.is_group else None - payload = decision.payload or {} - parsed_reminders = payload.get("reminders", []) or [] - raw_text = payload.get("raw_text", raw_text) + for index, data in enumerate(parsed_reminders): + reminder_label = f"提醒{index+1}" # 给每个提醒一个标签,方便反馈 + validation_error = None # 存储验证错误信息 - # 检查 ReminderManager 是否存在 - if not hasattr(ctx.robot, 'reminder_manager'): - ctx.send_text("❌ 内部错误:提醒管理器未初始化。", at_list) - if ctx.logger: ctx.logger.error("handle_reminder 无法访问 ctx.robot.reminder_manager") - return True - - # 如果没有解析出任何提醒,告知用户 - if not parsed_reminders: - ctx.send_text("🤔 嗯... 我好像没太明白您想设置什么提醒,可以换种方式再说一次吗?", at_list) - return True - - # 批量处理提醒 - results = [] # 用于存储每个提醒的处理结果 - roomid = ctx.msg.roomid if ctx.is_group else None - - for index, data in enumerate(parsed_reminders): - reminder_label = f"提醒{index+1}" # 给每个提醒一个标签,方便反馈 - validation_error = None # 存储验证错误信息 - - # **验证单个提醒数据** - if not isinstance(data, dict): - validation_error = "格式错误 (不是有效的提醒对象)" - elif not data.get("type") or not data.get("time") or not data.get("content"): - validation_error = "缺少必要字段(类型/时间/内容)" - elif len(data.get("content", "").strip()) < 2: - validation_error = "提醒内容太短" - else: - # 验证时间格式 - try: - if data["type"] == "once": - dt = datetime.strptime(data["time"], "%Y-%m-%d %H:%M") - if dt < datetime.now(): - validation_error = f"时间 ({data['time']}) 必须是未来的时间" - elif data["type"] in ["daily", "weekly"]: - datetime.strptime(data["time"], "%H:%M") # 仅校验格式 - else: - validation_error = f"不支持的提醒类型: {data.get('type')}" - except ValueError: - validation_error = f"时间格式错误 ({data.get('time', '')})" - - # 验证周提醒 (如果类型是 weekly 且无验证错误) - if not validation_error and data["type"] == "weekly": - if not (isinstance(data.get("weekday"), int) and 0 <= data.get("weekday") <= 6): - validation_error = "每周提醒需要指定周几(0-6)" - - # 如果验证通过,尝试添加到数据库 - if not validation_error: - try: - success, result_or_id = ctx.robot.reminder_manager.add_reminder(ctx.msg.sender, data, roomid=roomid) - if success: - results.append({"label": reminder_label, "success": True, "id": result_or_id, "data": data}) - if ctx.logger: ctx.logger.info(f"成功添加提醒 {result_or_id} for {ctx.msg.sender} (来自批量处理)") - else: - # add_reminder 返回错误信息 - results.append({"label": reminder_label, "success": False, "error": result_or_id, "data": data}) - if ctx.logger: ctx.logger.warning(f"添加提醒失败 (来自批量处理): {result_or_id}") - except Exception as db_e: - # 捕获 add_reminder 可能抛出的其他异常 - error_msg = f"数据库错误: {db_e}" - results.append({"label": reminder_label, "success": False, "error": error_msg, "data": data}) - if ctx.logger: ctx.logger.error(f"添加提醒时数据库出错 (来自批量处理): {db_e}", exc_info=True) - else: - # 验证失败 - results.append({"label": reminder_label, "success": False, "error": validation_error, "data": data}) - if ctx.logger: ctx.logger.warning(f"提醒数据验证失败 ({reminder_label}): {validation_error} - Data: {data}") - - # 构建汇总反馈消息 - reply_parts = [] - successful_count = sum(1 for res in results if res["success"]) - failed_count = len(results) - successful_count - - # 添加总览信息 - if len(results) > 1: # 只有多个提醒时才需要总览 - if successful_count > 0 and failed_count > 0: - reply_parts.append(f"✅ 已设置 {successful_count} 个提醒,{failed_count} 个设置失败:\n") - elif successful_count > 0: - reply_parts.append(f"✅ 已设置 {successful_count} 个提醒:\n") - else: - reply_parts.append(f"❌ 抱歉,所有 {len(results)} 个提醒设置均失败:\n") - - # 添加每个提醒的详细信息 - for res in results: - content_preview = res['data'].get('content', '未知内容') - # 如果内容太长,截取前20个字符加省略号 - if len(content_preview) > 20: - content_preview = content_preview[:20] + "..." - - if res["success"]: - reminder_id = res['id'] - type_str = {"once": "一次性", "daily": "每日", "weekly": "每周"}.get(res['data'].get('type'), "未知") - time_display = res['data'].get("time", "?") - - # 为周提醒格式化显示 - if res['data'].get("type") == "weekly" and "weekday" in res['data']: - weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] - if 0 <= res['data']["weekday"] <= 6: - time_display = f"{weekdays[res['data']['weekday']]} {time_display}" - - # 单个提醒或多个提醒的第一个,不需要标签 - if len(results) == 1: - reply_parts.append(f"✅ 已为您设置{type_str}提醒:\n" - f"时间: {time_display}\n" - f"内容: {res['data'].get('content', '无')}") + # **验证单个提醒数据** + if not isinstance(data, dict): + validation_error = "格式错误 (不是有效的提醒对象)" + elif not data.get("type") or not data.get("time") or not data.get("content"): + validation_error = "缺少必要字段(类型/时间/内容)" + elif len(data.get("content", "").strip()) < 2: + validation_error = "提醒内容太短" else: - reply_parts.append(f"✅ {res['label']}: {type_str}\n {time_display} - \"{content_preview}\"") - else: - # 失败的提醒 - if len(results) == 1: - reply_parts.append(f"❌ 设置提醒失败: {res['error']}") + # 验证时间格式 + try: + if data["type"] == "once": + dt = datetime.strptime(data["time"], "%Y-%m-%d %H:%M") + if dt < datetime.now(): + validation_error = f"时间 ({data['time']}) 必须是未来的时间" + elif data["type"] in ["daily", "weekly"]: + datetime.strptime(data["time"], "%H:%M") # 仅校验格式 + else: + validation_error = f"不支持的提醒类型: {data.get('type')}" + except ValueError: + validation_error = f"时间格式错误 ({data.get('time', '')})" + + # 验证周提醒 (如果类型是 weekly 且无验证错误) + if not validation_error and data["type"] == "weekly": + if not (isinstance(data.get("weekday"), int) and 0 <= data.get("weekday") <= 6): + validation_error = "每周提醒需要指定周几(0-6)" + + # 如果验证通过,尝试添加到数据库 + if not validation_error: + try: + success, result_or_id = ctx.robot.reminder_manager.add_reminder(ctx.msg.sender, data, roomid=roomid) + if success: + results.append({"label": reminder_label, "success": True, "id": result_or_id, "data": data}) + if ctx.logger: ctx.logger.info(f"成功添加提醒 {result_or_id} for {ctx.msg.sender} (来自批量处理)") + else: + # add_reminder 返回错误信息 + results.append({"label": reminder_label, "success": False, "error": result_or_id, "data": data}) + if ctx.logger: ctx.logger.warning(f"添加提醒失败 (来自批量处理): {result_or_id}") + except Exception as db_e: + # 捕获 add_reminder 可能抛出的其他异常 + error_msg = f"数据库错误: {db_e}" + results.append({"label": reminder_label, "success": False, "error": error_msg, "data": data}) + if ctx.logger: ctx.logger.error(f"添加提醒时数据库出错 (来自批量处理): {db_e}", exc_info=True) else: - reply_parts.append(f"❌ {res['label']}: \"{content_preview}\" - {res['error']}") + # 验证失败 + results.append({"label": reminder_label, "success": False, "error": validation_error, "data": data}) + if ctx.logger: ctx.logger.warning(f"提醒数据验证失败 ({reminder_label}): {validation_error} - Data: {data}") - # 发送汇总消息 - ctx.send_text("\n".join(reply_parts), at_list) + # 构建汇总反馈消息 + reply_parts = [] + successful_count = sum(1 for res in results if res["success"]) + failed_count = len(results) - successful_count + + # 添加总览信息 + if len(results) > 1: # 只有多个提醒时才需要总览 + if successful_count > 0 and failed_count > 0: + reply_parts.append(f"✅ 已设置 {successful_count} 个提醒,{failed_count} 个设置失败:\n") + elif successful_count > 0: + reply_parts.append(f"✅ 已设置 {successful_count} 个提醒:\n") + else: + reply_parts.append(f"❌ 抱歉,所有 {len(results)} 个提醒设置均失败:\n") + + # 添加每个提醒的详细信息 + for res in results: + content_preview = res['data'].get('content', '未知内容') + # 如果内容太长,截取前20个字符加省略号 + if len(content_preview) > 20: + content_preview = content_preview[:20] + "..." + + if res["success"]: + reminder_id = res['id'] + type_str = {"once": "一次性", "daily": "每日", "weekly": "每周"}.get(res['data'].get('type'), "未知") + time_display = res['data'].get("time", "?") + + # 为周提醒格式化显示 + if res['data'].get("type") == "weekly" and "weekday" in res['data']: + weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + if 0 <= res['data']["weekday"] <= 6: + time_display = f"{weekdays[res['data']['weekday']]} {time_display}" + + # 单个提醒或多个提醒的第一个,不需要标签 + if len(results) == 1: + reply_parts.append(f"✅ 已为您设置{type_str}提醒:\n" + f"时间: {time_display}\n" + f"内容: {res['data'].get('content', '无')}") + else: + reply_parts.append(f"✅ {res['label']}: {type_str}\n {time_display} - \"{content_preview}\"") + else: + # 失败的提醒 + if len(results) == 1: + reply_parts.append(f"❌ 设置提醒失败: {res['error']}") + else: + reply_parts.append(f"❌ {res['label']}: \"{content_preview}\" - {res['error']}") - return True # 命令处理流程结束 + # 发送汇总消息 + ctx.send_text("\n".join(reply_parts), at_list) + + return True # 命令处理流程结束 + + except Exception as e: # 捕获代码块顶层的其他潜在错误 + at_list = ctx.msg.sender if ctx.is_group else "" + error_message = f"处理提醒时发生意外错误: {str(e)}" + ctx.send_text(f"❌ {error_message}", at_list) + if ctx.logger: + ctx.logger.error(f"handle_reminder 顶层错误: {e}", exc_info=True) + return True def handle_list_reminders(ctx: 'MessageContext', match: Optional[Match]) -> bool: """处理查看提醒命令(支持群聊和私聊)""" @@ -754,112 +831,231 @@ def handle_list_reminders(ctx: 'MessageContext', match: Optional[Match]) -> bool def handle_delete_reminder(ctx: 'MessageContext', match: Optional[Match]) -> bool: """处理删除提醒命令(支持群聊和私聊),通过 AI 理解用户意图并执行操作。""" + # 1. 获取用户输入的完整内容 raw_text = ctx.msg.content.strip() - reminder_manager = getattr(ctx.robot, 'reminder_manager', None) - if not reminder_manager: + # 2. 检查 ReminderManager 是否存在 + if not hasattr(ctx.robot, 'reminder_manager'): + # 这个检查需要保留,是内部依赖 ctx.send_text("❌ 内部错误:提醒管理器未初始化。", ctx.msg.sender if ctx.is_group else "") - return True + return True # 确实是想处理,但内部错误,返回 True + # 在群聊中@用户 at_list = ctx.msg.sender if ctx.is_group else "" - parsed_ai_response = None - reminders = None - - plan = getattr(ctx, "_reminder_delete_plan", None) - if hasattr(ctx, "_reminder_delete_plan"): - delattr(ctx, "_reminder_delete_plan") - - if isinstance(plan, dict): - parsed_ai_response = plan.get("parsed_ai_response") - reminders = plan.get("reminders") - else: - try: - from .reminder_router import reminder_router - decision = reminder_router.route(ctx, raw_text) - except Exception as exc: - if ctx.logger: - ctx.logger.error(f"handle_delete_reminder 路由解析失败: {exc}", exc_info=True) - ctx.send_text("❌ 抱歉,无法理解您的删除提醒请求。请尝试换一种方式表达,或使用提醒ID进行精确删除。", at_list) - return True - - if not decision or decision.action != "delete": - ctx.send_text("❌ 抱歉,无法理解您的删除提醒请求。请尝试换一种方式表达,或使用提醒ID进行精确删除。", at_list) - return True - - if not decision.success: - ctx.send_text(decision.message or "❌ 抱歉,无法理解您的删除提醒请求。请尝试换一种方式表达,或使用提醒ID进行精确删除。", at_list) - return True - - payload = decision.payload or {} - parsed_ai_response = payload.get("parsed_ai_response") - reminders = payload.get("reminders") - - if reminders is None: - reminders = reminder_manager.list_reminders(ctx.msg.sender) + # --- 核心流程:直接使用 AI 分析 --- + # 3. 获取用户的所有提醒作为 AI 的上下文 + reminders = ctx.robot.reminder_manager.list_reminders(ctx.msg.sender) if not reminders: + # 如果用户没有任何提醒,直接告知 ctx.send_text("您当前没有任何提醒可供删除。", at_list) return True - if not isinstance(parsed_ai_response, dict) or "action" not in parsed_ai_response: - ctx.send_text("❌ 抱歉,无法理解您的删除提醒请求。请尝试换一种方式表达,或使用提醒ID进行精确删除。", at_list) - if ctx.logger: - ctx.logger.error("handle_delete_reminder 未获得有效的 AI 删除指令: %s", parsed_ai_response) - return True + # 将提醒列表转换为 JSON 字符串给 AI 参考 + try: + reminders_json_str = json.dumps(reminders, ensure_ascii=False, indent=2) + except Exception as e: + ctx.send_text("❌ 内部错误:准备数据给 AI 时出错。", at_list) + if ctx.logger: ctx.logger.error(f"序列化提醒列表失败: {e}", exc_info=True) + return True - action = parsed_ai_response.get("action") + # 4. 构造 AI Prompt (与之前相同,AI 需要能处理所有情况) + # 注意:确保 prompt 中的 {{ 和 }} 转义正确 + sys_prompt = """ +你是提醒删除助手。用户会提出删除提醒的请求。我会提供用户的**完整请求原文**,以及一个包含该用户所有当前提醒的 JSON 列表。 - if action == "delete_specific": - reminder_ids_to_delete = parsed_ai_response.get("ids", []) - if not reminder_ids_to_delete or not isinstance(reminder_ids_to_delete, list): - ctx.send_text("❌ AI 指示删除特定提醒,但未提供有效的 ID 列表。", at_list) - return True +你的任务是:根据用户请求和提醒列表,判断用户的意图,并确定要删除哪些提醒。用户可能要求删除特定提醒(通过描述内容、时间、ID等),也可能要求删除所有提醒。 - delete_results = [] - successful_deletes = 0 - deleted_descriptions = [] +**必须严格**按照以下几种 JSON 格式之一返回结果: - for r_id in reminder_ids_to_delete: - original_reminder = next((r for r in reminders if r['id'] == r_id), None) - desc = f"ID:{r_id[:6]}..." - if original_reminder: - desc = f"ID:{r_id[:6]}... 内容: \"{original_reminder['content'][:20]}...\"" +1. **删除特定提醒:** 如果你能明确匹配到一个或多个特定提醒,返回: + ```json + {{ + "action": "delete_specific", + "ids": ["", "", ...] + }} + ``` + (`ids` 列表中包含所有匹配到的提醒的 **完整 ID**) - success, message = reminder_manager.delete_reminder(ctx.msg.sender, r_id) - delete_results.append({"id": r_id, "success": success, "message": message, "description": desc}) - if success: - successful_deletes += 1 - deleted_descriptions.append(desc) +2. **删除所有提醒:** 如果用户明确表达了删除所有/全部提醒的意图,返回: + ```json + {{ + "action": "delete_all" + }} + ``` + +3. **需要澄清:** 如果用户描述模糊,匹配到多个可能的提醒,无法确定具体是哪个,返回: + ```json + {{ + "action": "clarify", + "message": "抱歉,您的描述可能匹配多个提醒,请问您想删除哪一个?(建议使用 ID 精确删除)", + "options": [ {{ "id": "id_prefix_1...", "description": "提醒1的简短描述(如: 周一 09:00 开会)" }}, ... ] + }} + ``` + (`message` 是给用户的提示,`options` 包含可能的选项及其简短描述和 ID 前缀) + +4. **未找到:** 如果在列表中找不到任何与用户描述匹配的提醒,返回: + ```json + {{ + "action": "not_found", + "message": "抱歉,在您的提醒列表中没有找到与您描述匹配的提醒。" + }} + ``` + +5. **错误:** 如果处理中遇到问题或无法理解请求,返回: + ```json + {{ + "action": "error", + "message": "抱歉,处理您的删除请求时遇到问题。" + }} + ``` + +**重要:** +- 仔细分析用户的**完整请求原文**和提供的提醒列表 JSON 进行匹配。 +- 用户请求中可能直接包含 ID,也需要你能识别并匹配。 +- 匹配时要综合考虑内容、时间、类型(一次性/每日/每周)等信息。 +- 如果返回 `delete_specific`,必须提供 **完整** 的 reminder ID。 +- **只输出 JSON 结构,不要包含任何额外的解释性文字。** + +用户的提醒列表如下 (JSON 格式): +{reminders_list_json} + +当前时间(供参考): {current_datetime} +""" + current_dt_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + try: + # 将用户的自然语言请求和提醒列表JSON传入Prompt + formatted_prompt = sys_prompt.format( + reminders_list_json=reminders_json_str, + current_datetime=current_dt_str + ) + except KeyError as e: + ctx.send_text("❌ 内部错误:构建 AI 请求时出错。", at_list) + if ctx.logger: ctx.logger.error(f"格式化删除提醒 prompt 失败: {e},可能是 sys_prompt 中的 {{}} 未正确转义", exc_info=True) + return True + + + # 5. 调用 AI (使用完整的用户原始输入) + q_for_ai = f"请根据以下用户完整请求,分析需要删除哪个提醒:\n{raw_text}" # 使用 raw_text + try: + if not hasattr(ctx, 'chat') or not ctx.chat: + raise ValueError("当前上下文中没有可用的AI模型") + + # 实现最多尝试3次解析AI回复的逻辑 + max_retries = 3 + retry_count = 0 + parsed_ai_response = None + ai_parsing_success = False + + while retry_count < max_retries and not ai_parsing_success: + # 如果是重试,更新提示信息 + if retry_count > 0: + enhanced_prompt = sys_prompt + f"\n\n**重要提示:** 这是第{retry_count+1}次尝试。你之前的回复格式有误,无法被解析为有效的JSON。请确保你的回复仅包含有效的JSON对象,没有其他任何文字。" + try: + formatted_prompt = enhanced_prompt.format( + reminders_list_json=reminders_json_str, + current_datetime=current_dt_str + ) + except Exception as e: + ctx.send_text("❌ 内部错误:构建重试请求时出错。", at_list) + if ctx.logger: ctx.logger.error(f"格式化重试 prompt 失败: {e}", exc_info=True) + return True + + # 在重试时提供更明确的信息 + retry_q = f"请再次分析以下删除提醒请求,并返回严格的JSON格式(第{retry_count+1}次尝试):\n{raw_text}" + q_for_ai = retry_q + + # 获取AI回答 + ai_response = ctx.chat.get_answer(q_for_ai, ctx.get_receiver(), system_prompt_override=formatted_prompt) + + # 6. 解析 AI 的 JSON 回复 + json_str = None + json_match_obj = re.search(r'\{.*\}', ai_response, re.DOTALL) + if json_match_obj: + json_str = json_match_obj.group(0) + else: + json_str = ai_response + + try: + parsed_ai_response = json.loads(json_str) + if not isinstance(parsed_ai_response, dict) or "action" not in parsed_ai_response: + raise ValueError("AI 返回的 JSON 格式不符合预期(缺少 action 字段)") + + # 如果能到这里,说明解析成功 + ai_parsing_success = True + + except (json.JSONDecodeError, ValueError) as e: + # JSON解析失败 + retry_count += 1 + if ctx.logger: + ctx.logger.warning(f"AI 删除提醒 JSON 解析失败(第{retry_count}次尝试): {ai_response}, 错误: {str(e)}") + + if retry_count >= max_retries: + # 达到最大重试次数,返回错误 + ctx.send_text(f"❌ 抱歉,无法理解您的删除提醒请求。请尝试换一种方式表达,或使用提醒ID进行精确删除。", at_list) + if ctx.logger: ctx.logger.error(f"解析AI删除提醒回复失败,已达到最大重试次数({max_retries}): {ai_response}") + return True + # 否则继续下一次循环重试 + + # 7. 根据 AI 指令执行操作 (与之前相同) + action = parsed_ai_response.get("action") + + if action == "delete_specific": + reminder_ids_to_delete = parsed_ai_response.get("ids", []) + if not reminder_ids_to_delete or not isinstance(reminder_ids_to_delete, list): + ctx.send_text("❌ AI 指示删除特定提醒,但未提供有效的 ID 列表。", at_list) + return True + + delete_results = [] + successful_deletes = 0 + deleted_descriptions = [] + + for r_id in reminder_ids_to_delete: + original_reminder = next((r for r in reminders if r['id'] == r_id), None) + desc = f"ID:{r_id[:6]}..." + if original_reminder: + desc = f"ID:{r_id[:6]}... 内容: \"{original_reminder['content'][:20]}...\"" + + success, message = ctx.robot.reminder_manager.delete_reminder(ctx.msg.sender, r_id) + delete_results.append({"id": r_id, "success": success, "message": message, "description": desc}) + if success: + successful_deletes += 1 + deleted_descriptions.append(desc) + + if successful_deletes == len(reminder_ids_to_delete): + reply_msg = f"✅ 已删除 {successful_deletes} 个提醒:\n" + "\n".join([f"- {d}" for d in deleted_descriptions]) + elif successful_deletes > 0: + reply_msg = f"⚠️ 部分提醒删除完成 ({successful_deletes}/{len(reminder_ids_to_delete)}):\n" + for res in delete_results: + status = "✅ 成功" if res["success"] else f"❌ 失败: {res['message']}" + reply_msg += f"- {res['description']}: {status}\n" + else: + reply_msg = f"❌ 未能删除 AI 指定的提醒。\n" + for res in delete_results: + reply_msg += f"- {res['description']}: 失败原因: {res['message']}\n" + + ctx.send_text(reply_msg.strip(), at_list) + + elif action == "delete_all": + success, message, count = ctx.robot.reminder_manager.delete_all_reminders(ctx.msg.sender) + ctx.send_text(message, at_list) + + elif action in ["clarify", "not_found", "error"]: + message_to_user = parsed_ai_response.get("message", "抱歉,我没能处理您的请求。") + if action == "clarify" and "options" in parsed_ai_response: + options_text = "\n可能的选项:\n" + "\n".join([f"- ID: {opt.get('id', 'N/A')} ({opt.get('description', '无描述')})" for opt in parsed_ai_response["options"]]) + message_to_user += options_text + ctx.send_text(message_to_user, at_list) - if successful_deletes == len(reminder_ids_to_delete): - reply_msg = f"✅ 已删除 {successful_deletes} 个提醒:\n" + "\n".join([f"- {d}" for d in deleted_descriptions]) - elif successful_deletes > 0: - reply_msg = f"⚠️ 部分提醒删除完成 ({successful_deletes}/{len(reminder_ids_to_delete)}):\n" - for res in delete_results: - status = "✅ 成功" if res["success"] else f"❌ 失败: {res['message']}" - reply_msg += f"- {res['description']}: {status}\n" else: - reply_msg = f"❌ 未能删除 AI 指定的提醒。\n" - for res in delete_results: - reply_msg += f"- {res['description']}: 失败原因: {res['message']}\n" + ctx.send_text("❌ AI 返回了无法理解的指令。", at_list) + if ctx.logger: ctx.logger.error(f"AI 删除提醒返回未知 action: {action} - Response: {ai_response}") - ctx.send_text(reply_msg.strip(), at_list) + return True # AI 处理流程结束 - elif action == "delete_all": - success, message, count = reminder_manager.delete_all_reminders(ctx.msg.sender) - ctx.send_text(message, at_list) - - elif action in ["clarify", "not_found", "error"]: - message_to_user = parsed_ai_response.get("message", "抱歉,我没能处理您的请求。") - if action == "clarify" and "options" in parsed_ai_response: - options_text = "\n可能的选项:\n" + "\n".join([f"- ID: {opt.get('id', 'N/A')} ({opt.get('description', '无描述')})" for opt in parsed_ai_response["options"]]) - message_to_user += options_text - ctx.send_text(message_to_user, at_list) - - else: - ctx.send_text("❌ AI 返回了无法理解的指令。", at_list) + except Exception as e: # 捕获 AI 调用和处理过程中的其他顶层错误 + ctx.send_text(f"❌ 处理删除提醒时发生意外错误。", at_list) if ctx.logger: - ctx.logger.error(f"AI 删除提醒返回未知 action: {action} - Parsed: {parsed_ai_response}") - - return True + ctx.logger.error(f"handle_delete_reminder AI 部分顶层错误: {e}", exc_info=True) + return True diff --git a/commands/reminder_router.py b/commands/reminder_router.py index 4bf2ad4..cfafb4b 100644 --- a/commands/reminder_router.py +++ b/commands/reminder_router.py @@ -1,9 +1,7 @@ import json import logging -import re from dataclasses import dataclass -from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple from .context import MessageContext @@ -14,231 +12,67 @@ REMINDER_ROUTER_HISTORY_LIMIT = 10 class ReminderDecision: action: str params: str = "" - payload: Any = None - message: str = "" - success: bool = True class ReminderRouter: - """二级提醒路由器,单次调用AI即可产出最终执行计划。""" + """二级提醒路由器,用于在提醒场景下判定具体操作""" def __init__(self) -> None: self.logger = logging.getLogger(__name__ + ".ReminderRouter") + def _build_prompt(self) -> str: + return ( + "你是提醒助手的路由器。根据用户关于提醒的说法,判断应该执行哪个操作,并返回 JSON。\n\n" + "### 可执行的操作:\n" + "- create:创建新的提醒,需要从用户话语中提取完整的提醒内容(包括时间、人称、事项等)。\n" + "- list:查询当前用户的所有提醒,当用户想要查看、看看、列出、有哪些提醒时使用。\n" + "- delete:删除提醒,当用户想取消、删除、移除某个提醒时使用。需要根据用户给出的描述、关键字或者编号帮助定位哪条提醒。\n\n" + "### 返回格式:\n" + "{\n" + ' "action": "create" | "list" | "delete",\n' + ' "content": "从用户话语中提取或保留的关键信息(删除或新增时必填)"\n' + "}\n\n" + "注意:只返回 JSON,不要包含多余文字。若无法识别,返回 create 并把原句放进 content。" + ) + def route(self, ctx: MessageContext, original_text: str) -> Optional[ReminderDecision]: chat_model = getattr(ctx, "chat", None) or getattr(ctx.robot, "chat", None) if not chat_model: self.logger.error("提醒路由器:缺少可用的聊天模型。") return None - reminder_manager = getattr(ctx.robot, "reminder_manager", None) - reminders: list[Dict[str, Any]] = [] - reminders_available = False - if reminder_manager: - try: - reminders = reminder_manager.list_reminders(ctx.msg.sender) - reminders_available = True - except Exception as exc: - self.logger.error("提醒路由器:获取提醒列表失败: %s", exc, exc_info=True) - reminders = [] - - if reminders_available: - reminders_section = json.dumps(reminders, ensure_ascii=False, indent=2) - reminder_list_block = f"- 用户当前提醒列表:\n{reminders_section}\n" - reminder_list_hint = ( - "提醒列表可用:是。你可以直接使用上面的提醒列表来匹配用户的删除请求。" - ) - else: - reminder_list_block = "- 该用户没有设置提醒。\n" - reminder_list_hint = ( - "提醒列表不可用:当前无法获取提醒列表;请依赖用户描述,并在必要时提示对方补充信息。" - ) - - current_dt_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - user_text = (original_text or "").strip() - - system_prompt = ( - "你是提醒助手的决策引擎,需要在**一次调用**中完成意图识别与计划生成。\n" - "请严格遵循以下说明:\n\n" - "### 背景数据\n" - f"- 当前准确时间:{current_dt_str}\n" - f"{reminder_list_block}" - f"- {reminder_list_hint}\n\n" - "### 总体目标\n" - "1. 根据用户原话判断动作:`create` / `list` / `delete`。\n" - "2. 为动作生成可直接执行的结构化计划;如果信息不足或存在歧义,返回 `success=false` 并在 `message` 中解释。\n" - "3. 输出的 JSON 必须可被机器解析,严禁附带多余说明。\n\n" - "### 详细要求\n" - "#### 1. 判定动作\n" - "- 用户想新增提醒:`action=\"create\"`\n" - "- 用户想查看提醒:`action=\"list\"`\n" - "- 用户想删除或取消提醒:`action=\"delete\"`\n" - "- 如用户表达多个意图,优先满足提醒相关需求;若实在无法判定,返回 `create` 并将 success 设为 false,提示需要澄清。\n\n" - "#### 2. create 计划\n" - "- `create.reminders` 必须是数组,每个元素都包含:\n" - " - `type`: \"once\" | \"daily\" | \"weekly\"\n" - " - `time`: once 使用 `YYYY-MM-DD HH:MM`;daily/weekly 使用 `HH:MM`,均需是未来时间。若用户只给出日期或时间,需要合理补全(优先使用当前年份、24 小时制)。\n" - " - `content`: 提醒内容,至少 2 个字符。\n" - " - `weekday`: 仅当 type=weekly 时填入整数 0-6(周一=0)。\n" - " - `extra`: 始终输出空对象 `{}`。\n" - "- 用户一次提出多个提醒时需要全部拆分成数组元素。\n" - "- 如果时间或内容无法确定,务必将 `success=false`,并在 `message` 里告诉用户需要补充的信息。\n\n" - "#### 3. delete 计划\n" - "- 如果无法访问提醒列表(reminders_available=false),尽量根据用户描述判断;若确实不能决定,返回 `success=false` 并说明原因。\n" - "- 计划结构保持以下字段:\n" - " {\n" - " \"action\": \"delete_specific\" | \"delete_all\" | \"clarify\" | \"not_found\" | \"error\",\n" - " \"ids\": [...], # delete_specific 时包含完整提醒 ID;否则忽略\n" - " \"message\": \"...\", # 告诉用户的提示,必须是自然语言\n" - " \"options\": [ # 仅 clarify 时可用,给出候选项(id 前缀 + 简要描述)\n" - " {\"id\": \"id_prefix...\", \"description\": \"示例:周一 09:00 开会\"}\n" - " ]\n" - " }\n" - "- `delete_all` 仅在用户明确提到“全部/所有提醒”时使用。\n" - "- 若用户描述不足以匹配具体提醒,优先返回 `clarify` 并列出可能选项;若列表为空则用 `not_found`。\n\n" - "#### 4. list 计划\n" - "- 设置 `action=\"list\"`,无需额外字段。若需要提示用户(例如没有提醒),可填入 `message`。\n\n" - "#### 5. 通用字段\n" - "- `success`: 布尔值。只要计划能够直接执行就为 true;一旦需要用户补充信息或遇到错误,就置为 false。\n" - "- `message`: 给用户的自然语言提示,可为空字符串。\n" - "- 若 `success=false`,务必提供有帮助的 `message`,说明缺失信息或发生的问题。\n\n" - "### 输出格式\n" - "{\n" - " \"action\": \"create\" | \"list\" | \"delete\",\n" - " \"success\": true/false,\n" - " \"message\": \"...\",\n" - " \"create\": {\"reminders\": [...]}, # 仅当 action=create 时必须提供\n" - " \"delete\": {...} # 仅当 action=delete 时必须提供\n" - "}\n" - "- 字段顺序不限,但必须是有效 JSON。\n" - "- 未使用的分支请完全省略(例如 action=list 时不要输出 create/delete)。\n\n" - "### 参考示例(仅供理解,注意替换为真实数据)\n" - "```json\n" - "{\n" - " \"action\": \"create\",\n" - " \"success\": true,\n" - " \"message\": \"\",\n" - " \"create\": {\n" - " \"reminders\": [\n" - " {\"type\": \"once\", \"time\": \"2025-05-20 09:00\", \"content\": \"提交季度报告\", \"extra\": {}},\n" - " {\"type\": \"weekly\", \"time\": \"19:30\", \"content\": \"篮球训练\", \"weekday\": 2, \"extra\": {}}\n" - " ]\n" - " }\n" - "}\n" - "```\n" - "```json\n" - "{\n" - " \"action\": \"delete\",\n" - " \"success\": true,\n" - " \"message\": \"\",\n" - " \"delete\": {\n" - " \"action\": \"delete_specific\",\n" - " \"ids\": [\"d6f2ab341234\"],\n" - " \"message\": \"\",\n" - " \"options\": []\n" - " }\n" - "}\n" - "```\n" - "```json\n" - "{\n" - " \"action\": \"list\",\n" - " \"success\": true,\n" - " \"message\": \"\"\n" - "}\n" - "```\n" - "始终只返回 JSON。\n" - ) - - user_prompt = f"用户原始请求:{user_text or '[空]'}" + prompt = self._build_prompt() + user_input = f"用户关于提醒的输入:{original_text}" try: ai_response = chat_model.get_answer( - user_prompt, + user_input, wxid=ctx.get_receiver(), - system_prompt_override=system_prompt, + system_prompt_override=prompt, specific_max_history=REMINDER_ROUTER_HISTORY_LIMIT, ) self.logger.debug("提醒路由器原始响应: %s", ai_response) + + json_match = json.loads(json_response(ai_response)) + action = json_match.get("action", "").strip().lower() + content = json_match.get("content", "").strip() + if action not in {"create", "list", "delete"}: + self.logger.warning("提醒路由器:未知动作 %s,默认为 create。", action) + action = "create" + return ReminderDecision(action=action, params=content) except Exception as exc: - self.logger.error("提醒路由器:调用模型失败: %s", exc, exc_info=True) + self.logger.error("提醒路由器解析失败: %s", exc, exc_info=True) return None - decision_data = self._extract_json(ai_response) - if decision_data is None: - self.logger.warning("提醒路由器:无法解析模型输出,返回 None") - return None - action = (decision_data.get("action") or "").strip().lower() - if action not in {"create", "list", "delete"}: - self.logger.warning("提醒路由器:未知动作 %s,默认为 create。", action) - action = "create" - - success = self._to_bool(decision_data.get("success", True)) - message = str(decision_data.get("message") or "").strip() - payload: Any = None - - if action == "create": - create_info = decision_data.get("create") or {} - reminders_plan = create_info.get("reminders") - if not isinstance(reminders_plan, list): - reminders_plan = [] - normalized_text = user_text - if normalized_text and not normalized_text.startswith("提醒我"): - normalized_text = f"提醒我{normalized_text}" - if not reminders_plan: - success = False - if not message: - message = "抱歉,我没有识别出可以设置的提醒。" - payload = { - "reminders": reminders_plan, - "raw_text": original_text, - "normalized_text": normalized_text, - } - - elif action == "delete": - delete_info = decision_data.get("delete") or {} - delete_action = (delete_info.get("action") or "").strip() - if not delete_action: - success = False - if not message: - message = "抱歉,我没能理解需要删除哪些提醒。" - payload = { - "parsed_ai_response": delete_info, - "reminders": reminders, - "raw_text": original_text, - "normalized_text": user_text, - } - - decision = ReminderDecision( - action=action, - params=original_text, - payload=payload, - message=message, - success=success, - ) - return decision - - def _extract_json(self, ai_response: str) -> Optional[Dict[str, Any]]: - if not isinstance(ai_response, str): - return None - match = re.search(r"\{.*\}", ai_response, re.DOTALL) - if not match: - return None - try: - return json.loads(match.group(0)) - except json.JSONDecodeError as exc: - self.logger.error("提醒路由器:JSON 解析失败: %s", exc) - return None - - @staticmethod - def _to_bool(value: Any) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"true", "1", "yes", "y"} - if isinstance(value, (int, float)): - return value != 0 - return bool(value) +def json_response(raw: str) -> str: + """从模型返回的文本中提取 JSON。""" + try: + start = raw.index("{") + end = raw.rindex("}") + 1 + return raw[start:end] + except ValueError: + return "{}" reminder_router = ReminderRouter()