# -*- 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()