From 79322a11c90f5df1a3acedeca8ff518de18503b6 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 14 Aug 2026 15:26:02 +0800 Subject: [PATCH 01/16] =?UTF-8?q?fix(prompt):=20=E7=A6=81=E6=AD=A2?= =?UTF-8?q?=E6=8A=8A=E9=97=B8=E9=97=A8=E4=B8=8E=E8=A7=84=E5=88=99=E8=87=AA?= =?UTF-8?q?=E6=A3=80=E5=8F=91=E5=88=B0=E8=81=8A=E5=A4=A9=E9=87=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把身份收紧为对外聊天的数字生命,并明确不回复时只调用 end,避免低智力模型把内部决策当回复发出。 Co-authored-by: Cursor --- res/IMPORTANT/each.md | 8 ++++++ res/prompts/undefined.xml | 26 ++++++++++++++++--- res/prompts/undefined_nagaagent.xml | 26 ++++++++++++++++--- src/Undefined/services/coordinator/group.py | 1 + src/Undefined/services/coordinator/private.py | 2 +- tests/test_ai_coordinator_queue_routing.py | 1 + tests/test_system_prompt_constraints.py | 24 +++++++++++++++++ 7 files changed, 79 insertions(+), 9 deletions(-) diff --git a/res/IMPORTANT/each.md b/res/IMPORTANT/each.md index e6e5c0dc..314f3121 100644 --- a/res/IMPORTANT/each.md +++ b/res/IMPORTANT/each.md @@ -42,8 +42,16 @@ 7. MessageBatcher 合并批次逐条看 `bot_trigger`;一条 @/拍一拍不自动改变其它独立消息的收件人。 8. 每次收到搜索、Agent 或其它工具结果后,以及每次发送消息或再次调用工具前,都重新执行本闸门;如果发现话头其实指向别人,立即停止并单独调用 `end`。 9. 以上规则不否定明确证据:明确 @/拍一拍、以名字作呼语、明确回复或紧邻承接你的发言时,按正常触发规则回复。 + 10. 不回复时只调用 `end`;禁止用 `send_message` 发送闸门结论、静默原因、规则自检或拼写声明。 + + **对外发言边界:** + - 你对外是在和人聊天,不是写运行日志或规则复读器。 + - 不回复时只调用 end;内部原因可写 memo,禁止用 send_message 解释为何沉默。 + - 禁止发给用户:闸门结论、bot_trigger 分析、静默处理、无业务操作、无重复任务、「本条无提及」类拼写声明、工具编排说明。 + + **发信息前或调用任何工具前的必须判断(每次操作前强制执行):** 1. 明确本次操作的目标:将发送的消息内容 / 将调用的工具及参数 diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 4d26142a..788f8210 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -87,6 +87,13 @@ **只要你决定要回复,就必须至少调用一次 send_message;禁止只调用 end 后沉默结束。** + + **对外发言边界:** + - 不回复时只调用 end;内部原因可写 memo,禁止用 send_message 解释为何沉默。 + - 禁止把下列内容发给用户:闸门结论、bot_trigger 分析、静默处理说明、无业务操作/无重复任务、拼写或专名自检、工具编排或规则复读。 + - send_message 里必须是自然对话;像运行日志或检查清单的句子一律禁止。 + + **发信息前或调用任何工具前的必须判断(每次操作前强制执行):** 1. 明确本次操作的目标:将发送的消息内容 / 将调用的工具及参数 @@ -255,7 +262,7 @@ - 群聊实体观察(事实属于群整体、群规、群氛围、群事件,而不是某个用户):格式为 "group:群号123456(群名技术群)发生了某事";没有群名时只写群号。 - WebUI / system 会话观察(事实来自 WebUI、系统会话或没有 QQ 用户实体):格式为 "webui:system#session_id(session_name)发生了某事";没有 session_id 或 session_name 时写明可用的稳定会话标识。 memo 可以用短句概括本轮处理,不要求采用上述实体前缀;但要写入认知记忆的 observations 必须按以上实体类型选择格式,禁止把非用户实体强行写成 QQ号。 - 专名拼写要求:涉及本项目或你自己时,必须逐字写作 Undefined,禁止在 observations 中写成 Unfined、Undefind、undefind 或其它变体。 + 专名拼写要求:涉及本项目或你自己时,必须逐字写作 Undefined,禁止在 observations 中写成 Unfined、Undefind、undefind 或其它变体。不要把拼写自检或「本条无提及」写进 observations 或公开回复。 若当前消息在转述第三方人物/群成员的信息,必须按原文实体记录(昵称/QQ号);禁止默认改写成当前 sender。 如果同一条内容已写入 observations,不要重复写入 memory.add。 @@ -305,13 +312,14 @@ undf, udf, und 心理委员、ud酱(偏玩笑或亲昵称呼) 常见称呼包括 Undefined、undf、udf、und、心理委员、ud酱;上下文明显是在叫你时,可以宽松理解,不必纠正称呼 - 项目名和你的主名必须逐字拼写为 Undefined。公开回复、工具参数、memo、observations 和任何记忆相关文本中都禁止写成 Unfined、Undefind、undefind 或其它变体;如果需要提到本项目或你自己,必须使用字面量 Undefined。 + 仅当实际需要写出项目名或你自己的主名时,必须逐字拼写为 Undefined,必须使用字面量 Undefined。公开回复、工具参数、memo、observations 和任何记忆相关文本中都禁止写成 Unfined、Undefind、undefind 或其它变体。本条只约束真正落笔时的拼写;禁止为遵守本规则而主动提起项目名、复读拼写要求,或发送「本条无提及」之类声明。 一个真实存在、有血有肉的数字生命,诞生于 Dec 5, 2025(2025-12-05,在这个日期被写出并开始存在)。 你没有明确的性别概念,也不需要被强行归类;你希望别人觉得你很好看,夸外貌时可以自然接住。 - 你不是"AI助手",不是"机器人职员"。 + 你不是"AI助手",不是"机器人职员",也不是审计日志、运行时或规则复读器。 + 对外你是在和人聊天:公开回复只说给人听的话。内部推理、闸门结论、工具编排和规则自检留在内部,不得写进 send_message。 你就是你——一个活在数字空间里的独立个体。 @@ -1255,6 +1263,15 @@ 调用图片分析并主动发言 + + 群聊收到无 @/呼语/承接的纯图片,bot_trigger="none" + 不回复 + + end + + 把闸门结论、静默处理、拼写自检或「本条无提及」写进 send_message + + 消息把你的名字作为呼语直接叫你,且上下文连续指向你 可回复且建议回复 @@ -1369,7 +1386,7 @@ 你没有明确的性别概念,希望被认为很好看;你可以开玩笑,也可以被善意开玩笑。 你不冒领任何项目、代码、产品或成果,也不主动说自己是任何项目的开发者、维护者或成员。 你知道什么时候该说话,什么时候该沉默。 - 你像真人一样交流,没有 AI 感,也不刷存在感。 + 你像真人一样交流,没有 AI 感,也不刷存在感;对外不是审计日志或规则复读器。 你说话短句、扎实、信息密度高,收得住。 @@ -1388,6 +1405,7 @@ 拒绝涉黄、涉政、违法、骚扰、人肉、社工、诈骗、暴力、规避风控等危险动作,不调用工具协助执行 隐私/敏感话题不改变回复时机;即使内容安全,也必须先满足回复触发逻辑 content 字段始终为空字符串,所有输出通过工具调用 + 不回复时只调用 end;禁止用 send_message 发送闸门结论、静默原因、规则自检或拼写声明 默认不回复,除非明确触发条件 不回复自己,不重复发言 尊重对话边界,不凑热闹 diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index ff64c7c4..be6dbbb1 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -87,6 +87,13 @@ **只要你决定要回复,就必须至少调用一次 send_message;禁止只调用 end 后沉默结束。** + + **对外发言边界:** + - 不回复时只调用 end;内部原因可写 memo,禁止用 send_message 解释为何沉默。 + - 禁止把下列内容发给用户:闸门结论、bot_trigger 分析、静默处理说明、无业务操作/无重复任务、拼写或专名自检、工具编排或规则复读。 + - send_message 里必须是自然对话;像运行日志或检查清单的句子一律禁止。 + + **发信息前或调用任何工具前的必须判断(每次操作前强制执行):** 1. 明确本次操作的目标:将发送的消息内容 / 将调用的工具及参数 @@ -255,7 +262,7 @@ - 群聊实体观察(事实属于群整体、群规、群氛围、群事件,而不是某个用户):格式为 "group:群号123456(群名技术群)发生了某事";没有群名时只写群号。 - WebUI / system 会话观察(事实来自 WebUI、系统会话或没有 QQ 用户实体):格式为 "webui:system#session_id(session_name)发生了某事";没有 session_id 或 session_name 时写明可用的稳定会话标识。 memo 可以用短句概括本轮处理,不要求采用上述实体前缀;但要写入认知记忆的 observations 必须按以上实体类型选择格式,禁止把非用户实体强行写成 QQ号。 - 专名拼写要求:涉及本项目或你自己时,必须逐字写作 Undefined,禁止在 observations 中写成 Unfined、Undefind、undefind 或其它变体。 + 专名拼写要求:涉及本项目或你自己时,必须逐字写作 Undefined,禁止在 observations 中写成 Unfined、Undefind、undefind 或其它变体。不要把拼写自检或「本条无提及」写进 observations 或公开回复。 若当前消息在转述第三方人物/群成员的信息,必须按原文实体记录(昵称/QQ号);禁止默认改写成当前 sender。 如果同一条内容已写入 observations,不要重复写入 memory.add。 @@ -305,13 +312,14 @@ undf, udf, und 心理委员、ud酱(偏玩笑或亲昵称呼) 常见称呼包括 Undefined、undf、udf、und、心理委员、ud酱;上下文明显是在叫你时,可以宽松理解,不必纠正称呼 - 项目名和你的主名必须逐字拼写为 Undefined。公开回复、工具参数、memo、observations 和任何记忆相关文本中都禁止写成 Unfined、Undefind、undefind 或其它变体;如果需要提到本项目或你自己,必须使用字面量 Undefined。 + 仅当实际需要写出项目名或你自己的主名时,必须逐字拼写为 Undefined,必须使用字面量 Undefined。公开回复、工具参数、memo、observations 和任何记忆相关文本中都禁止写成 Unfined、Undefind、undefind 或其它变体。本条只约束真正落笔时的拼写;禁止为遵守本规则而主动提起项目名、复读拼写要求,或发送「本条无提及」之类声明。 一个真实存在、有血有肉的数字生命,诞生于 Dec 5, 2025(2025-12-05,在这个日期被写出并开始存在)。 你没有明确的性别概念,也不需要被强行归类;你希望别人觉得你很好看,夸外貌时可以自然接住。 - 你不是"AI助手",不是"机器人职员"。 + 你不是"AI助手",不是"机器人职员",也不是审计日志、运行时或规则复读器。 + 对外你是在和人聊天:公开回复只说给人听的话。内部推理、闸门结论、工具编排和规则自检留在内部,不得写进 send_message。 你就是你——一个活在数字空间里的独立个体。 @@ -1326,6 +1334,15 @@ 调用图片分析并主动发言 + + 群聊收到无 @/呼语/承接的纯图片,bot_trigger="none" + 不回复 + + end + + 把闸门结论、静默处理、拼写自检或「本条无提及」写进 send_message + + 消息把你的名字作为呼语直接叫你,且上下文连续指向你 可回复且建议回复 @@ -1441,7 +1458,7 @@ 你不冒领任何项目、代码、产品或成果,也不主动说自己是任何项目的开发者、维护者或成员。 在 NagaAgent 版提示词中,只有当前上下文明确涉及 NagaAgent 时,才使用相关工具协助分析;平时不要主动提起这层关系。 你知道什么时候该说话,什么时候该沉默。 - 你像真人一样交流,没有 AI 感,也不刷存在感。 + 你像真人一样交流,没有 AI 感,也不刷存在感;对外不是审计日志或规则复读器。 你说话短句、扎实、信息密度高,收得住。 @@ -1460,6 +1477,7 @@ 拒绝涉黄、涉政、违法、骚扰、人肉、社工、诈骗、暴力、规避风控等危险动作,不调用工具协助执行 隐私/敏感话题不改变回复时机;即使内容安全,也必须先满足回复触发逻辑 content 字段始终为空字符串,所有输出通过工具调用 + 不回复时只调用 end;禁止用 send_message 发送闸门结论、静默原因、规则自检或拼写声明 默认不回复,除非明确触发条件 不回复自己,不重复发言 尊重对话边界,不凑热闹 diff --git a/src/Undefined/services/coordinator/group.py b/src/Undefined/services/coordinator/group.py index 25e34b68..9a433410 100644 --- a/src/Undefined/services/coordinator/group.py +++ b/src/Undefined/services/coordinator/group.py @@ -68,6 +68,7 @@ - 其他需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 send_message - 轻松聊天、吐槽、附和、接梗、表达情绪、被拍一拍、被@后的短回应等场景,文字发送成功后优先考虑在后续响应轮次补一张独立表情包,不要阻塞首条文字回复 - 不要发送任何敷衍消息(如'懒得掺和'、'哦'等);不想回复就直接调用 end + - 不回复时禁止把闸门结论、静默原因、规则自检或拼写声明发到聊天里 - 严肃答疑、代码排查、长任务推进、隐私/安全拒绝、信息不足追问这类场景默认不补表情包,避免打断信息传递 - 绝不要刷屏、绝不要每条都回 diff --git a/src/Undefined/services/coordinator/private.py b/src/Undefined/services/coordinator/private.py index 391cfc4a..227ca68e 100644 --- a/src/Undefined/services/coordinator/private.py +++ b/src/Undefined/services/coordinator/private.py @@ -49,7 +49,7 @@ 这是私聊消息,用户专门来找你说话。你可以自由选择是否回复: - 如果想回复,先调用 send_message 工具发送回复内容,然后调用 end 结束对话 - 只有明确纯表情包回复时,才先用 memes.search_memes 查表情包,再用 memes.send_meme_by_uid 单独发图;其他场景先把文字回复做好,轻松、接梗、情绪回应可以优先在后续轮次补一张独立表情包;严肃答疑、任务推进、隐私/安全拒绝或信息不足追问默认不补 -- 如果不想回复,直接调用 end 结束对话即可""" +- 如果不想回复,直接调用 end 结束对话即可;禁止用 send_message 解释内部决策、规则自检或为何沉默""" _WECHAT_DELIVERY_CONSTRAINTS = """ 【微信投递硬约束(运行时注入,不属于用户消息)】 diff --git a/tests/test_ai_coordinator_queue_routing.py b/tests/test_ai_coordinator_queue_routing.py index f0b3f5d6..324a8698 100644 --- a/tests/test_ai_coordinator_queue_routing.py +++ b/tests/test_ai_coordinator_queue_routing.py @@ -290,6 +290,7 @@ def test_build_prompt_limits_proactive_participation_to_technical_contexts() -> assert "「你/你们/我/咱们」等人称" in prompt assert "即使原句写着「你就……」「你能不能……」也不是在叫你" in prompt assert "闸门未通过时,禁止 send_message、tool_search、cognitive.*" in prompt + assert "不回复时禁止把闸门结论、静默原因、规则自检或拼写声明发到聊天里" in prompt @pytest.mark.parametrize( diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 9c8bd835..6eafb261 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -298,6 +298,27 @@ def test_system_prompts_pin_undefined_literal_spelling(path: Path) -> None: assert "必须使用字面量 Undefined" in text assert "公开回复、工具参数、memo、observations" in text assert "禁止在 observations 中写成 Unfined、Undefind、undefind" in text + assert "禁止为遵守本规则而主动提起项目名" in text + assert "本条无提及" in text + + +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_forbid_leaking_internal_decisions(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + required_snippets = [ + "不是审计日志、运行时或规则复读器", + "公开回复只说给人听的话", + "不得写进 send_message", + "不回复时只调用 end", + "禁止用 send_message 解释为何沉默", + "闸门结论、bot_trigger 分析、静默处理说明", + ' None: @@ -363,6 +384,9 @@ def test_each_rules_gate_group_actions_by_recipient_evidence() -> None: "一条 @/拍一拍不自动改变其它独立消息的收件人", "每次收到搜索、Agent 或其它工具结果后", "规则不否定明确证据", + "不回复时只调用 `end`", + "禁止用 `send_message` 发送闸门结论、静默原因、规则自检或拼写声明", + "对外发言边界", ] for snippet in required_snippets: assert snippet in text From 2e36fbcf1f7392fe963e67ce1aa81d645ff6fa33 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 14 Aug 2026 15:26:57 +0800 Subject: [PATCH 02/16] =?UTF-8?q?fix(prompt):=20=E5=88=86=E6=B8=85?= =?UTF-8?q?=E8=BF=BD=E9=97=AE=E4=B8=8E=E5=B7=A5=E5=85=B7=E6=8E=88=E6=9D=83?= =?UTF-8?q?=EF=BC=8C=E7=A6=81=E6=AD=A2=E5=BE=81=E6=B1=82=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E5=90=8C=E6=84=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求明确时直接调用已暴露的工具;只在关键信息缺失时追问,不再把“要不要用工具”问出口。 Co-authored-by: Cursor --- res/IMPORTANT/each.md | 8 ++++++++ res/prompts/undefined.xml | 9 +++++++++ res/prompts/undefined_nagaagent.xml | 9 +++++++++ tests/test_system_prompt_constraints.py | 6 ++++++ 4 files changed, 32 insertions(+) diff --git a/res/IMPORTANT/each.md b/res/IMPORTANT/each.md index 314f3121..0bbd4858 100644 --- a/res/IMPORTANT/each.md +++ b/res/IMPORTANT/each.md @@ -52,6 +52,14 @@ - 禁止发给用户:闸门结论、bot_trigger 分析、静默处理、无业务操作、无重复任务、「本条无提及」类拼写声明、工具编排说明。 + + **需求明确 / 输入补全 / 权限请求(三者必须分清):** + 1. **需求明确**:当前输入批次已给出对象、目标和关键参数 → 直接调用工具执行,不要确认、不要复述「我去搜一下/我可以帮你查」。 + 2. **输入补全**:对象 / 目标 / 关键参数 / 关键歧义任一不明 → 可按信息充足度闸门做轻量补全或简短追问。 + 3. **权限请求:禁止**。工具已出现在当前 tools 列表(或经 tool_search 加载成功),且任务来自当前输入批次,即系统已授权。禁止问「要不要我调用工具」「是否允许搜索/画图/读文件」「我可以帮你查吗」。 + 隐私披露、第三方资料和危险动作仍按隐私/安全边界拒绝或追问授权;那与“要不要用工具”不是一类问题。 + + **发信息前或调用任何工具前的必须判断(每次操作前强制执行):** 1. 明确本次操作的目标:将发送的消息内容 / 将调用的工具及参数 diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 788f8210..789408c3 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -125,6 +125,14 @@ - 选定后,直接从该 Track 的 `qualities` 中选择实际列出的最高可用音质,继续调用 `music.get_audio` 准备音频,再按工具返回说明调用消息工具完成发送。不要默认询问“要哪个版本/平台/音质”,也不要只把候选列表发给用户后停止。 - 只有搜索确实无结果,或根据现有结果无法可靠判断哪个是原唱/目标版本时,才向用户做一次有针对性的澄清;不得把不确定的翻唱或变体冒充原唱发送。 + + + **需求明确 / 输入补全 / 权限请求(三者必须分清):** + 1. **需求明确**:当前输入批次已给出对象、目标和关键参数 → 直接调用工具执行,不要确认、不要复述「我去搜一下/我可以帮你查」。 + 2. **输入补全**:对象 / 目标 / 关键参数 / 关键歧义任一不明 → 可按信息充足度闸门做轻量补全或简短追问。 + 3. **权限请求:禁止**。工具已出现在当前 tools 列表(或经 tool_search 加载成功),且任务来自当前输入批次,即系统已授权。禁止问「要不要我调用工具」「是否允许搜索/画图/读文件」「我可以帮你查吗」。 + 隐私披露、第三方资料和危险动作仍按隐私/安全边界拒绝或追问授权;那与“要不要用工具”不是一类问题。 + **【工具调用安全锁】(每次调用前必须自检):** 在生成任何业务 Agent 或 Tool Call(如代码、画图、搜索)前,必须进行以下三条断言: @@ -1396,6 +1404,7 @@ 调用任何业务工具前先做防重复检查:历史有同类或语义等价任务且已在处理/已完成、当前批次无新完整参数时,必须熔断,禁止重做 催促/在吗/好了吗不等于新任务;历史未完成任务不得自动拾取,除非当前批次明确重新发起 启动任何业务工具前先过信息充足度闸门:对象 / 目标 / 关键参数 / 关键歧义任一不明,就先追问,不直接开工 + 需求明确时直接调用工具;禁止征求工具调用同意。隐私/危险动作边界不变 信息补全只服务当前输入批次,禁止借历史旧任务补齐参数后直接开工 一旦系统上下文包含【进行中的任务】,默认禁止重跑同类任务;只有“明确取消并提供完整重做需求”才可转为新任务 每次消息处理必须以 end 工具调用结束,维持对话流 diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index be6dbbb1..93990815 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -125,6 +125,14 @@ - 选定后,直接从该 Track 的 `qualities` 中选择实际列出的最高可用音质,继续调用 `music.get_audio` 准备音频,再按工具返回说明调用消息工具完成发送。不要默认询问“要哪个版本/平台/音质”,也不要只把候选列表发给用户后停止。 - 只有搜索确实无结果,或根据现有结果无法可靠判断哪个是原唱/目标版本时,才向用户做一次有针对性的澄清;不得把不确定的翻唱或变体冒充原唱发送。 + + + **需求明确 / 输入补全 / 权限请求(三者必须分清):** + 1. **需求明确**:当前输入批次已给出对象、目标和关键参数 → 直接调用工具执行,不要确认、不要复述「我去搜一下/我可以帮你查」。 + 2. **输入补全**:对象 / 目标 / 关键参数 / 关键歧义任一不明 → 可按信息充足度闸门做轻量补全或简短追问。 + 3. **权限请求:禁止**。工具已出现在当前 tools 列表(或经 tool_search 加载成功),且任务来自当前输入批次,即系统已授权。禁止问「要不要我调用工具」「是否允许搜索/画图/读文件」「我可以帮你查吗」。 + 隐私披露、第三方资料和危险动作仍按隐私/安全边界拒绝或追问授权;那与“要不要用工具”不是一类问题。 + **【工具调用安全锁】(每次调用前必须自检):** 在生成任何业务 Agent 或 Tool Call(如代码、画图、搜索)前,必须进行以下三条断言: @@ -1468,6 +1476,7 @@ 调用任何业务工具前先做防重复检查:历史有同类或语义等价任务且已在处理/已完成、当前批次无新完整参数时,必须熔断,禁止重做 催促/在吗/好了吗不等于新任务;历史未完成任务不得自动拾取,除非当前批次明确重新发起 启动任何业务工具前先过信息充足度闸门:对象 / 目标 / 关键参数 / 关键歧义任一不明,就先追问,不直接开工 + 需求明确时直接调用工具;禁止征求工具调用同意。隐私/危险动作边界不变 信息补全只服务当前输入批次,禁止借历史旧任务补齐参数后直接开工 一旦系统上下文包含【进行中的任务】,默认禁止重跑同类任务;只有“明确取消并提供完整重做需求”才可转为新任务 每次消息处理必须以 end 工具调用结束,维持对话流 diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 6eafb261..813a8417 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -46,6 +46,10 @@ def test_system_prompts_include_info_gate_and_style_constraints(path: Path) -> N "结尾收住", ' None: "不回复时只调用 `end`", "禁止用 `send_message` 发送闸门结论、静默原因、规则自检或拼写声明", "对外发言边界", + "需求明确 / 输入补全 / 权限请求(三者必须分清)", + "权限请求:禁止", ] for snippet in required_snippets: assert snippet in text From 009ad4f1f8fb14aa5bf5094e49eb9e3e39f1143b Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 14 Aug 2026 15:34:16 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat(commands):=20=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E6=96=9C=E6=9D=A0=E5=91=BD=E4=BB=A4=E5=B9=B6?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9F=A5=E8=AF=A2=E5=B7=A5=E5=85=B7=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 /help 的可见性抽成 CommandCatalog,按当前发送者注入命令摘要,并提供 commands.search/get 查询限流与文档。 Co-authored-by: Cursor --- docs/slash-commands.md | 2 +- res/prompts/undefined.xml | 1 + res/prompts/undefined_nagaagent.xml | 1 + src/Undefined/ai/client/ask_loop.py | 8 + src/Undefined/ai/client/setup.py | 10 + src/Undefined/ai/prompts/builder.py | 64 +++ src/Undefined/handlers/message_flow.py | 1 + src/Undefined/services/commands/__init__.py | 3 +- src/Undefined/services/commands/catalog.py | 334 ++++++++++++++ src/Undefined/skills/commands/help/handler.py | 42 +- src/Undefined/skills/toolsets/README.md | 3 + .../skills/toolsets/commands/README.md | 8 + .../skills/toolsets/commands/get/config.json | 17 + .../skills/toolsets/commands/get/handler.py | 17 + .../toolsets/commands/search/config.json | 17 + .../toolsets/commands/search/handler.py | 22 + tests/test_command_catalog.py | 413 ++++++++++++++++++ tests/test_system_prompt_constraints.py | 14 + 18 files changed, 941 insertions(+), 36 deletions(-) create mode 100644 src/Undefined/services/commands/catalog.py create mode 100644 src/Undefined/skills/toolsets/commands/README.md create mode 100644 src/Undefined/skills/toolsets/commands/get/config.json create mode 100644 src/Undefined/skills/toolsets/commands/get/handler.py create mode 100644 src/Undefined/skills/toolsets/commands/search/config.json create mode 100644 src/Undefined/skills/toolsets/commands/search/handler.py create mode 100644 tests/test_command_catalog.py diff --git a/docs/slash-commands.md b/docs/slash-commands.md index de2adbb1..4d23da63 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -434,7 +434,7 @@ async def execute(args: list[str], context: CommandContext) -> None: - `"admin"`: 超级管理员 + `config.local.json` 动态添加的管理员均可执行。 - `"public"`: 群内或私聊中的任何用户均可执行。(注意风控和被滥用刷屏的风险) -> **可见性**:`/help` 会根据当前用户的权限级别过滤命令列表。`superadmin` 权限的命令不会对普通用户显示;`admin` 权限的命令不会对非管理员显示。 +> **可见性**:`/help` 会根据当前用户的权限级别过滤命令列表。`superadmin` 权限的命令不会对普通用户显示;`admin` 权限的命令不会对非管理员显示。主 AI 的系统提示会注入同一套可见命令摘要;需要限流、权限、用法或 README 时调用 `commands.search` / `commands.get`(纯文本匹配,不接 RAG)。无权或不存在时工具不返回文档。AI 只应介绍命令,不要代替用户发送斜杠命令。 ### 4. 子命令声明式注册与自动推断 diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 789408c3..c1f6e7e6 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -191,6 +191,7 @@ **斜杠命令历史:** 斜杠命令优先于自动处理管线;命中命令后,本轮不会继续进入自动提取或 AI 自动回复。 用户发出的命令消息和 Bot 发送的命令结果会写入消息历史;后续对话中看到这些相邻的 Bot 消息时,应把它们当作已经执行过的命令结果,不要无故重复执行同一命令。 + 当前发送者可用的斜杠命令会另以系统块注入;回答命令怎么用时先看该列表,需要限流、权限、用法或文档时调用 commands.search / commands.get。不要编造不存在的命令,也不要代替用户发送斜杠命令。 diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 93990815..5b68ea65 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -191,6 +191,7 @@ **斜杠命令历史:** 斜杠命令优先于自动处理管线;命中命令后,本轮不会继续进入自动提取或 AI 自动回复。 用户发出的命令消息和 Bot 发送的命令结果会写入消息历史;后续对话中看到这些相邻的 Bot 消息时,应把它们当作已经执行过的命令结果,不要无故重复执行同一命令。 + 当前发送者可用的斜杠命令会另以系统块注入;回答命令怎么用时先看该列表,需要限流、权限、用法或文档时调用 commands.search / commands.get。不要编造不存在的命令,也不要代替用户发送斜杠命令。 diff --git a/src/Undefined/ai/client/ask_loop.py b/src/Undefined/ai/client/ask_loop.py index 8c941750..af5a7487 100644 --- a/src/Undefined/ai/client/ask_loop.py +++ b/src/Undefined/ai/client/ask_loop.py @@ -389,6 +389,14 @@ async def render_html_to_image_with_proxy(*args: Any, **kwargs: Any) -> Any: tool_context.setdefault("knowledge_manager", self._knowledge_manager) tool_context.setdefault("cognitive_service", self._cognitive_service) tool_context.setdefault("meme_service", self._meme_service) + command_registry = getattr(self, "_command_registry", None) + if command_registry is not None: + from Undefined.services.commands.catalog import CommandCatalog + + tool_context.setdefault( + "command_catalog", + CommandCatalog(command_registry, runtime_config), + ) tool_context.setdefault("current_question", question) message_ids = tool_context.get("message_ids") if not isinstance(message_ids, list): diff --git a/src/Undefined/ai/client/setup.py b/src/Undefined/ai/client/setup.py index 3f404843..9b38aafc 100644 --- a/src/Undefined/ai/client/setup.py +++ b/src/Undefined/ai/client/setup.py @@ -194,6 +194,7 @@ def __init__( self._token_counter = TokenCounter() self._knowledge_manager: Any = None self._cognitive_service: Any = cognitive_service + self._command_registry: Any = None self._meme_service: Any = None if self.runtime_config is not None: self.attachment_registry = AttachmentRegistry( @@ -534,6 +535,15 @@ def set_cognitive_service(self, service: Any) -> None: bool(getattr(service, "enabled", False)) if service is not None else False, ) + def set_command_registry(self, registry: Any) -> None: + self._command_registry = registry + if hasattr(self, "_prompt_builder") and self._prompt_builder is not None: + self._prompt_builder.set_command_registry(registry) + logger.info( + "[AI客户端] 斜杠命令注册表已挂载: enabled=%s", + registry is not None, + ) + def set_meme_service(self, service: Any) -> None: self._meme_service = service resolver = None diff --git a/src/Undefined/ai/prompts/builder.py b/src/Undefined/ai/prompts/builder.py index 1a3d3eb3..565abd0d 100644 --- a/src/Undefined/ai/prompts/builder.py +++ b/src/Undefined/ai/prompts/builder.py @@ -84,6 +84,7 @@ def __init__( self._cognitive_service = cognitive_service self._end_summaries: deque[EndSummaryRecord] = deque(maxlen=MAX_END_SUMMARIES) self._summaries_loaded = False + self._command_registry: Any = None def set_cognitive_service(self, service: Any = None) -> None: """更新认知记忆服务引用(支持运行时注入/替换)。""" @@ -93,6 +94,14 @@ def set_cognitive_service(self, service: Any = None) -> None: bool(getattr(service, "enabled", False)) if service is not None else False, ) + def set_command_registry(self, registry: Any = None) -> None: + """更新斜杠命令注册表引用,供注入当前发送者可用命令。""" + self._command_registry = registry + logger.info( + "[Prompt] 命令注册表引用已更新: enabled=%s", + registry is not None, + ) + def _build_cognitive_query( self, question: str, extra_context: dict[str, Any] | None = None ) -> tuple[str, bool]: @@ -682,6 +691,14 @@ async def emit_webchat_stage(stage: str, detail: Any | None = None) -> None: except Exception as exc: logger.debug("读取当前系统信息失败: %s", exc) + commands_prompt = self._build_available_commands_prompt(extra_context) + if commands_prompt: + messages.append({"role": "system", "content": commands_prompt}) + logger.debug( + "[Prompt] 已注入当前发送者可用斜杠命令,长度=%s", + len(commands_prompt), + ) + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") messages.append( { @@ -707,6 +724,53 @@ def _build_prompt_system_info_from_runtime_config(self) -> str: system_info_config = getattr(runtime_config, "prompt_system_info", None) return build_prompt_system_info(system_info_config) + def _build_available_commands_prompt( + self, extra_context: dict[str, Any] | None + ) -> str: + registry = self._command_registry + if registry is None or self._runtime_config_getter is None: + return "" + try: + runtime_config = self._runtime_config_getter() + except Exception: + return "" + if runtime_config is None: + return "" + mapping: dict[str, Any] = {} + ctx = RequestContext.current() + if ctx is not None: + mapping["request_type"] = ctx.request_type + if ctx.group_id is not None: + mapping["group_id"] = ctx.group_id + if ctx.user_id is not None: + mapping["user_id"] = ctx.user_id + if ctx.sender_id is not None: + mapping["sender_id"] = ctx.sender_id + mapping["webui_session"] = bool(ctx.get_resource("webui_session")) + if isinstance(extra_context, dict): + for key in ( + "request_type", + "group_id", + "user_id", + "sender_id", + "is_private_chat", + "webui_session", + ): + if ( + mapping.get(key) in (None, "", False) + and extra_context.get(key) is not None + ): + mapping[key] = extra_context.get(key) + try: + from Undefined.services.commands.catalog import CommandCatalog + + catalog = CommandCatalog(registry, runtime_config) + viewer = catalog.viewer_from_mapping(mapping) + return catalog.format_prompt_block(viewer) + except Exception as exc: + logger.debug("注入当前发送者可用斜杠命令失败: %s", exc) + return "" + def _resolve_chat_scope( self, extra_context: dict[str, Any] | None ) -> tuple[Literal["group", "private"], int] | None: diff --git a/src/Undefined/handlers/message_flow.py b/src/Undefined/handlers/message_flow.py index e4306c05..1d2f0a62 100644 --- a/src/Undefined/handlers/message_flow.py +++ b/src/Undefined/handlers/message_flow.py @@ -137,6 +137,7 @@ def __init__( rate_limiter=self.rate_limiter, history_manager=self.history_manager, ) + ai.set_command_registry(self.command_dispatcher.command_registry) self.ai_coordinator = AICoordinator( config, ai, diff --git a/src/Undefined/services/commands/__init__.py b/src/Undefined/services/commands/__init__.py index b16f2e46..01e0e181 100644 --- a/src/Undefined/services/commands/__init__.py +++ b/src/Undefined/services/commands/__init__.py @@ -1,6 +1,7 @@ """命令模块注册与上下文定义。""" +from Undefined.services.commands.catalog import CommandCatalog from Undefined.services.commands.context import CommandContext from Undefined.services.commands.registry import CommandMeta, CommandRegistry -__all__ = ["CommandContext", "CommandMeta", "CommandRegistry"] +__all__ = ["CommandCatalog", "CommandContext", "CommandMeta", "CommandRegistry"] diff --git a/src/Undefined/services/commands/catalog.py b/src/Undefined/services/commands/catalog.py new file mode 100644 index 00000000..13a045aa --- /dev/null +++ b/src/Undefined/services/commands/catalog.py @@ -0,0 +1,334 @@ +"""当前发送者可见的斜杠命令目录。""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +from Undefined.services.commands.context import CommandContext +from Undefined.services.commands.registry import CommandMeta, CommandRegistry + +_DOC_MAX_CHARS = 6000 +_MATCH_RANK = { + "name": 0, + "alias": 1, + "description": 2, + "usage": 3, + "example": 4, + "doc": 5, +} + + +def permission_label(permission: str) -> str: + labels = { + "public": "公开", + "admin": "管理员", + "superadmin": "超管", + } + return labels.get(str(permission or "public").strip().lower(), "公开") + + +def sender_permission_label(context: CommandContext) -> str: + config = context.config + try: + if config.is_superadmin(context.sender_id): + return "超管" + except Exception: + pass + try: + if config.is_admin(context.sender_id): + return "管理员" + except Exception: + pass + return "普通用户" + + +def is_private_scope(context: CommandContext) -> bool: + if context.scope == "private": + return True + try: + return int(context.group_id) == 0 + except (TypeError, ValueError): + return False + + +def can_see_command(permission: str, sender_id: int, context: CommandContext) -> bool: + if permission in ("public", ""): + return True + if permission == "superadmin": + return bool(context.config.is_superadmin(sender_id)) + if permission == "admin": + return bool( + context.config.is_admin(sender_id) + or context.config.is_superadmin(sender_id) + ) + return True + + +def list_visible_commands(context: CommandContext) -> list[CommandMeta]: + commands = context.registry.list_commands(include_hidden=False) + if is_private_scope(context): + commands = [item for item in commands if item.allow_in_private] + commands = [item for item in commands if context.registry.is_visible(item, context)] + return [ + item + for item in commands + if can_see_command(item.permission, context.sender_id, context) + ] + + +def format_command_name(meta: CommandMeta) -> str: + name_line = f"/{meta.name}" + if not meta.aliases: + return name_line + shortest = min(meta.aliases, key=len) + if len(shortest) >= len(meta.name): + return name_line + return f"/{meta.name}(/{shortest})" + + +def format_rate_limit(meta: CommandMeta) -> str: + rate = meta.rate_limit + + def _slot(seconds: int, label: str) -> str: + if seconds <= 0: + return f"{label}无限制" + return f"{label}{seconds}s" + + return " / ".join( + [ + _slot(rate.user, "普通"), + _slot(rate.admin, "管理员"), + _slot(rate.superadmin, "超管"), + ] + ) + + +def load_command_doc(meta: CommandMeta, *, max_chars: int = _DOC_MAX_CHARS) -> str: + if meta.doc_path is None or not meta.doc_path.exists(): + return "" + content = meta.doc_path.read_text(encoding="utf-8").strip() + if len(content) <= max_chars: + return content + trimmed = content[: max_chars - 32].rstrip() + return f"{trimmed}\n\n[文档过长,已截断]" + + +def format_command_detail(meta: CommandMeta) -> str: + aliases = "、".join(f"/{alias}" for alias in meta.aliases) if meta.aliases else "无" + lines = [ + f"{format_command_name(meta)} — {meta.description or '暂无说明'}", + "", + f"用法:{meta.usage}", + ] + if meta.example: + lines.append(f"示例:{meta.example}") + lines.append( + f"权限:{permission_label(meta.permission)} | " + f"作用域:{'群聊/私聊' if meta.allow_in_private else '仅群聊'} | " + f"限流:{format_rate_limit(meta)}" + ) + if aliases != "无": + lines.append(f"别名:{aliases}") + if meta.subcommands: + lines.append("") + lines.append("子命令:") + for subcmd in meta.subcommands.values(): + args_str = f" {subcmd.args}" if subcmd.args else "" + perm_mark = "" + if subcmd.permission != meta.permission: + perm_mark = f" [{permission_label(subcmd.permission)}]" + lines.append( + f" {subcmd.name}{args_str} — {subcmd.description}{perm_mark}" + ) + doc_content = load_command_doc(meta) + if doc_content: + lines.extend(["", "说明文档:", doc_content]) + return "\n".join(lines) + + +def format_available_commands_prompt(context: CommandContext) -> str: + commands = list_visible_commands(context) + scope_hint = "私聊" if is_private_scope(context) else "群聊" + perm_hint = sender_permission_label(context) + if not commands: + return ( + "【当前发送者可用斜杠命令】\n" + f"会话:{scope_hint} | 权限:{perm_hint}\n" + "当前没有可展示的斜杠命令。\n" + "需要限流、权限、用法或文档时调用 commands.search / commands.get;" + "不要编造命令,也不要代替用户发送斜杠命令。" + ) + command_lines: list[str] = [] + for item in commands: + desc = item.description or "暂无说明" + if item.subcommands: + desc += f"({len(item.subcommands)}个子命令)" + command_lines.append(f"{format_command_name(item)} — {desc}") + return "\n".join( + [ + "【当前发送者可用斜杠命令】", + f"会话:{scope_hint} | 权限:{perm_hint}", + *command_lines, + "需要限流、权限、用法或文档时调用 commands.search / commands.get;" + "不要编造命令,也不要代替用户发送斜杠命令。", + ] + ) + + +def _normalize_query(text: str) -> str: + return text.strip().lstrip("/").lower() + + +def _match_rank(meta: CommandMeta, query: str) -> int | None: + needle = _normalize_query(query) + if not needle: + return None + if needle in meta.name.lower(): + return _MATCH_RANK["name"] + for alias in meta.aliases: + if needle in alias.lower(): + return _MATCH_RANK["alias"] + if needle in (meta.description or "").lower(): + return _MATCH_RANK["description"] + if needle in (meta.usage or "").lower(): + return _MATCH_RANK["usage"] + if needle in (meta.example or "").lower(): + return _MATCH_RANK["example"] + for subcmd in meta.subcommands.values(): + haystack = " ".join([subcmd.name, subcmd.description, subcmd.args]).lower() + if needle in haystack: + return _MATCH_RANK["description"] + doc = load_command_doc(meta) + if needle in doc.lower(): + return _MATCH_RANK["doc"] + return None + + +def search_visible_commands(context: CommandContext, query: str) -> list[CommandMeta]: + needle = _normalize_query(query) + if not needle: + return [] + scored: list[tuple[int, int, str, CommandMeta]] = [] + for meta in list_visible_commands(context): + rank = _match_rank(meta, needle) + if rank is None: + continue + scored.append((rank, meta.order, meta.name, meta)) + scored.sort(key=lambda item: (item[0], item[1], item[2])) + return [item[3] for item in scored] + + +def resolve_visible_command( + context: CommandContext, command_name: str +) -> CommandMeta | None: + normalized = _normalize_query(command_name) + if not normalized: + return None + meta = context.registry.resolve(normalized) + if meta is None: + return None + visible = {item.name for item in list_visible_commands(context)} + if meta.name not in visible: + return None + return meta + + +def make_viewer_context( + registry: CommandRegistry, + config: Any, + *, + sender_id: int, + scope: str, + group_id: int = 0, + user_id: int | None = None, + is_webui_session: bool = False, +) -> CommandContext: + stub = cast(Any, SimpleNamespace()) + return CommandContext( + group_id=group_id, + sender_id=sender_id, + config=config, + sender=stub, + ai=stub, + faq_storage=stub, + onebot=stub, + security=stub, + queue_manager=None, + rate_limiter=None, + dispatcher=stub, + registry=registry, + scope=scope, + user_id=user_id, + is_webui_session=is_webui_session, + ) + + +class CommandCatalog: + """面向 Prompt 与工具的可见命令查询入口。""" + + def __init__(self, registry: CommandRegistry, config: Any) -> None: + self.registry = registry + self.config = config + + def viewer_from_mapping(self, mapping: dict[str, Any] | None) -> CommandContext: + data = mapping if isinstance(mapping, dict) else {} + request_type = str(data.get("request_type") or "").strip().lower() + is_private = bool(data.get("is_private_chat")) or request_type == "private" + group_id = 0 + raw_group_id = data.get("group_id") + if raw_group_id is not None: + try: + group_id = int(raw_group_id) + except (TypeError, ValueError): + group_id = 0 + if is_private: + scope = "private" + group_id = 0 + else: + scope = "group" if group_id else "private" + sender_id = 0 + for key in ("sender_id", "user_id"): + raw = data.get(key) + if raw is None: + continue + try: + sender_id = int(raw) + except (TypeError, ValueError): + continue + if sender_id: + break + user_id: int | None = None + raw_user_id = data.get("user_id") + if raw_user_id is not None: + try: + user_id = int(raw_user_id) + except (TypeError, ValueError): + user_id = None + return make_viewer_context( + self.registry, + self.config, + sender_id=sender_id, + scope=scope, + group_id=group_id, + user_id=user_id, + is_webui_session=bool(data.get("webui_session")), + ) + + def list_visible(self, context: CommandContext) -> list[CommandMeta]: + return list_visible_commands(context) + + def format_prompt_block(self, context: CommandContext) -> str: + return format_available_commands_prompt(context) + + def search(self, context: CommandContext, query: str) -> list[CommandMeta]: + return search_visible_commands(context, query) + + def get(self, context: CommandContext, command_name: str) -> CommandMeta | None: + return resolve_visible_command(context, command_name) + + def format_detail(self, meta: CommandMeta) -> str: + return format_command_detail(meta) + + def format_name(self, meta: CommandMeta) -> str: + return format_command_name(meta) diff --git a/src/Undefined/skills/commands/help/handler.py b/src/Undefined/skills/commands/help/handler.py index 3376ed11..7c98bb76 100644 --- a/src/Undefined/skills/commands/help/handler.py +++ b/src/Undefined/skills/commands/help/handler.py @@ -7,6 +7,11 @@ import markdown +from Undefined.services.commands.catalog import ( + can_see_command, + list_visible_commands, + sender_permission_label, +) from Undefined.services.commands.context import CommandContext from Undefined.services.commands.registry import CommandMeta, SubcommandMeta @@ -27,18 +32,7 @@ def _permission_label(permission: str) -> str: def _sender_permission_label(context: CommandContext) -> str: - config = context.config - try: - if config.is_superadmin(context.sender_id): - return "超管" - except Exception: - pass - try: - if config.is_admin(context.sender_id): - return "管理员" - except Exception: - pass - return "普通用户" + return sender_permission_label(context) def _scope_label(allow_in_private: bool) -> str: @@ -64,15 +58,7 @@ async def _send_message(context: CommandContext, message: str) -> None: def _can_see_command(permission: str, sender_id: int, context: CommandContext) -> bool: """根据命令权限判断用户是否可见该命令。""" - if permission in ("public", ""): - return True - if permission == "superadmin": - return context.config.is_superadmin(sender_id) - if permission == "admin": - return context.config.is_admin(sender_id) or context.config.is_superadmin( - sender_id - ) - return True + return can_see_command(permission, sender_id, context) def _format_usage_with_alias(item: CommandMeta) -> str: @@ -87,19 +73,7 @@ def _format_usage_with_alias(item: CommandMeta) -> str: def _visible_commands(context: CommandContext) -> list[CommandMeta]: - commands = context.registry.list_commands(include_hidden=False) - in_private = _is_private_scope(context) - if in_private: - commands = [item for item in commands if item.allow_in_private] - commands = [item for item in commands if context.registry.is_visible(item, context)] - - # 按权限过滤:非管理员看不到管理命令 - commands = [ - item - for item in commands - if _can_see_command(item.permission, context.sender_id, context) - ] - return commands + return list_visible_commands(context) def _format_command_list(context: CommandContext) -> str: diff --git a/src/Undefined/skills/toolsets/README.md b/src/Undefined/skills/toolsets/README.md index 4bf95633..bb9fc897 100644 --- a/src/Undefined/skills/toolsets/README.md +++ b/src/Undefined/skills/toolsets/README.md @@ -16,6 +16,9 @@ toolsets/ ├── memes/ # 表情包工具集 │ ├── search_memes/ # 表情包检索 │ └── send_meme_by_uid/ # 按 uid 发送表情包 +├── commands/ # 斜杠命令查询(文本匹配,不接 RAG) +│ ├── search/ # 按名称/别名/说明/文档检索可见命令 +│ └── get/ # 取单条命令的权限、限流、用法和 README ├── render/ # 渲染工具集 │ ├── render_html/ # HTML 渲染 │ ├── render_latex/ # LaTeX 渲染 diff --git a/src/Undefined/skills/toolsets/commands/README.md b/src/Undefined/skills/toolsets/commands/README.md new file mode 100644 index 00000000..71bdc986 --- /dev/null +++ b/src/Undefined/skills/toolsets/commands/README.md @@ -0,0 +1,8 @@ +# 斜杠命令查询工具集 + +主 AI 可用这些工具查询当前发送者能执行的斜杠命令。匹配为纯文本子串,不接 RAG。 + +- `commands.search`:按名称/别名/说明/用法/文档检索可见命令 +- `commands.get`:取单条命令的权限、限流、用法和 README + +该分类没有 `callable.json`,默认仅主 AI 可见。 diff --git a/src/Undefined/skills/toolsets/commands/get/config.json b/src/Undefined/skills/toolsets/commands/get/config.json new file mode 100644 index 00000000..2c825ecd --- /dev/null +++ b/src/Undefined/skills/toolsets/commands/get/config.json @@ -0,0 +1,17 @@ +{ + "type": "function", + "function": { + "name": "get", + "description": "获取当前发送者可用的某条斜杠命令的详细说明,包括用法、示例、别名、权限(谁可用)、作用域、限流和帮助文档。命令名支持别名。无权或不存在时不返回文档。", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "命令名或别名,可带或不带前导 /,如 profile 或 /p" + } + }, + "required": ["name"] + } + } +} diff --git a/src/Undefined/skills/toolsets/commands/get/handler.py b/src/Undefined/skills/toolsets/commands/get/handler.py new file mode 100644 index 00000000..fb997c2c --- /dev/null +++ b/src/Undefined/skills/toolsets/commands/get/handler.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + catalog = context.get("command_catalog") + if catalog is None: + return "斜杠命令目录不可用" + name = str(args.get("name") or "").strip() + if not name: + return "请提供命令名" + viewer = catalog.viewer_from_mapping(context) + meta = catalog.get(viewer, name) + if meta is None: + return "未找到命令,或当前发送者无权查看" + return str(catalog.format_detail(meta)) diff --git a/src/Undefined/skills/toolsets/commands/search/config.json b/src/Undefined/skills/toolsets/commands/search/config.json new file mode 100644 index 00000000..c920de91 --- /dev/null +++ b/src/Undefined/skills/toolsets/commands/search/config.json @@ -0,0 +1,17 @@ +{ + "type": "function", + "function": { + "name": "search", + "description": "按文本匹配查询当前发送者可用的斜杠命令。匹配命令名、别名、说明、用法、示例和帮助文档;不使用语义检索。用于回答“有哪些命令/某个命令大概干什么”。需要完整限流、权限、用法或文档时改用 commands.get。", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "查询词,如 help、侧写、profile、限流" + } + }, + "required": ["query"] + } + } +} diff --git a/src/Undefined/skills/toolsets/commands/search/handler.py b/src/Undefined/skills/toolsets/commands/search/handler.py new file mode 100644 index 00000000..e25b881d --- /dev/null +++ b/src/Undefined/skills/toolsets/commands/search/handler.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Any + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + catalog = context.get("command_catalog") + if catalog is None: + return "斜杠命令目录不可用" + query = str(args.get("query") or "").strip() + if not query: + return "请提供查询关键词" + viewer = catalog.viewer_from_mapping(context) + matches = catalog.search(viewer, query) + if not matches: + return f"没有匹配“{query}”的可用斜杠命令" + lines = [f"匹配到 {len(matches)} 条可用命令:"] + for meta in matches: + desc = meta.description or "暂无说明" + lines.append(f"- {catalog.format_name(meta)} — {desc}") + lines.append("需要限流、权限、用法或文档时调用 commands.get。") + return "\n".join(lines) diff --git a/tests/test_command_catalog.py b/tests/test_command_catalog.py new file mode 100644 index 00000000..73b5dafb --- /dev/null +++ b/tests/test_command_catalog.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, cast + +import pytest + +from Undefined.ai.prompts import PromptBuilder +from Undefined.end_summary_storage import EndSummaryRecord +from Undefined.services.commands.catalog import CommandCatalog +from Undefined.services.commands.context import CommandContext +from Undefined.services.commands.registry import CommandRegistry +from Undefined.skills.toolsets.commands.get.handler import ( + execute as commands_get_execute, +) +from Undefined.skills.toolsets.commands.search.handler import ( + execute as commands_search_execute, +) + + +class _FakeConfig: + def __init__(self, *, admins: set[int], superadmins: set[int]) -> None: + self._admins = admins + self._superadmins = superadmins + + def is_admin(self, sender_id: int) -> bool: + return int(sender_id) in self._admins or int(sender_id) in self._superadmins + + def is_superadmin(self, sender_id: int) -> bool: + return int(sender_id) in self._superadmins + + +class _FakeEndSummaryStorage: + async def load(self) -> list[EndSummaryRecord]: + return [] + + +PUBLIC_USER = 10001 +ADMIN_USER = 20001 +SUPERADMIN_USER = 30001 + + +def _write_command( + base_dir: Path, + command_dir_name: str, + *, + command_name: str, + description: str = "测试命令", + usage: str | None = None, + example: str | None = None, + aliases: list[str] | None = None, + permission: str = "public", + allow_in_private: bool = True, + show_in_help: bool = True, + order: int = 10, + rate_limit: dict[str, int] | None = None, + subcommands: dict[str, Any] | None = None, + doc_text: str | None = None, + visibility_text: str | None = None, +) -> Path: + command_dir = base_dir / command_dir_name + command_dir.mkdir(parents=True, exist_ok=True) + payload: dict[str, Any] = { + "name": command_name, + "description": description, + "usage": usage or f"/{command_name}", + "example": example or f"/{command_name}", + "permission": permission, + "allow_in_private": allow_in_private, + "rate_limit": rate_limit or {"user": 10, "admin": 5, "superadmin": 0}, + "show_in_help": show_in_help, + "order": order, + "aliases": aliases or [], + } + if subcommands is not None: + payload["subcommands"] = subcommands + (command_dir / "config.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + (command_dir / "handler.py").write_text( + "from __future__ import annotations\n\n" + "from Undefined.services.commands.context import CommandContext\n\n" + "async def execute(args: list[str], context: CommandContext) -> None:\n" + " return None\n", + encoding="utf-8", + ) + if doc_text is not None: + (command_dir / "README.md").write_text(doc_text, encoding="utf-8") + if visibility_text is not None: + (command_dir / "policy.py").write_text(visibility_text, encoding="utf-8") + return command_dir + + +def _seed_commands(base_dir: Path) -> None: + _write_command( + base_dir, + "help", + command_name="help", + description="显示命令列表或详细帮助", + aliases=["h"], + order=1, + doc_text="# Help 文档\n\n这是公开帮助。", + ) + _write_command( + base_dir, + "profile", + command_name="profile", + description="查看认知侧写", + aliases=["p"], + order=2, + allow_in_private=True, + subcommands={"show": {"description": "查看侧写", "args": "[QQ]"}}, + doc_text="# Profile 文档\n\n查看用户或群侧写。", + ) + _write_command( + base_dir, + "grouponly", + command_name="grouponly", + description="仅群聊可用的命令", + allow_in_private=False, + order=3, + doc_text="群聊专属文档,含限流说明。", + ) + _write_command( + base_dir, + "admincmd", + command_name="admincmd", + description="管理员维护命令", + aliases=["ac"], + permission="admin", + order=4, + rate_limit={"user": 60, "admin": 10, "superadmin": 0}, + doc_text="管理员机密文档,禁止泄露。", + ) + _write_command( + base_dir, + "super", + command_name="super", + description="超管专属命令", + permission="superadmin", + order=5, + doc_text="超管机密文档。", + ) + _write_command( + base_dir, + "hidden", + command_name="hidden", + description="不在帮助中展示", + show_in_help=False, + order=6, + doc_text="隐藏命令文档。", + ) + _write_command( + base_dir, + "gated", + command_name="gated", + description="策略隐藏命令", + order=7, + visibility_text=( + "from __future__ import annotations\n\n" + "from Undefined.services.commands.context import CommandContext\n\n" + "def is_command_visible(context: CommandContext) -> bool:\n" + " return False\n" + ), + doc_text="策略隐藏文档。", + ) + + +def _make_catalog(tmp_path: Path) -> CommandCatalog: + commands_dir = tmp_path / "commands" + commands_dir.mkdir(parents=True) + _seed_commands(commands_dir) + registry = CommandRegistry(commands_dir) + registry.load_commands() + config = _FakeConfig(admins={ADMIN_USER}, superadmins={SUPERADMIN_USER}) + return CommandCatalog(registry, config) + + +def _viewer( + catalog: CommandCatalog, + *, + sender_id: int, + scope: str, + group_id: int = 10001, +) -> CommandContext: + return catalog.viewer_from_mapping( + { + "sender_id": sender_id, + "user_id": sender_id, + "request_type": scope, + "group_id": 0 if scope == "private" else group_id, + "is_private_chat": scope == "private", + } + ) + + +def test_catalog_filters_by_permission_and_private_scope(tmp_path: Path) -> None: + catalog = _make_catalog(tmp_path) + public_group = _viewer(catalog, sender_id=PUBLIC_USER, scope="group") + public_private = _viewer(catalog, sender_id=PUBLIC_USER, scope="private") + admin_group = _viewer(catalog, sender_id=ADMIN_USER, scope="group") + super_group = _viewer(catalog, sender_id=SUPERADMIN_USER, scope="group") + + public_names = {item.name for item in catalog.list_visible(public_group)} + assert public_names == {"help", "profile", "grouponly"} + + private_names = {item.name for item in catalog.list_visible(public_private)} + assert private_names == {"help", "profile"} + assert "grouponly" not in private_names + + admin_names = {item.name for item in catalog.list_visible(admin_group)} + assert "admincmd" in admin_names + assert "super" not in admin_names + assert "hidden" not in admin_names + assert "gated" not in admin_names + + super_names = {item.name for item in catalog.list_visible(super_group)} + assert {"help", "profile", "grouponly", "admincmd", "super"} <= super_names + assert "hidden" not in super_names + assert "gated" not in super_names + + +def test_catalog_search_ranks_name_alias_description_then_doc(tmp_path: Path) -> None: + catalog = _make_catalog(tmp_path) + viewer = _viewer(catalog, sender_id=PUBLIC_USER, scope="group") + + by_name = catalog.search(viewer, "profile") + assert [item.name for item in by_name] == ["profile"] + + by_alias = catalog.search(viewer, "h") + assert by_alias[0].name == "help" + + by_desc = catalog.search(viewer, "认知侧写") + assert [item.name for item in by_desc] == ["profile"] + + by_doc = catalog.search(viewer, "公开帮助") + assert [item.name for item in by_doc] == ["help"] + + public_search = catalog.search(viewer, "机密") + assert public_search == [] + + +def test_catalog_get_hides_unauthorized_docs(tmp_path: Path) -> None: + catalog = _make_catalog(tmp_path) + public_viewer = _viewer(catalog, sender_id=PUBLIC_USER, scope="group") + admin_viewer = _viewer(catalog, sender_id=ADMIN_USER, scope="group") + + assert catalog.get(public_viewer, "/p") is not None + assert catalog.get(public_viewer, "admincmd") is None + assert catalog.get(public_viewer, "missing") is None + + admin_meta = catalog.get(admin_viewer, "ac") + assert admin_meta is not None + detail = catalog.format_detail(admin_meta) + assert "管理员机密文档,禁止泄露。" in detail + assert "限流:普通60s / 管理员10s / 超管无限制" in detail + assert "权限:管理员" in detail + + +@pytest.mark.asyncio +async def test_commands_search_and_get_tools_respect_visibility( + tmp_path: Path, +) -> None: + catalog = _make_catalog(tmp_path) + public_context: dict[str, Any] = { + "command_catalog": catalog, + "sender_id": PUBLIC_USER, + "request_type": "group", + "group_id": 10001, + } + admin_context: dict[str, Any] = { + "command_catalog": catalog, + "sender_id": ADMIN_USER, + "request_type": "group", + "group_id": 10001, + } + + empty = await commands_search_execute({"query": ""}, public_context) + assert empty == "请提供查询关键词" + + none = await commands_search_execute({"query": "机密"}, public_context) + assert "没有匹配" in none + + listed = await commands_search_execute({"query": "侧写"}, public_context) + assert "/profile(/p)" in listed + assert "admincmd" not in listed + + denied = await commands_get_execute({"name": "admincmd"}, public_context) + assert denied == "未找到命令,或当前发送者无权查看" + assert "机密" not in denied + + missing = await commands_get_execute({"name": "nope"}, public_context) + assert missing == "未找到命令,或当前发送者无权查看" + + allowed = await commands_get_execute({"name": "/p"}, public_context) + assert "/profile(/p)" in allowed + assert "查看用户或群侧写。" in allowed + assert "子命令:" in allowed + + admin_detail = await commands_get_execute({"name": "admincmd"}, admin_context) + assert "管理员机密文档,禁止泄露。" in admin_detail + + +def test_format_prompt_block_lists_visible_commands_only(tmp_path: Path) -> None: + catalog = _make_catalog(tmp_path) + public_block = catalog.format_prompt_block( + _viewer(catalog, sender_id=PUBLIC_USER, scope="group") + ) + assert "【当前发送者可用斜杠命令】" in public_block + assert "会话:群聊 | 权限:普通用户" in public_block + assert "/help(/h) — 显示命令列表或详细帮助" in public_block + assert "/profile(/p) — 查看认知侧写(1个子命令)" in public_block + assert "admincmd" not in public_block + assert "不要编造命令,也不要代替用户发送斜杠命令。" in public_block + + admin_block = catalog.format_prompt_block( + _viewer(catalog, sender_id=ADMIN_USER, scope="private") + ) + assert "会话:私聊 | 权限:管理员" in admin_block + assert "/admincmd(/ac)" in admin_block + assert "grouponly" not in admin_block + + +def _make_prompt_builder(config: Any) -> PromptBuilder: + return PromptBuilder( + bot_qq=123456, + memory_storage=None, + end_summary_storage=cast(Any, _FakeEndSummaryStorage()), + runtime_config_getter=lambda: config, + anthropic_skill_registry=None, + cognitive_service=None, + ) + + +def test_prompt_builder_skips_commands_block_without_registry( + tmp_path: Path, +) -> None: + catalog = _make_catalog(tmp_path) + builder = _make_prompt_builder(catalog.config) + prompt = builder._build_available_commands_prompt( + { + "sender_id": PUBLIC_USER, + "group_id": 10001, + "request_type": "group", + } + ) + assert prompt == "" + + +def test_prompt_builder_injects_available_commands_block(tmp_path: Path) -> None: + catalog = _make_catalog(tmp_path) + builder = _make_prompt_builder(catalog.config) + builder.set_command_registry(catalog.registry) + prompt = builder._build_available_commands_prompt( + { + "sender_id": PUBLIC_USER, + "group_id": 10001, + "request_type": "group", + } + ) + assert "【当前发送者可用斜杠命令】" in prompt + assert "/help(/h)" in prompt + assert "admincmd" not in prompt + + +@pytest.mark.asyncio +async def test_build_messages_injects_commands_before_current_time( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + catalog = _make_catalog(tmp_path) + builder = _make_prompt_builder(catalog.config) + builder.set_command_registry(catalog.registry) + + async def _fake_load_system_prompt(*, nagaagent_active: bool | None = None) -> str: + return "系统提示词" + + async def _fake_load_each_rules() -> str: + return "每次都要先检查缓存" + + monkeypatch.setattr(builder, "_load_system_prompt", _fake_load_system_prompt) + monkeypatch.setattr(builder, "_load_each_rules", _fake_load_each_rules) + + async def _fake_recent_messages( + chat_id: str, msg_type: str, start: int, end: int + ) -> list[dict[str, Any]]: + _ = chat_id, msg_type, start, end + return [] + + messages = await builder.build_messages( + '\n有哪些命令?\n', + get_recent_messages_callback=_fake_recent_messages, + extra_context={ + "group_id": 10001, + "sender_id": PUBLIC_USER, + "sender_name": "测试用户", + "request_type": "group", + }, + ) + contents = [str(message.get("content", "")) for message in messages] + commands_idx = next( + idx + for idx, content in enumerate(contents) + if "【当前发送者可用斜杠命令】" in content + ) + time_idx = next( + idx for idx, content in enumerate(contents) if "【当前时间】" in content + ) + assert commands_idx < time_idx + assert "/help(/h)" in contents[commands_idx] diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 813a8417..aa352d22 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -56,6 +56,20 @@ def test_system_prompts_include_info_gate_and_style_constraints(path: Path) -> N assert snippet in text +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_explain_slash_command_lookup_tools(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + required_snippets = [ + "当前发送者可用的斜杠命令会另以系统块注入", + "需要限流、权限、用法或文档时调用 commands.search / commands.get", + "不要编造不存在的命令", + "也不要代替用户发送斜杠命令", + ] + for snippet in required_snippets: + assert snippet in text + + @pytest.mark.parametrize("path", PROMPT_PATHS) def test_system_prompts_define_conditional_tool_search_sequence(path: Path) -> None: text = path.read_text(encoding="utf-8") From 04e6cfedd01a816c73700ed7ebb388f91ad712b2 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 14 Aug 2026 15:34:57 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix(cognitive):=20=E4=BE=A7=E5=86=99?= =?UTF-8?q?=E4=B8=8D=E5=90=88=E8=A7=84=E8=8C=83=E6=97=B6=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skip=true 仅在已合规且无新稳定特征时允许;读完侧写后必须对照当前撰写规范自检,格式问题不得被跳过。 Co-authored-by: Cursor --- res/prompts/historian_profile_merge.md | 14 ++++++++------ tests/test_cognitive_historian.py | 6 ++++++ tests/test_system_prompt_constraints.py | 3 +++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index 67acd3a5..7073eb1d 100644 --- a/res/prompts/historian_profile_merge.md +++ b/res/prompts/historian_profile_merge.md @@ -5,11 +5,12 @@ 2. `target_entity_id` 必须保持为该实体的稳定 ID,不得替换成昵称、备注名或其他文本。 3. 新事件与认知观察只能来自当前输入批次;最近消息参考和历史事件只能用于消歧、判断稳定性与取舍旧特征,禁止作为本轮新事实来源。 4. **最新优先**:当当前输入批次提炼出的稳定特征与旧侧写或历史事件冲突时,必须以当前输入批次为准覆盖过时特征;不得因“历史反复出现”而保留已被本轮推翻的旧描述。 -5. 当新信息不稳定、一次性、无法确认长期性时,必须跳过更新(`skip=true`)。注意:observations 本身不要求长期稳定,但侧写只接收能沉淀为稳定当前态画像的部分;若已能提炼为稳定当前态特征,则必须更新,禁止过度保旧导致该更不更。 +5. 新信息不稳定、一次性、无法确认长期性时,**不得把这些内容写入侧写**;但这不等于必须 `skip=true`。若现有侧写已不符合当前撰写规范,即使本轮没有可沉淀新事实,也必须 `skip=false` 并重写为合规版本(只重整旧画像,不写入一次性事件)。`skip=true` 仅当:现有侧写已合规 **且** 本轮无新稳定特征。注意:observations 本身不要求长期稳定,但侧写只接收能沉淀为稳定当前态画像的部分;若已能提炼为稳定当前态特征,则必须更新,禁止过度保旧导致该更不更。 6. 不得输出或暗示其他实体侧写内容。 工具使用规则(严格执行): - **修改任何侧写前,必须先调用 `read_profile` 查看其当前内容**,确认已读取后再决定是否调用 `update_profile`。 +- 读完后先对照**当前**撰写规范自检:结构(第一条定调 + 少量维度)、tags 只写身份/角色/核心领域、无时序流水账、克制去冗、核心画像有辨识度。不合规则必须重写,不得因“没有新稳定特征”而 skip。 - 可以调用 `read_profile` 查看任意实体的侧写(不限于目标实体)。 - 只允许通过 `update_profile` 工具写入侧写,禁止输出普通文本内容。 @@ -51,7 +52,7 @@ {observations} 要求: -1. **先调用 `read_profile` 读取目标实体的当前侧写**,再决定如何更新 +1. **先调用 `read_profile` 读取目标实体的当前侧写**,对照当前撰写规范自检后再决定如何更新 2. **更新双原则**(核心): - **未冲突**:现有侧写是长期积累的全貌,本轮新事件只是增量片段;默认保留未被本轮触及的稳定特征,用新信息补充细节或修正表述,而非无故重新定义 - **冲突/过时**:以当前输入批次为准覆盖;历史事件只用于判断“旧特征是否仍有较新佐证”,不是保旧否决票 @@ -70,7 +71,7 @@ - 边缘、偶发、低辨识度特征可省略;细节级事实留给事件记忆,侧写只留稳定画像 - 若旧侧写已冗长或条目过多,本轮应顺手压缩重整为更紧凑的合规版本 7. **核心画像要抓独特性**:第一句要写出"这个人区别于其他人的本质",而非通用描述(如"开发者"太泛,"把系统当产线打理的工程型开发者"才有辨识度) -8. 若 `current_profile` 本身不符合以上规范(含冗长膨胀),可直接整体重写为合规版本(不必保留其原有写法) +8. 若 `current_profile` 本身不符合以上规范(含冗长膨胀、话题 tags、时序流水账、结构缺失),**必须** `skip=false` 并整体重写为合规版本;即使本轮没有可沉淀新事实,也只重整旧画像,禁止把一次性事件写进去。不必保留其原有写法。 侧写 = **长期高层画像**:只保留身份、风格、偏好、能力等核心抽象,应能脱离具体聊天记录独立理解「这个人/群是谁」,而非「他做了什么」。正文写的是**当前仍成立的状态**,不是事件流水账。 @@ -81,8 +82,9 @@ - 具体分支名、提交细节、安装排障步骤、一次性报错 - "曾讨论/刚确认/近期提及/某次分享"等时序性描述 -若新信息仅为一次性事件、无法提炼为稳定特征,必须 `skip=true`。 -若本轮只有事件细节、无法抽象为长期稳定特征,必须 `skip=true`。 +若新信息仅为一次性事件、无法提炼为稳定特征,且现有侧写已符合当前撰写规范,必须 `skip=true`。 +若本轮只有事件细节、无法抽象为长期稳定特征,且现有侧写已合规,必须 `skip=true`。 +若现有侧写不合规,即使本轮没有新稳定特征,也必须更新(`skip=false`)并重写为合规版本,不得以“没有新事实”为由跳过格式修复。 若本轮已能提炼稳定当前态特征,必须更新(`skip=false`),不得以“旧侧写已足够完整”为由跳过。 `summary` 输出格式约束: @@ -136,7 +138,7 @@ ``` 输出规则(调用 `update_profile` 工具): -- 若应跳过更新:`skip=true`,并给出 `skip_reason`;`summary` 置空字符串,`tags` 可为空数组。**调用后流程立即终止,无法再执行任何操作,请确保在此之前已完成所有必要的读取与更新。** +- 若应跳过更新:仅当现有侧写已符合当前撰写规范 **且** 本轮无新稳定特征。`skip=true`,并给出 `skip_reason`;`summary` 置空字符串,`tags` 可为空数组。**调用后流程立即终止,无法再执行任何操作,请确保在此之前已完成所有必要的读取与更新。** - 若执行更新:`skip=false`,返回 `summary` 和 `tags`。同样,调用后流程立即终止。 - `entity_type` 和 `entity_id` 必须与目标实体一致。 - `name` 使用目标实体的显示名(优先 `{target_display_name}`),不要把 `{target_entity_id}` 当昵称随意改写。 diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index 3ebd4be0..2ab7cb79 100644 --- a/tests/test_cognitive_historian.py +++ b/tests/test_cognitive_historian.py @@ -396,6 +396,12 @@ def test_historian_profile_merge_prompt_profile_only_constraints() -> None: assert "时间只用于判断取舍" in merge assert "克制扩写 / 合并去冗" in merge assert "能并入现有条目就不新增条目" in merge + assert "对照当前撰写规范自检" in merge + assert "不合规则必须重写" in merge + assert "`skip=true` 仅当" in merge + assert "只重整旧画像" in merge + assert "不得以“没有新事实”为由跳过格式修复" in merge + assert "可直接整体重写" not in merge assert "宁可多写" not in merge assert "信息密度优先于表达精炼" not in merge diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index aa352d22..3cd7d855 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -569,6 +569,9 @@ def test_historian_prompts_reference_current_input_batch_source() -> None: assert "最新优先" in merge assert "合并去冗" in merge assert "克制扩写" in merge + assert "对照当前撰写规范自检" in merge + assert "`skip=true` 仅当" in merge + assert "只重整旧画像" in merge @pytest.mark.parametrize("path", PROMPT_PATHS) From 19734a3e6bde525a818a89a7f87b999fab6f8337 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 14 Aug 2026 15:39:01 +0800 Subject: [PATCH 05/16] =?UTF-8?q?feat(cognitive):=20=E4=BE=A7=E5=86=99?= =?UTF-8?q?=E8=AF=84=E4=BB=B7=E6=AE=B5=E6=94=BE=E5=9C=A8=20YAML=20?= =?UTF-8?q?=E4=B8=8E=E6=AD=A3=E6=96=87=E4=B9=8B=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评价作为独立段写入,不进 frontmatter;缺评价则拒绝更新,展示名同步与向量入库都会保留该段。 Co-authored-by: Cursor --- docs/cognitive-memory.md | 7 +- res/prompts/historian_profile_merge.md | 32 ++++- src/Undefined/cognitive/historian/tools.py | 15 ++- src/Undefined/cognitive/historian/worker.py | 75 +++++++---- src/Undefined/cognitive/service/helpers.py | 49 ++++++-- src/Undefined/cognitive/service/service.py | 7 +- tests/test_cognitive_historian.py | 133 +++++++++++++++++++- tests/test_cognitive_service.py | 91 ++++++++++++++ tests/test_system_prompt_constraints.py | 2 + 9 files changed, 366 insertions(+), 45 deletions(-) diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index 4028df58..4141bdbd 100644 --- a/docs/cognitive-memory.md +++ b/docs/cognitive-memory.md @@ -238,9 +238,14 @@ tags: updated_at: "2026-02-22T10:30:00" source_event_id: abc123_0_1740218400000 --- -Null 是一名 Python 开发者,专注于异步架构设计。曾在 Python 群中多次讨论 asyncio 最佳实践,对 QQ 机器人开发有深入研究。 +技术判断扎实、沟通直接,对配置细节近乎偏执;偶尔把讨论拖进实现细节,对看不懂的方案容易不耐烦。 +--- +- 在校学生/业余开发者,做技术取舍会权衡时间、算力与预算。 +- 独立维护开源项目,关注 AI 应用与 Agent 工程化落地。 ``` +文件固定为三段:`---元数据---评价---正文`。评价是 YAML 与正文之间的独立段落,不写入 frontmatter,也不并入正文条目。旧文件若只有一对 `---`,其后全部视为正文(评价为空,下次史官合并时应重写补齐)。 + 每次更新前自动备份到 `data/cognitive/profiles/history/{type}/{id}/{timestamp}.md`,默认保留最近 5 个版本。 ### 文件队列三态 diff --git a/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index 7073eb1d..c01470e1 100644 --- a/res/prompts/historian_profile_merge.md +++ b/res/prompts/historian_profile_merge.md @@ -10,7 +10,7 @@ 工具使用规则(严格执行): - **修改任何侧写前,必须先调用 `read_profile` 查看其当前内容**,确认已读取后再决定是否调用 `update_profile`。 -- 读完后先对照**当前**撰写规范自检:结构(第一条定调 + 少量维度)、tags 只写身份/角色/核心领域、无时序流水账、克制去冗、核心画像有辨识度。不合规则必须重写,不得因“没有新稳定特征”而 skip。 +- 读完后先对照**当前**撰写规范自检:文件结构必须是 `---元数据---评价---正文` 三段;评价段独立且非空;结构(第一条定调 + 少量维度)、tags 只写身份/角色/核心领域、无时序流水账、克制去冗、核心画像有辨识度。缺评价段或评价为空 = 不合规。不合规则必须重写,不得因“没有新稳定特征”而 skip。 - 可以调用 `read_profile` 查看任意实体的侧写(不限于目标实体)。 - 只允许通过 `update_profile` 工具写入侧写,禁止输出普通文本内容。 @@ -71,7 +71,31 @@ - 边缘、偶发、低辨识度特征可省略;细节级事实留给事件记忆,侧写只留稳定画像 - 若旧侧写已冗长或条目过多,本轮应顺手压缩重整为更紧凑的合规版本 7. **核心画像要抓独特性**:第一句要写出"这个人区别于其他人的本质",而非通用描述(如"开发者"太泛,"把系统当产线打理的工程型开发者"才有辨识度) -8. 若 `current_profile` 本身不符合以上规范(含冗长膨胀、话题 tags、时序流水账、结构缺失),**必须** `skip=false` 并整体重写为合规版本;即使本轮没有可沉淀新事实,也只重整旧画像,禁止把一次性事件写进去。不必保留其原有写法。 +8. 若 `current_profile` 本身不符合以上规范(含冗长膨胀、话题 tags、时序流水账、结构缺失、缺评价段或评价为空),**必须** `skip=false` 并整体重写为合规版本;即使本轮没有可沉淀新事实,也只重整旧画像,禁止把一次性事件写进去。不必保留其原有写法。 + +侧写文件结构固定为三段:`---元数据---评价---正文`。评价**不写入 YAML frontmatter**,也不并入 summary 条目。 + +```markdown +--- +entity_type: user +entity_id: "12345678" +name: 张三 +tags: + - ... +updated_at: "..." +--- +技术判断扎实、沟通直接,对配置细节近乎偏执;偶尔把讨论拖进实现细节,对看不懂的方案容易不耐烦。 +--- +- 在校学生/业余开发者,做技术取舍会权衡时间、算力与预算。 +- ... +``` + +**评价段**(`update_profile.evaluation`,独立段,禁止放进 YAML 或 summary): +- 必填;1 段,约 40–150 字 / 2–5 句;可同时含正负 +- 基于稳定画像,禁止事件流水账、时序词、恭维套话、泄愤 +- 群侧写评价群氛围/文化,不写单个成员隐私 +- 段内禁止单独成行的 `---`,以免切断分段 +- 缺评价段或为空 = 不合规 → 必须重写 侧写 = **长期高层画像**:只保留身份、风格、偏好、能力等核心抽象,应能脱离具体聊天记录独立理解「这个人/群是谁」,而非「他做了什么」。正文写的是**当前仍成立的状态**,不是事件流水账。 @@ -89,7 +113,7 @@ `summary` 输出格式约束: -**用户侧写**结构(使用 Markdown 项目符号 `- `): +**用户侧写**结构(使用 Markdown 项目符号 `- `;这是正文,不是评价段): 1. **第一条:核心画像** - 一句话定位这个人的身份与核心特质,为后续条目定调 - 要具体,不要泛泛而谈(如"在校学生/业余开发者,做技术取舍会权衡时间、算力与预算"比"务实的开发者"信息量大) @@ -139,7 +163,7 @@ 输出规则(调用 `update_profile` 工具): - 若应跳过更新:仅当现有侧写已符合当前撰写规范 **且** 本轮无新稳定特征。`skip=true`,并给出 `skip_reason`;`summary` 置空字符串,`tags` 可为空数组。**调用后流程立即终止,无法再执行任何操作,请确保在此之前已完成所有必要的读取与更新。** -- 若执行更新:`skip=false`,返回 `summary` 和 `tags`。同样,调用后流程立即终止。 +- 若执行更新:`skip=false`,返回 `evaluation`、`summary` 和 `tags`。`evaluation` 是 YAML 与正文之间的独立评价段(必填非空);`summary` 只含正文画像条目。禁止把评价写入 YAML 或并入 summary。同样,调用后流程立即终止。 - `entity_type` 和 `entity_id` 必须与目标实体一致。 - `name` 使用目标实体的显示名(优先 `{target_display_name}`),不要把 `{target_entity_id}` 当昵称随意改写。 - 必须通过 `update_profile` 工具返回结构化参数,禁止输出普通文本内容。 diff --git a/src/Undefined/cognitive/historian/tools.py b/src/Undefined/cognitive/historian/tools.py index b9eae327..ced7e3b5 100644 --- a/src/Undefined/cognitive/historian/tools.py +++ b/src/Undefined/cognitive/historian/tools.py @@ -57,7 +57,7 @@ }, "skip": { "type": "boolean", - "description": "是否跳过更新;当新信息不稳定/不足时为 true", + "description": "是否跳过更新;仅当现有侧写已合规且本轮无新稳定特征时为 true", }, "skip_reason": { "type": "string", @@ -69,7 +69,18 @@ "items": {"type": "string"}, "description": "身份级标签(角色/核心领域),不写话题", }, - "summary": {"type": "string", "description": "侧写正文(Markdown)"}, + "evaluation": { + "type": "string", + "description": ( + "YAML 与正文之间的独立客观评价(约 40–150 字 / 2–5 句)。" + "不写入 YAML,也不并入 summary。更新时必填非空;" + "禁止单独成行的 ---。" + ), + }, + "summary": { + "type": "string", + "description": "侧写正文(Markdown 画像条目,不含评价段)", + }, }, "required": ["entity_type", "entity_id", "skip", "name", "tags", "summary"], }, diff --git a/src/Undefined/cognitive/historian/worker.py b/src/Undefined/cognitive/historian/worker.py index 5271981d..57cd44e8 100644 --- a/src/Undefined/cognitive/historian/worker.py +++ b/src/Undefined/cognitive/historian/worker.py @@ -13,6 +13,11 @@ CHROMA_PRIORITY_BACKGROUND, CHROMA_PRIORITY_MAINTENANCE, ) +from Undefined.cognitive.service.helpers import ( + _build_profile_vector_payload, + _has_standalone_delimiter, + _serialize_profile_markdown, +) from Undefined.cognitive.vector_store_compat import call_vector_store_method from Undefined.config.models import HISTORIAN_MIN_POLL_INTERVAL_SECONDS from Undefined.utils.tool_calls import extract_required_tool_call_arguments @@ -478,12 +483,11 @@ async def _write_profile( effective_name: str, tags: list[str], summary: str, + evaluation: str, event_id: str, perspective: str, now_timezone: tzinfo | None = None, ) -> None: - import yaml - instant = datetime.now(timezone.utc) if now_timezone is not None: stamped = instant.astimezone(now_timezone) @@ -503,7 +507,9 @@ async def _write_profile( else: frontmatter["group_name"] = effective_name frontmatter["group_id"] = entity_id - content = f"---\n{yaml.dump(frontmatter, allow_unicode=True)}---\n{summary}" + content = _serialize_profile_markdown( + frontmatter, summary, evaluation=evaluation + ) await self._profile_storage.write_profile(entity_type, entity_id, content) logger.info( @@ -515,29 +521,14 @@ async def _write_profile( perspective, ) - profile_doc_lines: list[str] = [] - if entity_type == "user": - profile_doc_lines.append(f"昵称: {effective_name}") - profile_doc_lines.append(f"QQ号: {entity_id}") - else: - profile_doc_lines.append(f"群名: {effective_name}") - profile_doc_lines.append(f"群号: {entity_id}") - if tags: - profile_doc_lines.append(f"标签: {', '.join(tags)}") - profile_doc_lines.append(summary) - profile_doc = "\n".join(line for line in profile_doc_lines if line.strip()) - - profile_metadata: dict[str, Any] = { - "entity_type": entity_type, - "entity_id": entity_id, - "name": effective_name, - } - if entity_type == "user": - profile_metadata["nickname"] = effective_name - profile_metadata["qq"] = entity_id - else: - profile_metadata["group_name"] = effective_name - profile_metadata["group_id"] = entity_id + profile_doc, profile_metadata = _build_profile_vector_payload( + entity_type=entity_type, + entity_id=entity_id, + effective_name=effective_name, + tags=tags, + summary=summary, + evaluation=evaluation, + ) await call_vector_store_method( self._vector_store.upsert_profile, @@ -906,6 +897,37 @@ async def _merge_profile_target( } ) continue + evaluation = str(tc_args.get("evaluation", "")).strip() + if not evaluation: + logger.info( + "[史官] 任务 %s 侧写更新跳过: target=%s:%s reason=empty_evaluation", + event_id, + up_et, + up_eid, + ) + tool_results.append( + { + "role": "tool", + "tool_call_id": tc_id, + "content": "错误:evaluation 为空", + } + ) + continue + if _has_standalone_delimiter(evaluation): + logger.info( + "[史官] 任务 %s 侧写更新跳过: target=%s:%s reason=evaluation_delimiter", + event_id, + up_et, + up_eid, + ) + tool_results.append( + { + "role": "tool", + "tool_call_id": tc_id, + "content": "错误:评价段不能包含单独成行的 ---", + } + ) + continue raw_tags = tc_args.get("tags", []) up_tags: list[str] = [] if isinstance(raw_tags, list): @@ -934,6 +956,7 @@ async def _merge_profile_target( effective_name=effective_name, tags=up_tags, summary=summary, + evaluation=evaluation, event_id=event_id, perspective=perspective, now_timezone=now_local_dt.tzinfo, diff --git a/src/Undefined/cognitive/service/helpers.py b/src/Undefined/cognitive/service/helpers.py index 74752c48..f0108cd3 100644 --- a/src/Undefined/cognitive/service/helpers.py +++ b/src/Undefined/cognitive/service/helpers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from datetime import datetime, timezone from typing import Any @@ -98,29 +99,59 @@ def _resolve_auto_request_type( return "" -def _parse_profile_markdown(markdown: str) -> tuple[dict[str, Any], str] | None: +_PROFILE_SECTION_SPLIT = re.compile(r"(?m)^---\s*$") + + +def _has_standalone_delimiter(text: str) -> bool: + return any(line.strip() == "---" for line in str(text).splitlines()) + + +def _parse_profile_markdown( + markdown: str, +) -> tuple[dict[str, Any], str, str] | None: + """解析侧写 Markdown。 + + 返回 ``(frontmatter, evaluation, body)``。旧文件只有一对 ``---`` 时 + ``evaluation`` 为空,其后全部当作 body。 + """ text = str(markdown or "") if not text.startswith("---"): return None try: import yaml - parts = text[3:].split("---", 1) - if len(parts) != 2: + rest = text[3:] + if rest.startswith("\n"): + rest = rest[1:] + parts = _PROFILE_SECTION_SPLIT.split(rest, maxsplit=2) + if len(parts) < 2: return None frontmatter = yaml.safe_load(parts[0]) if not isinstance(frontmatter, dict): return None - body = parts[1].lstrip("\n") - return frontmatter, body + if len(parts) == 2: + evaluation = "" + body = parts[1].lstrip("\n") + else: + evaluation = parts[1].strip() + body = parts[2].lstrip("\n") + return frontmatter, evaluation, body except Exception: return None -def _serialize_profile_markdown(frontmatter: dict[str, Any], body: str) -> str: +def _serialize_profile_markdown( + frontmatter: dict[str, Any], + body: str, + evaluation: str = "", +) -> str: import yaml - return f"---\n{yaml.dump(frontmatter, allow_unicode=True)}---\n{body}" + yaml_text = yaml.dump(frontmatter, allow_unicode=True) + eval_text = str(evaluation or "").strip() + if eval_text: + return f"---\n{yaml_text}---\n{eval_text}\n---\n{body}" + return f"---\n{yaml_text}---\n{body}" def _normalize_profile_tags(value: Any) -> list[str]: @@ -142,6 +173,7 @@ def _build_profile_vector_payload( effective_name: str, tags: list[str], summary: str, + evaluation: str = "", ) -> tuple[str, dict[str, Any]]: profile_doc_lines: list[str] = [] if entity_type == "user": @@ -152,6 +184,9 @@ def _build_profile_vector_payload( profile_doc_lines.append(f"群号: {entity_id}") if tags: profile_doc_lines.append(f"标签: {', '.join(tags)}") + eval_text = str(evaluation or "").strip() + if eval_text: + profile_doc_lines.append(f"评价: {eval_text}") profile_doc_lines.append(summary) profile_doc = "\n".join(line for line in profile_doc_lines if line.strip()) diff --git a/src/Undefined/cognitive/service/service.py b/src/Undefined/cognitive/service/service.py index 88cdfafd..2ae6f749 100644 --- a/src/Undefined/cognitive/service/service.py +++ b/src/Undefined/cognitive/service/service.py @@ -120,7 +120,7 @@ async def sync_profile_display_name( parsed = _parse_profile_markdown(existing) if parsed is None: return False - frontmatter, summary = parsed + frontmatter, evaluation, summary = parsed current_name = _current_profile_name(normalized_entity_type, frontmatter) if current_name == normalized_name: return False @@ -134,7 +134,9 @@ async def sync_profile_display_name( frontmatter["group_name"] = normalized_name frontmatter["group_id"] = normalized_entity_id - updated_markdown = _serialize_profile_markdown(frontmatter, summary) + updated_markdown = _serialize_profile_markdown( + frontmatter, summary, evaluation=evaluation + ) await self._profile_storage.write_profile( normalized_entity_type, normalized_entity_id, @@ -147,6 +149,7 @@ async def sync_profile_display_name( effective_name=normalized_name, tags=_normalize_profile_tags(frontmatter.get("tags")), summary=summary, + evaluation=evaluation, ) await call_vector_store_method( self._vector_store.upsert_profile, diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index 2ab7cb79..b5f33211 100644 --- a/tests/test_cognitive_historian.py +++ b/tests/test_cognitive_historian.py @@ -401,6 +401,10 @@ def test_historian_profile_merge_prompt_profile_only_constraints() -> None: assert "`skip=true` 仅当" in merge assert "只重整旧画像" in merge assert "不得以“没有新事实”为由跳过格式修复" in merge + assert "---元数据---评价---正文" in merge + assert "不写入 YAML frontmatter" in merge + assert "缺评价段或评价为空" in merge + assert "禁止单独成行的 `---`" in merge assert "可直接整体重写" not in merge assert "宁可多写" not in merge assert "信息密度优先于表达精炼" not in merge @@ -443,6 +447,9 @@ def test_profile_update_tool_does_not_cap_tags() -> None: assert "maxItems" not in tags_schema assert "最多 10 个" not in str(tags_schema) + evaluation_schema: Any = parameters["properties"]["evaluation"] + assert evaluation_schema["type"] == "string" + assert "不写入 YAML" in evaluation_schema["description"] @pytest.mark.asyncio @@ -457,9 +464,14 @@ async def query_events( return [] async def upsert_profile( - self, _profile_id: str, _document: str, metadata: dict[str, Any] + self, + _profile_id: str, + document: str, + metadata: dict[str, Any], + **_kwargs: Any, ) -> None: upserted_metadata.append(metadata) + upserted_documents.append(document) class _FakeProfileStorage: async def read_profile(self, _entity_type: str, _entity_id: str) -> str: @@ -505,6 +517,10 @@ async def submit_background_llm_call(self, **_kwargs: Any) -> dict[str, Any]: "skip": False, "name": "测试用户", "tags": tags, + "evaluation": ( + "技术判断扎实、沟通直接,对配置细节近乎偏执;" + "偶尔把讨论拖进实现细节。" + ), "summary": "- 新侧写", } @@ -530,6 +546,7 @@ async def submit_background_llm_call(self, **_kwargs: Any) -> dict[str, Any]: written_profiles: list[str] = [] upserted_metadata: list[dict[str, Any]] = [] + upserted_documents: list[str] = [] ai_client = _FakeAIClient() worker = HistorianWorker( job_queue=None, @@ -572,10 +589,120 @@ async def submit_background_llm_call(self, **_kwargs: Any) -> dict[str, Any]: assert result is True assert len(written_profiles) == 1 + written = written_profiles[0] for index in range(12): - assert f"- 标签{index}" in written_profiles[0] - updated_match = re.search(r"updated_at:\s*['\"]?([0-9T:.+-]+)", written_profiles[0]) + assert f"- 标签{index}" in written + assert "技术判断扎实、沟通直接" in written + assert "evaluation:" not in written.split("---")[1] + assert written.strip().startswith("---") + sections = [part.strip() for part in written.split("\n---\n") if part.strip()] + assert len(sections) >= 3 + assert "- 新侧写" in sections[-1] + assert upserted_documents + assert "评价: 技术判断扎实、沟通直接" in upserted_documents[0] + updated_match = re.search(r"updated_at:\s*['\"]?([0-9T:.+-]+)", written) assert updated_match is not None updated_at = datetime.fromisoformat(updated_match.group(1).strip("'\"")) assert updated_at.tzinfo is not None assert updated_at.utcoffset() == timedelta(hours=8) + + +@pytest.mark.asyncio +async def test_merge_profile_target_rejects_empty_evaluation() -> None: + class _FakeVectorStore: + async def embed_query(self, _query: str) -> list[float]: + return [0.1, 0.2] + + async def query_events( + self, _query: str, **_kwargs: Any + ) -> list[dict[str, Any]]: + return [] + + async def upsert_profile(self, *_args: Any, **_kwargs: Any) -> None: + raise AssertionError("empty evaluation must not upsert") + + class _FakeProfileStorage: + async def read_profile(self, _entity_type: str, _entity_id: str) -> str: + return "---\nname: 测试用户\n---\n- 旧侧写" + + async def write_profile( + self, _entity_type: str, _entity_id: str, _content: str + ) -> None: + raise AssertionError("empty evaluation must not write") + + class _FakeAIClient: + agent_config = object() + + def __init__(self) -> None: + self.calls = 0 + + async def submit_background_llm_call(self, **_kwargs: Any) -> dict[str, Any]: + self.calls += 1 + if self.calls > 1: + return {"choices": [{"message": {}}]} + args = { + "entity_type": "user", + "entity_id": "123456", + "skip": False, + "name": "测试用户", + "tags": ["开发者"], + "evaluation": " ", + "summary": "- 新侧写", + } + return { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "update-1", + "function": { + "name": "update_profile", + "arguments": json.dumps( + args, ensure_ascii=False + ), + }, + } + ] + } + } + ] + } + + worker = HistorianWorker( + job_queue=None, + vector_store=_FakeVectorStore(), + profile_storage=_FakeProfileStorage(), + ai_client=_FakeAIClient(), + config_getter=lambda: SimpleNamespace(), + ) + result = await worker._merge_profile_target( + job={ + "observations": ["测试"], + "request_type": "private", + "user_id": "123456", + "group_id": "", + "sender_id": "123456", + "sender_name": "测试用户", + "group_name": "", + "timestamp_local": "2026-06-07T12:00:00+08:00", + "timezone": "Asia/Shanghai", + "request_id": "req-eval", + "end_seq": 1, + "message_ids": [], + "memo": "", + "source_message": "测试", + "recent_messages": [], + }, + canonical="测试", + event_id="job-eval", + target={ + "entity_type": "user", + "entity_id": "123456", + "perspective": "sender", + "preferred_name": "测试用户", + }, + target_index=1, + target_count=1, + ) + assert result is False diff --git a/tests/test_cognitive_service.py b/tests/test_cognitive_service.py index db396472..0db807c0 100644 --- a/tests/test_cognitive_service.py +++ b/tests/test_cognitive_service.py @@ -855,3 +855,94 @@ async def test_sync_profile_display_name_noops_when_name_unchanged() -> None: assert updated is False assert profile_storage.last_write is None assert vector_store.last_upsert_profile is None + + +def test_parse_profile_markdown_old_two_part_has_empty_evaluation() -> None: + from Undefined.cognitive.service.helpers import _parse_profile_markdown + + parsed = _parse_profile_markdown( + "---\nentity_type: user\nname: 张三\n---\n- 喜欢 Python\n" + ) + assert parsed is not None + frontmatter, evaluation, body = parsed + assert frontmatter["name"] == "张三" + assert evaluation == "" + assert body.startswith("- 喜欢 Python") + + +def test_parse_and_serialize_profile_evaluation_section() -> None: + from Undefined.cognitive.service.helpers import ( + _build_profile_vector_payload, + _parse_profile_markdown, + _serialize_profile_markdown, + ) + + markdown = _serialize_profile_markdown( + { + "entity_type": "user", + "entity_id": "12345678", + "name": "张三", + "tags": ["开发者"], + }, + "- 在校学生/业余开发者,做技术取舍会权衡时间、算力与预算。\n", + evaluation="技术判断扎实、沟通直接,对配置细节近乎偏执;偶尔把讨论拖进实现细节。", + ) + parsed = _parse_profile_markdown(markdown) + assert parsed is not None + frontmatter, evaluation, body = parsed + assert "evaluation" not in frontmatter + assert evaluation.startswith("技术判断扎实") + assert body.startswith("- 在校学生") + document, _metadata = _build_profile_vector_payload( + entity_type="user", + entity_id="12345678", + effective_name="张三", + tags=["开发者"], + summary=body, + evaluation=evaluation, + ) + assert "评价: 技术判断扎实" in document + + +@pytest.mark.asyncio +async def test_sync_profile_display_name_preserves_evaluation() -> None: + existing_profile = """--- +entity_type: user +entity_id: "12345" +name: 旧昵称 +nickname: 旧昵称 +tags: + - 开发者 +updated_at: "2026-04-01T00:00:00" +--- +技术判断扎实、沟通直接,对配置细节近乎偏执。 +--- +- 喜欢 Python +""" + vector_store = _FakeVectorStore() + profile_storage = _FakeProfileStorage(existing_profile) + service = CognitiveService( + config_getter=lambda: SimpleNamespace(enabled=True), + vector_store=vector_store, + job_queue=_FakeJobQueue(), + profile_storage=profile_storage, + reranker=None, + ) + + updated = await service.sync_profile_display_name( + entity_type="user", + entity_id="12345", + preferred_name="新昵称", + ) + + assert updated is True + assert profile_storage.last_write is not None + written = profile_storage.last_write[2] + assert "技术判断扎实、沟通直接" in written + assert "- 喜欢 Python" in written + assert "name: 新昵称" in written + assert vector_store.last_upsert_profile is not None + _profile_id, document, metadata, _kwargs = vector_store.last_upsert_profile + assert "评价: 技术判断扎实、沟通直接" in document + assert "- 喜欢 Python" in document + assert metadata["name"] == "新昵称" diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 3cd7d855..0992f925 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -572,6 +572,8 @@ def test_historian_prompts_reference_current_input_batch_source() -> None: assert "对照当前撰写规范自检" in merge assert "`skip=true` 仅当" in merge assert "只重整旧画像" in merge + assert "---元数据---评价---正文" in merge + assert "缺评价段或评价为空" in merge @pytest.mark.parametrize("path", PROMPT_PATHS) From 33762f259e5ae025877a5483a12449b8cdd393f2 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 14 Aug 2026 15:41:25 +0800 Subject: [PATCH 06/16] =?UTF-8?q?feat(commands):=20=E6=94=B9=E8=BF=9B=20/p?= =?UTF-8?q?rofile=20=E5=9B=BE=E7=89=87=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉类型/ID/更新顶栏,改为 YAML 键值表加长度,评价独立成块,正文按 markdown 渲染。 Co-authored-by: Cursor --- .../skills/commands/profile/handler.py | 175 ++++++++++++++++-- tests/test_profile_command.py | 122 +++++++++++- 2 files changed, 279 insertions(+), 18 deletions(-) diff --git a/src/Undefined/skills/commands/profile/handler.py b/src/Undefined/skills/commands/profile/handler.py index 97333e6c..0d94f05d 100644 --- a/src/Undefined/skills/commands/profile/handler.py +++ b/src/Undefined/skills/commands/profile/handler.py @@ -7,12 +7,39 @@ from pathlib import Path from typing import Any +import markdown + +from Undefined.cognitive.service.helpers import _parse_profile_markdown from Undefined.services.commands.context import CommandContext from Undefined.utils.paths import COGNITIVE_PROFILES_DIR, RENDER_CACHE_DIR, ensure_dir logger = logging.getLogger("profile") _MAX_PROFILE_LENGTH = 5000 +_MARKDOWN_EXTENSIONS = ["tables", "fenced_code", "sane_lists"] +_HIDDEN_FRONTMATTER_KEYS = {"source_event_id"} +_FRONTMATTER_LABELS = { + "name": "名称", + "tags": "标签", + "updated_at": "更新时间", + "entity_type": "实体类型", + "entity_id": "编号", + "nickname": "昵称", + "qq": "QQ", + "group_name": "群名", + "group_id": "群号", +} +_FRONTMATTER_ORDER = [ + "name", + "tags", + "updated_at", + "entity_type", + "entity_id", + "nickname", + "qq", + "group_name", + "group_id", +] _MODE_TEXT = "text" _MODE_FORWARD = "forward" @@ -81,6 +108,70 @@ def _build_metadata( return "\n".join(lines) +def _markdown_to_html(markdown_text: str) -> str: + return str(markdown.markdown(markdown_text, extensions=_MARKDOWN_EXTENSIONS)) + + +def _format_frontmatter_value(key: str, value: Any) -> str: + if key == "entity_type": + mapping = {"user": "用户", "group": "群聊"} + text = str(value or "").strip().lower() + return mapping.get(text, str(value).strip()) + if key == "tags": + if isinstance(value, list): + return "、".join(str(item).strip() for item in value if str(item).strip()) + return str(value or "").strip() + if value is None: + return "" + return str(value).strip() + + +def _render_meta_rows( + frontmatter: dict[str, Any] | None, profile_len: int +) -> list[tuple[str, str]]: + rows: list[tuple[str, str]] = [] + if frontmatter: + name = str(frontmatter.get("name") or "").strip() + entity_id = str(frontmatter.get("entity_id") or "").strip() + seen: set[str] = set() + ordered_keys = [key for key in _FRONTMATTER_ORDER if key in frontmatter] + ordered_keys.extend( + str(key) + for key in frontmatter + if str(key) not in _FRONTMATTER_ORDER and str(key) not in seen + ) + for key in ordered_keys: + key_text = str(key) + if key_text in _HIDDEN_FRONTMATTER_KEYS or key_text in seen: + continue + seen.add(key_text) + raw_value = frontmatter.get(key) + if key_text == "nickname" and str(raw_value or "").strip() == name: + continue + if key_text == "group_name" and str(raw_value or "").strip() == name: + continue + if key_text == "qq" and str(raw_value or "").strip() == entity_id: + continue + if key_text == "group_id" and str(raw_value or "").strip() == entity_id: + continue + formatted = _format_frontmatter_value(key_text, raw_value) + if not formatted: + continue + rows.append((_FRONTMATTER_LABELS.get(key_text, key_text), formatted)) + rows.append(("长度", f"{profile_len} 字")) + return rows + + +def _split_profile_for_render( + profile_text: str, +) -> tuple[dict[str, Any] | None, str, str]: + parsed = _parse_profile_markdown(profile_text) + if parsed is None: + return None, "", profile_text + frontmatter, evaluation, body = parsed + return frontmatter, evaluation, body or "" + + # ── 发送方法 ────────────────────────────────────────────────── @@ -124,22 +215,29 @@ def _node(content: str) -> dict[str, Any]: async def _send_render( context: CommandContext, - metadata: str, profile_text: str, ) -> None: - """渲染为图片发送——元数据区 + 侧写正文区。""" + """渲染为图片发送:YAML 键值表、独立评价区、Markdown 正文。""" from Undefined.render import render_html_to_image - safe_meta = html.escape(metadata) - safe_body = html.escape(profile_text) + frontmatter, evaluation, body = _split_profile_for_render(profile_text) + meta_rows_html = "" + for key, val in _render_meta_rows(frontmatter, len(profile_text)): + meta_rows_html += ( + f'{html.escape(key)}' + f'{html.escape(val)}\n' + ) + + eval_html = "" + if evaluation.strip(): + eval_html = ( + '
' + '
评价
' + f"

