Files
kefu/wechat_rpa/test_agent_rules.py
T
2026-09-21 10:34:06 +08:00

322 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""智能体(角色 + 规则)。
重点不是"配置能读出来",而是**那条已经发出去过的错误回复现在发不出去了**:
客户发来血糖值「13」,模型回"这串像系统编码"。提示词里写了不许这么说也没用,
所以这里逐条盯的是出口那一道校验。
"""
from unittest import TestCase, main, mock
import agent_rules
import ai_chat
import ai_config
def _agent(**overrides):
item = {
"id": "health-advisor",
"name": "健康顾问",
"role": "糖尿病专科医生",
"description": "血糖、用药",
"persona": "你负责血糖和用药相关的问题。",
"keywords": ["血糖", "空腹"],
"rules": [],
"enabled": True,
"priority": 10,
}
item.update(overrides)
return item
FORBID_ENCODING = {
"id": "no-encoding-excuse",
"label": "不许把客户的话说成乱码",
"type": "forbid",
"keywords": ["系统编码", "无法确认具体意思"],
"instruction": "",
"reply": "",
"enabled": True,
}
class UseAgents:
"""在一个用例内替换掉全局的智能体配置。"""
def __init__(self, agents, plan):
self.patches = [
mock.patch.object(ai_config, "AI_AGENTS", agents),
mock.patch.object(ai_config, "AI_AGENT_PLAN", plan),
]
def __enter__(self):
for patch in self.patches:
patch.start()
return self
def __exit__(self, *exc):
for patch in reversed(self.patches):
patch.stop()
return False
class NoAgentsConfigured(TestCase):
"""没配智能体时必须一个字都不加——这是这套东西能安全上线的前提。"""
def test_prompt_is_untouched(self) -> None:
with UseAgents([], {}):
self.assertEqual(agent_rules.prompt_sections("血糖13"), "")
self.assertEqual(agent_rules.canned_reply("血糖13"), "")
def test_reply_still_gets_the_builtin_guard(self) -> None:
"""内置兜底不依赖配置:一个智能体都没有时照样拦。"""
with UseAgents([], {}):
self.assertNotIn(
"系统编码",
agent_rules.enforce_reply("这串像系统编码,解析后可能是13。", "13"),
)
class BuiltinRepair(TestCase):
def test_pure_number_gets_a_human_answer(self) -> None:
repaired = agent_rules.builtin_repair("这串像系统编码,解析后可能是13。", "13")
self.assertIn("13", repaired)
self.assertNotIn("系统编码", repaired)
def test_visible_text_is_quoted_back(self) -> None:
repaired = agent_rules.builtin_repair(
"这串内容像系统编码,我这边无法确认具体意思。", "我说的是13点呀"
)
self.assertIn("我说的是13点呀", repaired)
self.assertNotIn("编码", repaired)
def test_untranscribed_voice_reply_is_left_alone(self) -> None:
"""真的没听清语音时说自己没听清是对的,这条路不能被误伤。"""
original = "这条语音我这边暂时没法准确听清,麻烦您把重点打成文字发我一下。"
self.assertEqual(agent_rules.builtin_repair(original, ""), original)
def test_normal_reply_is_untouched(self) -> None:
original = "空腹13确实偏高了,昨晚是不是吃夜宵了?"
self.assertEqual(agent_rules.builtin_repair(original, "13"), original)
class ForbidRules(TestCase):
def test_custom_fallback_wins(self) -> None:
rule = dict(FORBID_ENCODING, reply="您是说今早空腹13吧?这个值偏高了。")
agents = [_agent(rules=[rule])]
plan = {"mode": "single", "primary_id": "health-advisor"}
with UseAgents(agents, plan):
self.assertEqual(
agent_rules.enforce_reply("这串像系统编码。", "13"),
"您是说今早空腹13吧?这个值偏高了。",
)
def test_empty_fallback_falls_through_to_builtin(self) -> None:
agents = [_agent(rules=[FORBID_ENCODING])]
plan = {"mode": "single", "primary_id": "health-advisor"}
with UseAgents(agents, plan):
repaired = agent_rules.enforce_reply("这串像系统编码。", "13")
self.assertNotIn("系统编码", repaired)
self.assertIn("13", repaired)
def test_collaborating_agents_all_enforce(self) -> None:
"""协作时非主答角色的禁止措辞一样要拦得住,否则协作只是换了个提示词。"""
agents = [
_agent(),
_agent(
id="customer-manager",
name="客户经理",
role="客户经理",
keywords=["挂号"],
rules=[dict(FORBID_ENCODING, reply="您稍等,我看一下您说的这个。")],
priority=20,
),
]
plan = {
"mode": "collaborate",
"primary_id": "health-advisor",
"active_ids": ["health-advisor", "customer-manager"],
}
with UseAgents(agents, plan):
self.assertEqual(
agent_rules.enforce_reply("这串像系统编码。", "13"),
"您稍等,我看一下您说的这个。",
)
class GuideRules(TestCase):
def test_keywordless_guide_is_always_injected(self) -> None:
rule = {
"id": "g1", "label": "数字按数值理解", "type": "guide",
"keywords": [], "instruction": "纯数字按血糖值理解。",
"reply": "", "enabled": True,
}
with UseAgents([_agent(rules=[rule])], {"mode": "single", "primary_id": "health-advisor"}):
self.assertIn("纯数字按血糖值理解", agent_rules.prompt_sections("13"))
def test_keyworded_guide_only_on_hit(self) -> None:
"""带关键词的规则不能每轮都塞——提示词越长模型越不听话。"""
rule = {
"id": "g2", "label": "挂号口径", "type": "guide",
"keywords": ["挂号"], "instruction": "先问清病症再登记。",
"reply": "", "enabled": True,
}
plan = {"mode": "single", "primary_id": "health-advisor"}
with UseAgents([_agent(rules=[rule])], plan):
self.assertIn("先问清病症再登记", agent_rules.prompt_sections("想挂号"))
self.assertNotIn("先问清病症再登记", agent_rules.prompt_sections("血糖13"))
def test_disabled_rule_is_ignored(self) -> None:
rule = {
"id": "g3", "label": "停用的", "type": "guide", "keywords": [],
"instruction": "不该出现的指令。", "reply": "", "enabled": False,
}
with UseAgents([_agent(rules=[rule])], {"mode": "single", "primary_id": "health-advisor"}):
self.assertNotIn("不该出现的指令", agent_rules.prompt_sections("13"))
class CannedReplies(TestCase):
def setUp(self) -> None:
self.rule = {
"id": "link", "label": "面诊链接口径", "type": "reply",
"keywords": ["链接什么时候发"], "instruction": "",
"reply": "面诊链接会提前 1 小时发给您。", "enabled": True,
}
def test_hit_returns_the_exact_wording(self) -> None:
with UseAgents([_agent(rules=[self.rule])], {"mode": "single", "primary_id": "health-advisor"}):
self.assertEqual(
agent_rules.canned_reply("链接什么时候发呀"),
"面诊链接会提前 1 小时发给您。",
)
self.assertEqual(agent_rules.canned_reply("血糖13怎么办"), "")
def test_media_turn_never_uses_a_canned_reply(self) -> None:
"""媒体消息要走视觉链路,用固定话术顶掉会让归档丢掉媒体出处。"""
with UseAgents([_agent(rules=[self.rule])], {"mode": "single", "primary_id": "health-advisor"}):
self.assertEqual(
ai_chat._canned_agent_reply("客户 15:35\n[图片]\n链接什么时候发"),
"",
)
class Switching(TestCase):
def setUp(self) -> None:
self.agents = [
_agent(),
_agent(id="customer-manager", name="客户经理", role="客户经理", priority=20),
]
def test_single_mode_puts_one_agent_on_duty(self) -> None:
with UseAgents(self.agents, {"mode": "single", "primary_id": "customer-manager"}):
self.assertEqual([a["id"] for a in agent_rules.active_agents()], ["customer-manager"])
def test_single_mode_falls_back_when_the_choice_is_gone(self) -> None:
"""切到一个已经被删掉的角色,不能落得一个角色都不上岗。"""
with UseAgents(self.agents, {"mode": "single", "primary_id": "deleted-one"}):
self.assertEqual([a["id"] for a in agent_rules.active_agents()], ["health-advisor"])
def test_disabled_agents_never_go_on_duty(self) -> None:
agents = [_agent(enabled=False), self.agents[1]]
with UseAgents(agents, {"mode": "collaborate", "primary_id": "", "active_ids": ["health-advisor", "customer-manager"]}):
self.assertEqual([a["id"] for a in agent_rules.active_agents()], ["customer-manager"])
def test_primary_is_ordered_first_in_collaboration(self) -> None:
plan = {
"mode": "collaborate",
"primary_id": "customer-manager",
"active_ids": ["health-advisor", "customer-manager"],
}
with UseAgents(self.agents, plan):
self.assertEqual(
[a["id"] for a in agent_rules.active_agents()],
["customer-manager", "health-advisor"],
)
def test_topic_keywords_decide_who_answers(self) -> None:
agents = [
_agent(),
_agent(id="customer-manager", name="客户经理", role="客户经理",
keywords=["挂号", "物流"], priority=20),
]
plan = {
"mode": "collaborate",
"primary_id": "health-advisor",
"active_ids": ["health-advisor", "customer-manager"],
}
with UseAgents(agents, plan):
active = agent_rules.active_agents()
self.assertEqual(agent_rules.claim("我想挂号", active)["id"], "customer-manager")
self.assertEqual(agent_rules.claim("空腹血糖13", active)["id"], "health-advisor")
# 谁的话题都没命中时归主答,不能没人接
self.assertEqual(agent_rules.claim("在吗", active)["id"], "health-advisor")
class DirtyConfig(TestCase):
"""线上读到的可能是上一个版本留在磁盘上的配置。脏数据只能丢,不能炸。"""
def test_garbage_shapes_are_dropped(self) -> None:
agents = agent_rules.normalize_agents([
"不是对象",
{"name": "没有 id"},
{"id": "ok", "name": "正常", "rules": [
{"type": "forbid", "label": "没关键词"},
{"type": "reply", "label": "没话术", "keywords": ["a"]},
{"type": "不存在的类型", "label": "x"},
{"type": "guide", "label": "好的", "instruction": "照常说话。"},
]},
])
self.assertEqual([item["id"] for item in agents], ["ok"])
self.assertEqual([rule["label"] for rule in agents[0]["rules"]], ["好的"])
def test_plan_accepts_a_comma_string(self) -> None:
plan = agent_rules.normalize_plan(
{"mode": "collaborate", "active_ids": "a, b ,, a"}
)
self.assertEqual(plan["active_ids"], ["a", "b"])
def test_unknown_mode_degrades_to_single(self) -> None:
self.assertEqual(agent_rules.normalize_plan({"mode": "抢答"})["mode"], "single")
class ChatIntegration(TestCase):
"""接进 ai_chat 的那几处:提示词、出口校验、纯数字的会话指令。"""
def test_system_prompt_appends_agent_sections(self) -> None:
plan = {"mode": "single", "primary_id": "health-advisor"}
with UseAgents([_agent()], plan):
prompt = ai_chat._system_prompt("客户 15:35\n空腹血糖13")
self.assertIn("健康顾问", prompt)
self.assertIn("你负责血糖和用药相关的问题", prompt)
def test_dify_query_also_carries_the_agent_sections(self) -> None:
"""Dify 部署不发 system 消息,规则拼进 query——漏了这条角色就静默失效。"""
plan = {"mode": "single", "primary_id": "health-advisor"}
with UseAgents([_agent()], plan):
query = ai_chat._dify_query_from_chat("客户 15:35\n空腹血糖13")
self.assertIn("健康顾问", query)
self.assertIn("你负责血糖和用药相关的问题", query)
def test_finalize_reply_blocks_the_encoding_excuse(self) -> None:
with UseAgents([_agent(rules=[FORBID_ENCODING])], {"mode": "single", "primary_id": "health-advisor"}):
reply = ai_chat._finalize_reply("这串像系统编码,解析后可能是13。", "客户 15:35\n13")
self.assertNotIn("系统编码", reply)
def test_number_only_turn_gets_its_own_instruction(self) -> None:
instruction = ai_chat._conversation_mode_instruction("13")
self.assertIn("只发了一个数字", instruction)
self.assertIn("严禁", instruction)
def test_broken_agent_config_does_not_break_replies(self) -> None:
"""规则层抛异常时必须照常把这条回复发出去,不能整条链路停摆。"""
with mock.patch.object(
agent_rules, "enforce_reply", side_effect=RuntimeError("boom")
):
self.assertEqual(
ai_chat._enforce_agent_rules("血糖13偏高了。", "13"), "血糖13偏高了。"
)
if __name__ == "__main__":
main()