322 lines
14 KiB
Python
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()
|