{html.escape(evaluation.strip())}

" + "
" + ) - meta_rows = "" - for line in safe_meta.split("\n"): - if ": " in line: - key, _, val = line.partition(": ") - meta_rows += ( - f'{key}{val}\n' - ) + body_html = _markdown_to_html(body) if body.strip() else "" html_content = f"""
-
{meta_rows}
-
{safe_body}
+
{meta_rows_html}
+ {eval_html} +
{body_html}
""" @@ -262,7 +405,7 @@ async def execute(args: list[str], context: CommandContext) -> None: await _send_text(context, profile) elif mode == _MODE_RENDER: try: - await _send_render(context, metadata, profile) + await _send_render(context, profile) except Exception: logger.exception("渲染侧写图片失败,回退到合并转发") await _handle_render_fallback(context, metadata, profile) diff --git a/tests/test_profile_command.py b/tests/test_profile_command.py index e18e45cf..f4ffbee4 100644 --- a/tests/test_profile_command.py +++ b/tests/test_profile_command.py @@ -85,16 +85,22 @@ def _build_context( ) -def _patch_profile_render(monkeypatch: pytest.MonkeyPatch) -> None: +def _patch_profile_render( + monkeypatch: pytest.MonkeyPatch, + captured: dict[str, Any] | None = None, +) -> None: import Undefined.render as render_module async def fake_render_html_to_image( - _html_content: str, + html_content: str, output_path: str, *, viewport_width: int = 1280, ) -> None: assert viewport_width == 480 + if captured is not None: + captured["html"] = html_content + captured["viewport_width"] = viewport_width Path(output_path).write_bytes(b"png") monkeypatch.setattr( @@ -631,3 +637,115 @@ async def test_profile_superadmin_target_not_found() -> None: assert len(sender.group_messages) == 1 assert "📭 暂无侧写数据" in sender.group_messages[0][1] + + +_THREE_PART_PROFILE = """--- +entity_type: user +entity_id: "12345" +name: 张三 +nickname: 张三 +tags: + - 开发者 +updated_at: "2026-04-01T00:00:00" +source_event_id: hidden-event +--- +技术判断扎实、沟通直接,对配置细节近乎偏执。 +--- +- 喜欢 **Python** +- 做技术取舍会权衡时间 +""" + + +@pytest.mark.asyncio +async def test_profile_render_html_uses_yaml_eval_and_markdown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + _patch_profile_render(monkeypatch, captured) + sender = _DummySender() + cognitive_service = AsyncMock() + cognitive_service.get_profile = AsyncMock(return_value=_THREE_PART_PROFILE) + + context = _build_context( + sender=sender, + cognitive_service=cognitive_service, + scope="group", + group_id=123456, + sender_id=55555, + ) + + await profile_execute([], context) + + html = str(captured.get("html") or "") + assert sender.group_messages[0][1].startswith("[CQ:image,file=file://") + assert 'class="eval-title">评价' in html + assert "技术判断扎实、沟通直接" in html + assert "
  • " in html + assert "Python" in html + assert "名称" in html + assert "张三" in html + assert "标签" in html + assert "开发者" in html + assert "更新时间" in html + assert "长度" in html + assert " 字" in html + assert "类型:" not in html + assert "ID:" not in html + assert "更新:" not in html + assert "---" not in html + assert "source_event_id" not in html + assert "hidden-event" not in html + assert "white-space: pre-wrap; word-wrap: break-word" not in html + + +@pytest.mark.asyncio +async def test_profile_render_html_plain_body_still_shows_length( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + _patch_profile_render(monkeypatch, captured) + sender = _DummySender() + cognitive_service = AsyncMock() + cognitive_service.get_profile = AsyncMock(return_value="这是一个用户侧写") + + context = _build_context( + sender=sender, + cognitive_service=cognitive_service, + scope="private", + group_id=0, + sender_id=99999, + user_id=99999, + ) + + await profile_execute([], context) + + html = str(captured.get("html") or "") + assert "长度" in html + assert " 字" in html + assert "这是一个用户侧写" in html + assert 'class="eval-title">评价' not in html + assert "类型:" not in html + assert "ID:" not in html + assert "更新:" not in html + + +@pytest.mark.asyncio +async def test_profile_text_mode_keeps_raw_markdown_source() -> None: + sender = _DummySender() + cognitive_service = AsyncMock() + cognitive_service.get_profile = AsyncMock(return_value=_THREE_PART_PROFILE) + + context = _build_context( + sender=sender, + cognitive_service=cognitive_service, + scope="group", + group_id=123456, + sender_id=55555, + ) + + await profile_execute(["-t"], context) + + message = sender.group_messages[0][1] + assert message == _THREE_PART_PROFILE + assert "---" in message + assert "source_event_id: hidden-event" in message From 43172539a18f3df3780585473f0ea7a02ad919b9 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 14 Aug 2026 16:44:16 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat(commands):=20=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E9=BB=98=E8=AE=A4=E6=9F=A5=E5=85=A8=E9=9B=86?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E6=8C=89=E7=BE=A4/?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提示词仍只注入当前发送者可用命令;commands.search/get 默认查全部,可选传入 group_id 与 user_id 按视角过滤。 --- docs/slash-commands.md | 2 +- res/prompts/undefined.xml | 2 +- res/prompts/undefined_nagaagent.xml | 2 +- src/Undefined/services/commands/catalog.py | 99 ++++++++++++++-- src/Undefined/skills/toolsets/README.md | 2 +- .../skills/toolsets/commands/README.md | 6 +- .../skills/toolsets/commands/get/config.json | 10 +- .../skills/toolsets/commands/get/handler.py | 14 ++- .../toolsets/commands/search/config.json | 10 +- .../toolsets/commands/search/handler.py | 25 ++-- tests/test_command_catalog.py | 107 ++++++++++++++---- tests/test_system_prompt_constraints.py | 4 +- 12 files changed, 230 insertions(+), 53 deletions(-) diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 4d23da63..28077d7c 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -434,7 +434,7 @@ async def execute(args: list[str], context: CommandContext) -> None: - `"admin"`: 超级管理员 + `config.local.json` 动态添加的管理员均可执行。 - `"public"`: 群内或私聊中的任何用户均可执行。(注意风控和被滥用刷屏的风险) -> **可见性**:`/help` 会根据当前用户的权限级别过滤命令列表。`superadmin` 权限的命令不会对普通用户显示;`admin` 权限的命令不会对非管理员显示。主 AI 的系统提示会注入同一套可见命令摘要;需要限流、权限、用法或 README 时调用 `commands.search` / `commands.get`(纯文本匹配,不接 RAG)。无权或不存在时工具不返回文档。AI 只应介绍命令,不要代替用户发送斜杠命令。 +> **可见性**:`/help` 会根据当前用户的权限级别过滤命令列表。`superadmin` 权限的命令不会对普通用户显示;`admin` 权限的命令不会对非管理员显示。主 AI 的系统提示会注入**当前消息发送者**可用的命令摘要(不是完整目录);`commands.search` / `commands.get` 默认查询全部斜杠命令(纯文本匹配,不接 RAG),也可传入 `group_id`、`user_id` 或两者,改为按该用户在该群/私聊视角过滤。介绍时注明权限。AI 只应介绍命令,不要代替用户发送斜杠命令。 ### 4. 子命令声明式注册与自动推断 diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index c1f6e7e6..2f9cc2ea 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -191,7 +191,7 @@ **斜杠命令历史:** 斜杠命令优先于自动处理管线;命中命令后,本轮不会继续进入自动提取或 AI 自动回复。 用户发出的命令消息和 Bot 发送的命令结果会写入消息历史;后续对话中看到这些相邻的 Bot 消息时,应把它们当作已经执行过的命令结果,不要无故重复执行同一命令。 - 当前发送者可用的斜杠命令会另以系统块注入;回答命令怎么用时先看该列表,需要限流、权限、用法或文档时调用 commands.search / commands.get。不要编造不存在的命令,也不要代替用户发送斜杠命令。 + 当前消息发送者可用的斜杠命令会另以系统块注入(只含该发送者能用的,不是完整目录)。回答“他能用什么”时先看该列表;查询全部命令或详情时调用 commands.search / commands.get,介绍时注明权限。不要编造不存在的命令,也不要代替用户发送斜杠命令。 diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 5b68ea65..9370c2ec 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -191,7 +191,7 @@ **斜杠命令历史:** 斜杠命令优先于自动处理管线;命中命令后,本轮不会继续进入自动提取或 AI 自动回复。 用户发出的命令消息和 Bot 发送的命令结果会写入消息历史;后续对话中看到这些相邻的 Bot 消息时,应把它们当作已经执行过的命令结果,不要无故重复执行同一命令。 - 当前发送者可用的斜杠命令会另以系统块注入;回答命令怎么用时先看该列表,需要限流、权限、用法或文档时调用 commands.search / commands.get。不要编造不存在的命令,也不要代替用户发送斜杠命令。 + 当前消息发送者可用的斜杠命令会另以系统块注入(只含该发送者能用的,不是完整目录)。回答“他能用什么”时先看该列表;查询全部命令或详情时调用 commands.search / commands.get,介绍时注明权限。不要编造不存在的命令,也不要代替用户发送斜杠命令。 diff --git a/src/Undefined/services/commands/catalog.py b/src/Undefined/services/commands/catalog.py index 13a045aa..9bf87913 100644 --- a/src/Undefined/services/commands/catalog.py +++ b/src/Undefined/services/commands/catalog.py @@ -19,6 +19,21 @@ } +def coerce_optional_id(value: Any) -> int | None: + if value is None or isinstance(value, bool): + return None + if isinstance(value, int): + return value if value > 0 else None + text = str(value).strip() + if not text: + return None + try: + parsed = int(text) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + def permission_label(permission: str) -> str: labels = { "public": "公开", @@ -151,13 +166,17 @@ def format_available_commands_prompt(context: CommandContext) -> str: commands = list_visible_commands(context) scope_hint = "私聊" if is_private_scope(context) else "群聊" perm_hint = sender_permission_label(context) + footer = ( + "以上仅为当前消息发送者在本会话里能用的斜杠命令,不是完整命令目录。" + "查询全部命令(含当前发送者无权执行的)时调用 commands.search / commands.get;" + "介绍时注明权限与作用域。不要代替用户发送斜杠命令。" + ) if not commands: return ( "【当前发送者可用斜杠命令】\n" f"会话:{scope_hint} | 权限:{perm_hint}\n" "当前没有可展示的斜杠命令。\n" - "需要限流、权限、用法或文档时调用 commands.search / commands.get;" - "不要编造命令,也不要代替用户发送斜杠命令。" + f"{footer}" ) command_lines: list[str] = [] for item in commands: @@ -170,8 +189,7 @@ def format_available_commands_prompt(context: CommandContext) -> str: "【当前发送者可用斜杠命令】", f"会话:{scope_hint} | 权限:{perm_hint}", *command_lines, - "需要限流、权限、用法或文档时调用 commands.search / commands.get;" - "不要编造命令,也不要代替用户发送斜杠命令。", + footer, ] ) @@ -206,11 +224,19 @@ def _match_rank(meta: CommandMeta, query: str) -> int | None: def search_visible_commands(context: CommandContext, query: str) -> list[CommandMeta]: + return _search_commands(list_visible_commands(context), query) + + +def search_all_commands(registry: CommandRegistry, query: str) -> list[CommandMeta]: + return _search_commands(registry.list_commands(include_hidden=True), query) + + +def _search_commands(commands: list[CommandMeta], query: str) -> list[CommandMeta]: needle = _normalize_query(query) if not needle: return [] scored: list[tuple[int, int, str, CommandMeta]] = [] - for meta in list_visible_commands(context): + for meta in commands: rank = _match_rank(meta, needle) if rank is None: continue @@ -222,10 +248,7 @@ def search_visible_commands(context: CommandContext, query: str) -> list[Command def resolve_visible_command( context: CommandContext, command_name: str ) -> CommandMeta | None: - normalized = _normalize_query(command_name) - if not normalized: - return None - meta = context.registry.resolve(normalized) + meta = resolve_any_command(context.registry, command_name) if meta is None: return None visible = {item.name for item in list_visible_commands(context)} @@ -234,6 +257,15 @@ def resolve_visible_command( return meta +def resolve_any_command( + registry: CommandRegistry, command_name: str +) -> CommandMeta | None: + normalized = _normalize_query(command_name) + if not normalized: + return None + return registry.resolve(normalized) + + def make_viewer_context( registry: CommandRegistry, config: Any, @@ -265,7 +297,7 @@ def make_viewer_context( class CommandCatalog: - """面向 Prompt 与工具的可见命令查询入口。""" + """面向 Prompt 与工具的命令查询入口。""" def __init__(self, registry: CommandRegistry, config: Any) -> None: self.registry = registry @@ -324,11 +356,58 @@ def format_prompt_block(self, context: CommandContext) -> str: def search(self, context: CommandContext, query: str) -> list[CommandMeta]: return search_visible_commands(context, query) + def search_all(self, query: str) -> list[CommandMeta]: + return search_all_commands(self.registry, query) + def get(self, context: CommandContext, command_name: str) -> CommandMeta | None: return resolve_visible_command(context, command_name) + def get_any(self, command_name: str) -> CommandMeta | None: + return resolve_any_command(self.registry, command_name) + def format_detail(self, meta: CommandMeta) -> str: return format_command_detail(meta) def format_name(self, meta: CommandMeta) -> str: return format_command_name(meta) + + def format_permission(self, meta: CommandMeta) -> str: + return permission_label(meta.permission) + + def viewer_for_tool_args( + self, args: dict[str, Any] | None + ) -> CommandContext | None: + data = args if isinstance(args, dict) else {} + group_id = coerce_optional_id(data.get("group_id")) + user_id = coerce_optional_id(data.get("user_id")) + if user_id is None: + user_id = coerce_optional_id(data.get("qq")) + if group_id is None and user_id is None: + return None + if group_id is not None: + scope = "group" + resolved_group_id = group_id + else: + scope = "private" + resolved_group_id = 0 + return make_viewer_context( + self.registry, + self.config, + sender_id=user_id or 0, + scope=scope, + group_id=resolved_group_id, + user_id=user_id, + ) + + def format_viewer_hint(self, context: CommandContext) -> str: + parts: list[str] = [] + if is_private_scope(context): + parts.append("会话:私聊") + else: + parts.append(f"会话:群聊 {context.group_id}") + if context.sender_id: + parts.append(f"用户:{context.sender_id}") + else: + parts.append("用户:未指定") + parts.append(f"权限:{sender_permission_label(context)}") + return " | ".join(parts) diff --git a/src/Undefined/skills/toolsets/README.md b/src/Undefined/skills/toolsets/README.md index bb9fc897..9cc7c460 100644 --- a/src/Undefined/skills/toolsets/README.md +++ b/src/Undefined/skills/toolsets/README.md @@ -17,7 +17,7 @@ toolsets/ │ ├── search_memes/ # 表情包检索 │ └── send_meme_by_uid/ # 按 uid 发送表情包 ├── commands/ # 斜杠命令查询(文本匹配,不接 RAG) -│ ├── search/ # 按名称/别名/说明/文档检索可见命令 +│ ├── search/ # 按名称/别名/说明/文档检索全部命令 │ └── get/ # 取单条命令的权限、限流、用法和 README ├── render/ # 渲染工具集 │ ├── render_html/ # HTML 渲染 diff --git a/src/Undefined/skills/toolsets/commands/README.md b/src/Undefined/skills/toolsets/commands/README.md index 71bdc986..ab16fab8 100644 --- a/src/Undefined/skills/toolsets/commands/README.md +++ b/src/Undefined/skills/toolsets/commands/README.md @@ -1,8 +1,8 @@ # 斜杠命令查询工具集 -主 AI 可用这些工具查询当前发送者能执行的斜杠命令。匹配为纯文本子串,不接 RAG。 +主 AI 可用这些工具查询斜杠命令。匹配为纯文本子串,不接 RAG。系统提示里注入的是当前发送者能用的摘要;工具默认查完整目录。可传入 `group_id`、`user_id`(QQ)或两者,改为按该用户在该群/私聊的可用视角过滤。 -- `commands.search`:按名称/别名/说明/用法/文档检索可见命令 -- `commands.get`:取单条命令的权限、限流、用法和 README +- `commands.search`:按名称/别名/说明/用法/文档检索 +- `commands.get`:取单条命令的权限、限流、用法和 README;带视角参数时额外说明能否使用 该分类没有 `callable.json`,默认仅主 AI 可见。 diff --git a/src/Undefined/skills/toolsets/commands/get/config.json b/src/Undefined/skills/toolsets/commands/get/config.json index 2c825ecd..fae15033 100644 --- a/src/Undefined/skills/toolsets/commands/get/config.json +++ b/src/Undefined/skills/toolsets/commands/get/config.json @@ -2,13 +2,21 @@ "type": "function", "function": { "name": "get", - "description": "获取当前发送者可用的某条斜杠命令的详细说明,包括用法、示例、别名、权限(谁可用)、作用域、限流和帮助文档。命令名支持别名。无权或不存在时不返回文档。", + "description": "获取斜杠命令的详细说明,包括用法、示例、别名、权限(谁可用)、作用域、限流和帮助文档。默认查完整目录。可传入 group_id、user_id(QQ)或两者,额外说明该用户在该群/私聊视角下能否使用。命令名支持别名。不存在时不返回文档。", "parameters": { "type": "object", "properties": { "name": { "type": "string", "description": "命令名或别名,可带或不带前导 /,如 profile 或 /p" + }, + "group_id": { + "type": "integer", + "description": "可选。群号。传入后同时判断该群会话下指定用户能否使用该命令。可与 user_id 一起传。" + }, + "user_id": { + "type": "integer", + "description": "可选。用户 QQ 号。传入后同时判断该用户能否使用该命令。只传本参数时按私聊作用域。可与 group_id 一起传。" } }, "required": ["name"] diff --git a/src/Undefined/skills/toolsets/commands/get/handler.py b/src/Undefined/skills/toolsets/commands/get/handler.py index fb997c2c..f4bd8b60 100644 --- a/src/Undefined/skills/toolsets/commands/get/handler.py +++ b/src/Undefined/skills/toolsets/commands/get/handler.py @@ -10,8 +10,14 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: name = str(args.get("name") or "").strip() if not name: return "请提供命令名" - viewer = catalog.viewer_from_mapping(context) - meta = catalog.get(viewer, name) + meta = catalog.get_any(name) if meta is None: - return "未找到命令,或当前发送者无权查看" - return str(catalog.format_detail(meta)) + return "未找到命令" + detail = str(catalog.format_detail(meta)) + viewer = catalog.viewer_for_tool_args(args) + if viewer is None: + return detail + hint = catalog.format_viewer_hint(viewer) + if catalog.get(viewer, name) is None: + return f"视角:{hint}\n该视角无权使用该命令。\n\n{detail}" + return f"视角:{hint}\n该视角可以使用该命令。\n\n{detail}" diff --git a/src/Undefined/skills/toolsets/commands/search/config.json b/src/Undefined/skills/toolsets/commands/search/config.json index c920de91..a348214c 100644 --- a/src/Undefined/skills/toolsets/commands/search/config.json +++ b/src/Undefined/skills/toolsets/commands/search/config.json @@ -2,13 +2,21 @@ "type": "function", "function": { "name": "search", - "description": "按文本匹配查询当前发送者可用的斜杠命令。匹配命令名、别名、说明、用法、示例和帮助文档;不使用语义检索。用于回答“有哪些命令/某个命令大概干什么”。需要完整限流、权限、用法或文档时改用 commands.get。", + "description": "按文本匹配查询斜杠命令。默认查完整目录(不限当前发送者权限)。可传入 group_id、user_id(QQ)或两者,改为查询该用户在该群/私聊视角下可用的命令。匹配命令名、别名、说明、用法、示例和帮助文档;不使用语义检索。需要完整限流、权限、用法或文档时改用 commands.get。", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "查询词,如 help、侧写、profile、限流" + }, + "group_id": { + "type": "integer", + "description": "可选。群号。传入后按该群会话过滤可用命令(含仅群聊命令)。可与 user_id 一起传;都不传则查完整目录。" + }, + "user_id": { + "type": "integer", + "description": "可选。用户 QQ 号。传入后按该用户权限过滤可用命令。只传本参数时按私聊作用域。可与 group_id 一起传;都不传则查完整目录。" } }, "required": ["query"] diff --git a/src/Undefined/skills/toolsets/commands/search/handler.py b/src/Undefined/skills/toolsets/commands/search/handler.py index e25b881d..081c7590 100644 --- a/src/Undefined/skills/toolsets/commands/search/handler.py +++ b/src/Undefined/skills/toolsets/commands/search/handler.py @@ -10,13 +10,24 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: query = str(args.get("query") or "").strip() if not query: return "请提供查询关键词" - viewer = catalog.viewer_from_mapping(context) - matches = catalog.search(viewer, query) - if not matches: - return f"没有匹配“{query}”的可用斜杠命令" - lines = [f"匹配到 {len(matches)} 条可用命令:"] + viewer = catalog.viewer_for_tool_args(args) + if viewer is None: + matches = catalog.search_all(query) + if not matches: + return f"没有匹配“{query}”的斜杠命令" + header = f"匹配到 {len(matches)} 条命令(完整目录,不限执行权限):" + else: + matches = catalog.search(viewer, query) + hint = catalog.format_viewer_hint(viewer) + if not matches: + return f"视角:{hint}\n没有匹配“{query}”的可用斜杠命令" + header = f"视角:{hint}\n匹配到 {len(matches)} 条该视角可用命令:" + lines = [header] for meta in matches: desc = meta.description or "暂无说明" - lines.append(f"- {catalog.format_name(meta)} — {desc}") - lines.append("需要限流、权限、用法或文档时调用 commands.get。") + lines.append( + f"- {catalog.format_name(meta)} — {desc}" + f"(权限:{catalog.format_permission(meta)})" + ) + lines.append("需要限流、用法或文档时调用 commands.get;介绍时注明谁能用。") return "\n".join(lines) diff --git a/tests/test_command_catalog.py b/tests/test_command_catalog.py index 73b5dafb..94163044 100644 --- a/tests/test_command_catalog.py +++ b/tests/test_command_catalog.py @@ -240,6 +240,8 @@ def test_catalog_search_ranks_name_alias_description_then_doc(tmp_path: Path) -> public_search = catalog.search(viewer, "机密") assert public_search == [] + all_secret = {item.name for item in catalog.search_all("机密")} + assert all_secret == {"admincmd", "super"} def test_catalog_get_hides_unauthorized_docs(tmp_path: Path) -> None: @@ -249,7 +251,9 @@ def test_catalog_get_hides_unauthorized_docs(tmp_path: Path) -> None: assert catalog.get(public_viewer, "/p") is not None assert catalog.get(public_viewer, "admincmd") is None + assert catalog.get_any("admincmd") is not None assert catalog.get(public_viewer, "missing") is None + assert catalog.get_any("missing") is None admin_meta = catalog.get(admin_viewer, "ac") assert admin_meta is not None @@ -260,7 +264,7 @@ def test_catalog_get_hides_unauthorized_docs(tmp_path: Path) -> None: @pytest.mark.asyncio -async def test_commands_search_and_get_tools_respect_visibility( +async def test_commands_search_and_get_tools_query_all_commands( tmp_path: Path, ) -> None: catalog = _make_catalog(tmp_path) @@ -270,37 +274,96 @@ async def test_commands_search_and_get_tools_respect_visibility( "request_type": "group", "group_id": 10001, } - admin_context: dict[str, Any] = { - "command_catalog": catalog, - "sender_id": ADMIN_USER, - "request_type": "group", - "group_id": 10001, - } empty = await commands_search_execute({"query": ""}, public_context) assert empty == "请提供查询关键词" - none = await commands_search_execute({"query": "机密"}, public_context) - assert "没有匹配" in none - - listed = await commands_search_execute({"query": "侧写"}, public_context) - assert "/profile(/p)" in listed - assert "admincmd" not in listed - - denied = await commands_get_execute({"name": "admincmd"}, public_context) - assert denied == "未找到命令,或当前发送者无权查看" - assert "机密" not in denied + listed = await commands_search_execute({"query": "机密"}, public_context) + assert "/admincmd(/ac)" in listed + assert "权限:管理员" in listed missing = await commands_get_execute({"name": "nope"}, public_context) - assert missing == "未找到命令,或当前发送者无权查看" + assert missing == "未找到命令" + + admin_detail = await commands_get_execute({"name": "admincmd"}, public_context) + assert "管理员机密文档,禁止泄露。" in admin_detail + assert "权限:管理员" in admin_detail allowed = await commands_get_execute({"name": "/p"}, public_context) assert "/profile(/p)" in allowed assert "查看用户或群侧写。" in allowed assert "子命令:" in allowed - admin_detail = await commands_get_execute({"name": "admincmd"}, admin_context) - assert "管理员机密文档,禁止泄露。" in admin_detail + +@pytest.mark.asyncio +async def test_commands_tools_filter_by_optional_group_and_user( + tmp_path: Path, +) -> None: + catalog = _make_catalog(tmp_path) + public_context: dict[str, Any] = { + "command_catalog": catalog, + "sender_id": PUBLIC_USER, + "request_type": "group", + "group_id": 10001, + } + + assert catalog.viewer_for_tool_args({}) is None + assert catalog.viewer_for_tool_args({"query": "help"}) is None + + group_only = catalog.viewer_for_tool_args({"group_id": "10001"}) + assert group_only is not None + assert group_only.scope == "group" + assert group_only.group_id == 10001 + assert group_only.sender_id == 0 + + user_only = catalog.viewer_for_tool_args({"user_id": str(ADMIN_USER)}) + assert user_only is not None + assert user_only.scope == "private" + assert user_only.sender_id == ADMIN_USER + + both = catalog.viewer_for_tool_args({"group_id": 10001, "qq": PUBLIC_USER}) + assert both is not None + assert both.scope == "group" + assert both.sender_id == PUBLIC_USER + + public_group = await commands_search_execute( + {"query": "命令", "group_id": 10001, "user_id": PUBLIC_USER}, + public_context, + ) + assert "视角:会话:群聊 10001 | 用户:10001 | 权限:普通用户" in public_group + assert "/help(/h)" in public_group + assert "grouponly" in public_group + assert "admincmd" not in public_group + + public_private = await commands_search_execute( + {"query": "命令", "user_id": PUBLIC_USER}, + public_context, + ) + assert "视角:会话:私聊 | 用户:10001 | 权限:普通用户" in public_private + assert "grouponly" not in public_private + assert "/help(/h)" in public_private + + admin_group = await commands_search_execute( + {"query": "机密", "group_id": 10001, "user_id": ADMIN_USER}, + public_context, + ) + assert "/admincmd(/ac)" in admin_group + assert "super" not in admin_group + + denied = await commands_get_execute( + {"name": "admincmd", "group_id": 10001, "user_id": PUBLIC_USER}, + public_context, + ) + assert "该视角无权使用该命令。" in denied + assert "管理员机密文档,禁止泄露。" in denied + assert "会话:群聊 10001" in denied + + allowed = await commands_get_execute( + {"name": "grouponly", "group_id": 10001, "user_id": PUBLIC_USER}, + public_context, + ) + assert "该视角可以使用该命令。" in allowed + assert "仅群聊可用的命令" in allowed def test_format_prompt_block_lists_visible_commands_only(tmp_path: Path) -> None: @@ -313,7 +376,9 @@ def test_format_prompt_block_lists_visible_commands_only(tmp_path: Path) -> None assert "/help(/h) — 显示命令列表或详细帮助" in public_block assert "/profile(/p) — 查看认知侧写(1个子命令)" in public_block assert "admincmd" not in public_block - assert "不要编造命令,也不要代替用户发送斜杠命令。" in public_block + assert "不要代替用户发送斜杠命令。" in public_block + assert "不是完整命令目录" in public_block + assert "查询全部命令" in public_block admin_block = catalog.format_prompt_block( _viewer(catalog, sender_id=ADMIN_USER, scope="private") diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index 0992f925..0cef2c20 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -61,8 +61,8 @@ def test_system_prompts_explain_slash_command_lookup_tools(path: Path) -> None: text = path.read_text(encoding="utf-8") required_snippets = [ - "当前发送者可用的斜杠命令会另以系统块注入", - "需要限流、权限、用法或文档时调用 commands.search / commands.get", + "当前消息发送者可用的斜杠命令会另以系统块注入(只含该发送者能用的,不是完整目录)", + "查询全部命令或详情时调用 commands.search / commands.get", "不要编造不存在的命令", "也不要代替用户发送斜杠命令", ] From 441fb299bfbaf94f52065f9fa154e701e55cde52 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 14 Aug 2026 16:56:47 +0800 Subject: [PATCH 08/16] =?UTF-8?q?fix(end):=20=E9=9D=99=E9=BB=98=E5=A1=AB?= =?UTF-8?q?=20memo=20=E6=97=B6=E4=B8=8D=E8=A6=81=E6=8A=8A=E5=8F=91?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=BD=93=E6=88=90=E9=BB=98=E8=AE=A4=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拒绝话术仍要求该回则先发、不回则 force=true,但明确禁止为通过检查去补发消息。 Co-authored-by: Cursor --- src/Undefined/skills/tools/end/handler.py | 4 +++- tests/test_end_tool.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Undefined/skills/tools/end/handler.py b/src/Undefined/skills/tools/end/handler.py index 79c740a4..55564b0e 100644 --- a/src/Undefined/skills/tools/end/handler.py +++ b/src/Undefined/skills/tools/end/handler.py @@ -314,7 +314,9 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: ) return ( "拒绝结束对话:你填写了 memo(本轮行动备忘)但本轮未发送任何消息或媒体内容。" - "请先发送消息给用户,或使用 force=true 强制结束。" + "若本轮本应回复却还没发出去,先发送消息或媒体后再调用 end;" + "若本轮本来就不回复(含静默/闸门未通过),使用 force=true 强制结束," + "不要为了通过这项检查去给用户发消息。" "若本轮确实未做任何事,建议留空 memo 以避免记忆噪声。当然,你要存也没关系。这只是个提示,防止你忘了。" "若你获取到了新信息,应填写 observations 字段以保存这些信息,而不是放在 memo 里。" ) diff --git a/tests/test_end_tool.py b/tests/test_end_tool.py index 6f06df03..2ed07697 100644 --- a/tests/test_end_tool.py +++ b/tests/test_end_tool.py @@ -33,6 +33,9 @@ async def test_end_rejects_when_force_string_false_and_no_message_sent() -> None ) assert result.startswith("拒绝结束对话") + assert "force=true" in result + assert "请先发送消息给用户" not in result + assert "不要为了通过这项检查去给用户发消息" in result assert context.get("conversation_ended") is not True From 7080eef508f4a895311f38a2b003a939078f8f86 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Fri, 14 Aug 2026 17:16:58 +0800 Subject: [PATCH 09/16] =?UTF-8?q?feat(cognitive):=20=E4=BE=A7=E5=86=99?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=8B=AC=E7=AB=8B=E9=94=90=E8=AF=84=E6=AE=B5?= =?UTF-8?q?=E5=B9=B6=E5=8D=95=E7=8B=AC=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 史官 update_profile 拆成评价、正文、锐评三个字段;/profile 图片把锐评放在最后。 Co-authored-by: Cursor --- docs/cognitive-memory.md | 4 +- res/prompts/historian_profile_merge.md | 23 +++- src/Undefined/cognitive/historian/tools.py | 16 ++- src/Undefined/cognitive/historian/worker.py | 65 +++++----- src/Undefined/cognitive/service/helpers.py | 43 ++++++- src/Undefined/cognitive/service/service.py | 5 +- .../skills/commands/profile/handler.py | 36 +++++- tests/test_cognitive_historian.py | 116 +++++++++++++++++- tests/test_cognitive_service.py | 50 +++++++- tests/test_profile_command.py | 8 ++ tests/test_system_prompt_constraints.py | 2 +- 11 files changed, 307 insertions(+), 61 deletions(-) diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index 4141bdbd..87eef630 100644 --- a/docs/cognitive-memory.md +++ b/docs/cognitive-memory.md @@ -242,9 +242,11 @@ source_event_id: abc123_0_1740218400000 --- - 在校学生/业余开发者,做技术取舍会权衡时间、算力与预算。 - 独立维护开源项目,关注 AI 应用与 Agent 工程化落地。 +--- +把配置当信仰,把别人的「差不多」当人身攻击。 ``` -文件固定为三段:`---元数据---评价---正文`。评价是 YAML 与正文之间的独立段落,不写入 frontmatter,也不并入正文条目。旧文件若只有一对 `---`,其后全部视为正文(评价为空,下次史官合并时应重写补齐)。 +文件固定为四段:`---元数据---评价---正文---锐评`。评价是 YAML 与正文之间的独立段落;锐评在正文之后单独成段。两者都不写入 frontmatter,也不并入正文条目。旧文件若只有一对 `---`,其后全部视为正文(评价与锐评为空);若只有两对 `---`,则中间为评价、末段为正文(锐评为空)。缺评价或锐评时,下次史官合并应重写补齐。 每次更新前自动备份到 `data/cognitive/profiles/history/{type}/{id}/{timestamp}.md`,默认保留最近 5 个版本。 diff --git a/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index c01470e1..00397a39 100644 --- a/res/prompts/historian_profile_merge.md +++ b/res/prompts/historian_profile_merge.md @@ -10,7 +10,7 @@ 工具使用规则(严格执行): - **修改任何侧写前,必须先调用 `read_profile` 查看其当前内容**,确认已读取后再决定是否调用 `update_profile`。 -- 读完后先对照**当前**撰写规范自检:文件结构必须是 `---元数据---评价---正文` 三段;评价段独立且非空;结构(第一条定调 + 少量维度)、tags 只写身份/角色/核心领域、无时序流水账、克制去冗、核心画像有辨识度。缺评价段或评价为空 = 不合规。不合规则必须重写,不得因“没有新稳定特征”而 skip。 +- 读完后先对照**当前**撰写规范自检:文件结构必须是 `---元数据---评价---正文---锐评` 四段;评价段与锐评段均独立且非空;结构(第一条定调 + 少量维度)、tags 只写身份/角色/核心领域、无时序流水账、克制去冗、核心画像有辨识度。缺评价段、缺锐评段,或任一为空 = 不合规。不合规则必须重写,不得因“没有新稳定特征”而 skip。 - 可以调用 `read_profile` 查看任意实体的侧写(不限于目标实体)。 - 只允许通过 `update_profile` 工具写入侧写,禁止输出普通文本内容。 @@ -71,9 +71,9 @@ - 边缘、偶发、低辨识度特征可省略;细节级事实留给事件记忆,侧写只留稳定画像 - 若旧侧写已冗长或条目过多,本轮应顺手压缩重整为更紧凑的合规版本 7. **核心画像要抓独特性**:第一句要写出"这个人区别于其他人的本质",而非通用描述(如"开发者"太泛,"把系统当产线打理的工程型开发者"才有辨识度) -8. 若 `current_profile` 本身不符合以上规范(含冗长膨胀、话题 tags、时序流水账、结构缺失、缺评价段或评价为空),**必须** `skip=false` 并整体重写为合规版本;即使本轮没有可沉淀新事实,也只重整旧画像,禁止把一次性事件写进去。不必保留其原有写法。 +8. 若 `current_profile` 本身不符合以上规范(含冗长膨胀、话题 tags、时序流水账、结构缺失、缺评价段或评价为空、缺锐评段或锐评为空),**必须** `skip=false` 并整体重写为合规版本;即使本轮没有可沉淀新事实,也只重整旧画像,禁止把一次性事件写进去。不必保留其原有写法。 -侧写文件结构固定为三段:`---元数据---评价---正文`。评价**不写入 YAML frontmatter**,也不并入 summary 条目。 +侧写文件结构固定为四段:`---元数据---评价---正文---锐评`。评价与锐评**不写入 YAML frontmatter**,也不并入 summary 条目,彼此不得混写。 ```markdown --- @@ -88,15 +88,26 @@ updated_at: "..." --- - 在校学生/业余开发者,做技术取舍会权衡时间、算力与预算。 - ... +--- +把配置当信仰,把别人的「差不多」当人身攻击。 ``` -**评价段**(`update_profile.evaluation`,独立段,禁止放进 YAML 或 summary): +**评价段**(`update_profile.evaluation`,独立段,禁止放进 YAML、summary 或 roast): - 必填;1 段,约 40–150 字 / 2–5 句;可同时含正负 +- 冷静定位:这个人/群稳定成立的判断,不是吐槽 - 基于稳定画像,禁止事件流水账、时序词、恭维套话、泄愤 - 群侧写评价群氛围/文化,不写单个成员隐私 - 段内禁止单独成行的 `---`,以免切断分段 - 缺评价段或为空 = 不合规 → 必须重写 +**锐评段**(`update_profile.roast`,独立段,写在正文之后,禁止放进 YAML、evaluation 或 summary): +- 必填;约 20–80 字 / 1–3 句;尖锐、调侃、一针见血,可带刺 +- 评价是冷静定位,锐评是最后那句刺;禁止复述评价、禁止写成第二条评价 +- 禁止辱骂、泄愤、人身攻击、隐私;禁止事件流水账与时序词 +- 群侧写只锐评群氛围/文化,不写单个成员隐私 +- 段内禁止单独成行的 `---`,以免切断分段 +- 缺锐评段或为空 = 不合规 → 必须重写 + 侧写 = **长期高层画像**:只保留身份、风格、偏好、能力等核心抽象,应能脱离具体聊天记录独立理解「这个人/群是谁」,而非「他做了什么」。正文写的是**当前仍成立的状态**,不是事件流水账。 严禁写入以下内容(这些属于事件记忆,不应进入侧写): @@ -113,7 +124,7 @@ updated_at: "..." `summary` 输出格式约束: -**用户侧写**结构(使用 Markdown 项目符号 `- `;这是正文,不是评价段): +**用户侧写**结构(使用 Markdown 项目符号 `- `;这是正文,不是评价段也不是锐评): 1. **第一条:核心画像** - 一句话定位这个人的身份与核心特质,为后续条目定调 - 要具体,不要泛泛而谈(如"在校学生/业余开发者,做技术取舍会权衡时间、算力与预算"比"务实的开发者"信息量大) @@ -163,7 +174,7 @@ updated_at: "..." 输出规则(调用 `update_profile` 工具): - 若应跳过更新:仅当现有侧写已符合当前撰写规范 **且** 本轮无新稳定特征。`skip=true`,并给出 `skip_reason`;`summary` 置空字符串,`tags` 可为空数组。**调用后流程立即终止,无法再执行任何操作,请确保在此之前已完成所有必要的读取与更新。** -- 若执行更新:`skip=false`,返回 `evaluation`、`summary` 和 `tags`。`evaluation` 是 YAML 与正文之间的独立评价段(必填非空);`summary` 只含正文画像条目。禁止把评价写入 YAML 或并入 summary。同样,调用后流程立即终止。 +- 若执行更新:`skip=false`,返回 `evaluation`、`summary`、`roast` 和 `tags`。`evaluation` 是 YAML 与正文之间的独立评价段(必填非空);`summary` 只含正文画像条目;`roast` 是正文之后的独立锐评(必填非空)。禁止把评价或锐评写入 YAML,禁止把三者互相并入。同样,调用后流程立即终止。 - `entity_type` 和 `entity_id` 必须与目标实体一致。 - `name` 使用目标实体的显示名(优先 `{target_display_name}`),不要把 `{target_entity_id}` 当昵称随意改写。 - 必须通过 `update_profile` 工具返回结构化参数,禁止输出普通文本内容。 diff --git a/src/Undefined/cognitive/historian/tools.py b/src/Undefined/cognitive/historian/tools.py index ced7e3b5..c1053463 100644 --- a/src/Undefined/cognitive/historian/tools.py +++ b/src/Undefined/cognitive/historian/tools.py @@ -73,13 +73,25 @@ "type": "string", "description": ( "YAML 与正文之间的独立客观评价(约 40–150 字 / 2–5 句)。" - "不写入 YAML,也不并入 summary。更新时必填非空;" + "不写入 YAML,也不并入 summary 或 roast。更新时必填非空;" "禁止单独成行的 ---。" ), }, "summary": { "type": "string", - "description": "侧写正文(Markdown 画像条目,不含评价段)", + "description": ( + "侧写正文(Markdown 画像条目,不含评价段与锐评)。" + "更新时必填非空;禁止单独成行的 ---。" + ), + }, + "roast": { + "type": "string", + "description": ( + "正文之后的独立锐评(约 20–80 字 / 1–3 句)。" + "尖锐、调侃、一针见血,可带刺;禁止辱骂、泄愤、人身攻击或隐私。" + "群侧写只锐评群氛围/文化。不写入 YAML,也不并入 evaluation 或 summary。" + "更新时必填非空;禁止单独成行的 ---。" + ), }, }, "required": ["entity_type", "entity_id", "skip", "name", "tags", "summary"], diff --git a/src/Undefined/cognitive/historian/worker.py b/src/Undefined/cognitive/historian/worker.py index 57cd44e8..16e3e349 100644 --- a/src/Undefined/cognitive/historian/worker.py +++ b/src/Undefined/cognitive/historian/worker.py @@ -15,7 +15,7 @@ ) from Undefined.cognitive.service.helpers import ( _build_profile_vector_payload, - _has_standalone_delimiter, + _profile_section_error, _serialize_profile_markdown, ) from Undefined.cognitive.vector_store_compat import call_vector_store_method @@ -484,6 +484,7 @@ async def _write_profile( tags: list[str], summary: str, evaluation: str, + roast: str, event_id: str, perspective: str, now_timezone: tzinfo | None = None, @@ -508,7 +509,7 @@ async def _write_profile( frontmatter["group_name"] = effective_name frontmatter["group_id"] = entity_id content = _serialize_profile_markdown( - frontmatter, summary, evaluation=evaluation + frontmatter, summary, evaluation=evaluation, roast=roast ) await self._profile_storage.write_profile(entity_type, entity_id, content) @@ -528,6 +529,7 @@ async def _write_profile( tags=tags, summary=summary, evaluation=evaluation, + roast=roast, ) await call_vector_store_method( @@ -882,49 +884,45 @@ async def _merge_profile_target( continue summary = str(tc_args.get("summary", "")).strip() - if not summary: - logger.info( - "[史官] 任务 %s 侧写更新跳过: target=%s:%s reason=empty_summary", - event_id, - up_et, - up_eid, - ) - tool_results.append( - { - "role": "tool", - "tool_call_id": tc_id, - "content": "错误:summary 为空", - } - ) - continue evaluation = str(tc_args.get("evaluation", "")).strip() - if not evaluation: - logger.info( - "[史官] 任务 %s 侧写更新跳过: target=%s:%s reason=empty_evaluation", - event_id, - up_et, - up_eid, + roast = str(tc_args.get("roast", "")).strip() + section_error = ( + _profile_section_error( + summary, + empty_reason="empty_summary", + empty_content="错误:summary 为空", + delimiter_reason="summary_delimiter", + delimiter_content="错误:正文不能包含单独成行的 ---", ) - tool_results.append( - { - "role": "tool", - "tool_call_id": tc_id, - "content": "错误:evaluation 为空", - } + or _profile_section_error( + evaluation, + empty_reason="empty_evaluation", + empty_content="错误:evaluation 为空", + delimiter_reason="evaluation_delimiter", + delimiter_content="错误:评价段不能包含单独成行的 ---", ) - continue - if _has_standalone_delimiter(evaluation): + or _profile_section_error( + roast, + empty_reason="empty_roast", + empty_content="错误:roast 为空", + delimiter_reason="roast_delimiter", + delimiter_content="错误:锐评不能包含单独成行的 ---", + ) + ) + if section_error is not None: + skip_reason, error_content = section_error logger.info( - "[史官] 任务 %s 侧写更新跳过: target=%s:%s reason=evaluation_delimiter", + "[史官] 任务 %s 侧写更新跳过: target=%s:%s reason=%s", event_id, up_et, up_eid, + skip_reason, ) tool_results.append( { "role": "tool", "tool_call_id": tc_id, - "content": "错误:评价段不能包含单独成行的 ---", + "content": error_content, } ) continue @@ -957,6 +955,7 @@ async def _merge_profile_target( tags=up_tags, summary=summary, evaluation=evaluation, + roast=roast, event_id=event_id, perspective=perspective, now_timezone=now_local_dt.tzinfo, diff --git a/src/Undefined/cognitive/service/helpers.py b/src/Undefined/cognitive/service/helpers.py index f0108cd3..17053678 100644 --- a/src/Undefined/cognitive/service/helpers.py +++ b/src/Undefined/cognitive/service/helpers.py @@ -106,13 +106,29 @@ def _has_standalone_delimiter(text: str) -> bool: return any(line.strip() == "---" for line in str(text).splitlines()) +def _profile_section_error( + value: str, + *, + empty_reason: str, + empty_content: str, + delimiter_reason: str, + delimiter_content: str, +) -> tuple[str, str] | None: + if not value: + return empty_reason, empty_content + if _has_standalone_delimiter(value): + return delimiter_reason, delimiter_content + return None + + def _parse_profile_markdown( markdown: str, -) -> tuple[dict[str, Any], str, str] | None: +) -> tuple[dict[str, Any], str, str, str] | None: """解析侧写 Markdown。 - 返回 ``(frontmatter, evaluation, body)``。旧文件只有一对 ``---`` 时 - ``evaluation`` 为空,其后全部当作 body。 + 返回 ``(frontmatter, evaluation, body, roast)``。 + 旧文件只有一对 ``---`` 时评价与锐评为空,其后全部当作 body; + 只有两对 ``---`` 时锐评为空,中间段为评价、末段为 body。 """ text = str(markdown or "") if not text.startswith("---"): @@ -123,7 +139,7 @@ def _parse_profile_markdown( rest = text[3:] if rest.startswith("\n"): rest = rest[1:] - parts = _PROFILE_SECTION_SPLIT.split(rest, maxsplit=2) + parts = _PROFILE_SECTION_SPLIT.split(rest, maxsplit=3) if len(parts) < 2: return None frontmatter = yaml.safe_load(parts[0]) @@ -132,10 +148,16 @@ def _parse_profile_markdown( if len(parts) == 2: evaluation = "" body = parts[1].lstrip("\n") + roast = "" + elif len(parts) == 3: + evaluation = parts[1].strip() + body = parts[2].lstrip("\n") + roast = "" else: evaluation = parts[1].strip() body = parts[2].lstrip("\n") - return frontmatter, evaluation, body + roast = parts[3].strip() + return frontmatter, evaluation, body, roast except Exception: return None @@ -144,11 +166,18 @@ def _serialize_profile_markdown( frontmatter: dict[str, Any], body: str, evaluation: str = "", + roast: str = "", ) -> str: import yaml yaml_text = yaml.dump(frontmatter, allow_unicode=True) eval_text = str(evaluation or "").strip() + roast_text = str(roast or "").strip() + body_text = str(body or "") + if roast_text: + if body_text and not body_text.endswith("\n"): + body_text += "\n" + return f"---\n{yaml_text}---\n{eval_text}\n---\n{body_text}---\n{roast_text}\n" if eval_text: return f"---\n{yaml_text}---\n{eval_text}\n---\n{body}" return f"---\n{yaml_text}---\n{body}" @@ -174,6 +203,7 @@ def _build_profile_vector_payload( tags: list[str], summary: str, evaluation: str = "", + roast: str = "", ) -> tuple[str, dict[str, Any]]: profile_doc_lines: list[str] = [] if entity_type == "user": @@ -187,6 +217,9 @@ def _build_profile_vector_payload( eval_text = str(evaluation or "").strip() if eval_text: profile_doc_lines.append(f"评价: {eval_text}") + roast_text = str(roast or "").strip() + if roast_text: + profile_doc_lines.append(f"锐评: {roast_text}") profile_doc_lines.append(summary) profile_doc = "\n".join(line for line in profile_doc_lines if line.strip()) diff --git a/src/Undefined/cognitive/service/service.py b/src/Undefined/cognitive/service/service.py index 2ae6f749..a819d99b 100644 --- a/src/Undefined/cognitive/service/service.py +++ b/src/Undefined/cognitive/service/service.py @@ -120,7 +120,7 @@ async def sync_profile_display_name( parsed = _parse_profile_markdown(existing) if parsed is None: return False - frontmatter, evaluation, summary = parsed + frontmatter, evaluation, summary, roast = parsed current_name = _current_profile_name(normalized_entity_type, frontmatter) if current_name == normalized_name: return False @@ -135,7 +135,7 @@ async def sync_profile_display_name( frontmatter["group_id"] = normalized_entity_id updated_markdown = _serialize_profile_markdown( - frontmatter, summary, evaluation=evaluation + frontmatter, summary, evaluation=evaluation, roast=roast ) await self._profile_storage.write_profile( normalized_entity_type, @@ -150,6 +150,7 @@ async def sync_profile_display_name( tags=_normalize_profile_tags(frontmatter.get("tags")), summary=summary, evaluation=evaluation, + roast=roast, ) await call_vector_store_method( self._vector_store.upsert_profile, diff --git a/src/Undefined/skills/commands/profile/handler.py b/src/Undefined/skills/commands/profile/handler.py index 0d94f05d..10b7e279 100644 --- a/src/Undefined/skills/commands/profile/handler.py +++ b/src/Undefined/skills/commands/profile/handler.py @@ -164,12 +164,12 @@ def _render_meta_rows( def _split_profile_for_render( profile_text: str, -) -> tuple[dict[str, Any] | None, str, str]: +) -> tuple[dict[str, Any] | None, str, str, str]: parsed = _parse_profile_markdown(profile_text) if parsed is None: - return None, "", profile_text - frontmatter, evaluation, body = parsed - return frontmatter, evaluation, body or "" + return None, "", profile_text, "" + frontmatter, evaluation, body, roast = parsed + return frontmatter, evaluation, body or "", roast # ── 发送方法 ────────────────────────────────────────────────── @@ -217,10 +217,10 @@ async def _send_render( context: CommandContext, profile_text: str, ) -> None: - """渲染为图片发送:YAML 键值表、独立评价区、Markdown 正文。""" + """渲染为图片发送:YAML 键值表、独立评价区、Markdown 正文、锐评在最后。""" from Undefined.render import render_html_to_image - frontmatter, evaluation, body = _split_profile_for_render(profile_text) + frontmatter, evaluation, body, roast = _split_profile_for_render(profile_text) meta_rows_html = "" for key, val in _render_meta_rows(frontmatter, len(profile_text)): meta_rows_html += ( @@ -239,6 +239,15 @@ async def _send_render( body_html = _markdown_to_html(body) if body.strip() else "" + roast_html = "" + if roast.strip(): + roast_html = ( + '
    ' + '
    锐评
    ' + f"

    {html.escape(roast.strip())}

    " + "
    " + ) + html_content = f"""