350 lines
14 KiB
Python
350 lines
14 KiB
Python
"""
|
||
转人工:缺项连问、虚拟号、摘要骨架。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import unittest
|
||
from datetime import datetime
|
||
from zoneinfo import ZoneInfo
|
||
|
||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||
if ROOT not in sys.path:
|
||
sys.path.insert(0, ROOT)
|
||
|
||
from agent.policy import handoff_human as hh
|
||
from agent.policy import inquiry_copy as copy
|
||
from agent.policy.air_text_flow import FlowSession
|
||
|
||
|
||
class _Sess:
|
||
def __init__(self) -> None:
|
||
self.clarify_missing_sig = ""
|
||
self.clarify_retry = 0
|
||
self.handoff_offer = ""
|
||
self.work_order_no = ""
|
||
self.virtual_work_order_no = ""
|
||
|
||
|
||
class HandoffCoreTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
hh.reset_virtual_seq_for_test()
|
||
|
||
def test_same_missing_set_offers_after_three_asks(self) -> None:
|
||
sess = _Sess()
|
||
keys = ["起运港", "目的港"]
|
||
self.assertEqual(hh.note_clarify_round(sess, keys), "ask")
|
||
self.assertEqual(sess.clarify_retry, 0)
|
||
self.assertEqual(hh.note_clarify_round(sess, keys), "ask")
|
||
self.assertEqual(sess.clarify_retry, 1)
|
||
self.assertEqual(hh.note_clarify_round(sess, keys), "ask")
|
||
self.assertEqual(sess.clarify_retry, 2)
|
||
self.assertEqual(hh.note_clarify_round(sess, keys), "handoff")
|
||
self.assertEqual(sess.clarify_retry, 3)
|
||
|
||
def test_changed_missing_resets_and_voids_clarify_offer(self) -> None:
|
||
sess = _Sess()
|
||
hh.note_clarify_round(sess, ["起运港"])
|
||
hh.note_clarify_round(sess, ["起运港"])
|
||
sess.handoff_offer = "clarify"
|
||
self.assertEqual(hh.note_clarify_round(sess, ["目的港"]), "ask")
|
||
self.assertEqual(sess.clarify_retry, 0)
|
||
self.assertEqual(sess.handoff_offer, "")
|
||
|
||
def test_virtual_no_prefix_not_wo(self) -> None:
|
||
no = hh.next_virtual_no(now=datetime(2026, 9, 20, tzinfo=ZoneInfo("Asia/Shanghai")))
|
||
self.assertTrue(no.startswith("VT20260920"))
|
||
self.assertEqual(len(no), 14)
|
||
self.assertFalse(no.startswith("WO"))
|
||
self.assertTrue(hh.is_virtual_no(no))
|
||
self.assertFalse(hh.is_virtual_no("WO202609200001"))
|
||
|
||
def test_handoff_brief_dashes_collab_and_glues_ticket(self) -> None:
|
||
text = copy.handoff_group_brief(
|
||
work_order_no="WO202609190018",
|
||
facts={
|
||
"运输类型": "跨境集拼",
|
||
"线路类别": "中港/中亚/中欧",
|
||
"运输分类": "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
|
||
"起运地": "深圳市",
|
||
"目的地": "南京市",
|
||
"毛重": "1KG",
|
||
"报价日期": "2026-09-19",
|
||
},
|
||
business_line="LAND",
|
||
product_names=["林绵勋", "罗小华", "吴季林"],
|
||
)
|
||
self.assertIn("@林绵勋 @罗小华 @吴季林", text)
|
||
self.assertIn("WO202609190018转人工", text)
|
||
self.assertNotIn("工单号:", text.split("转人工")[0][-6:])
|
||
self.assertIn("货物品名:-", text)
|
||
self.assertIn("客户名称:-", text)
|
||
self.assertNotIn("TMS报价", text)
|
||
|
||
def test_already_collab_copy(self) -> None:
|
||
self.assertEqual(copy.HANDOFF_ALREADY_COLLAB, "该工单已拉产品进群协同。")
|
||
|
||
def test_air_hint_contains_virtual(self) -> None:
|
||
text = copy.handoff_air_hint(work_order_no="VT202609200001")
|
||
self.assertEqual(
|
||
text,
|
||
"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 VT202609200001。",
|
||
)
|
||
self.assertNotIn("拉产品进群", text)
|
||
|
||
def test_clarify_progress_and_stale_wo(self) -> None:
|
||
self.assertTrue(copy.clarify_progress_line(1).startswith("参数提取不全(1/3)"))
|
||
self.assertEqual(copy.with_clarify_progress("请确认并补充以下信息:", 0), "请确认并补充以下信息:")
|
||
wrapped = copy.with_clarify_progress("请确认并补充以下信息:", 2)
|
||
self.assertTrue(wrapped.startswith("参数提取不全(2/3)"))
|
||
self.assertIn("请确认并补充以下信息", wrapped)
|
||
sess = _Sess()
|
||
sess.work_order_no = "WO202609180018"
|
||
sess.phase = "clarify"
|
||
no = hh.ticket_no_for_handoff_offer(sess)
|
||
self.assertTrue(str(no).startswith("VT"))
|
||
self.assertEqual(sess.work_order_no, "")
|
||
kept = _Sess()
|
||
kept.work_order_no = "WO202609180018"
|
||
kept.phase = "wait_collab"
|
||
self.assertEqual(hh.ticket_no_for_handoff_offer(kept), "WO202609180018")
|
||
|
||
|
||
class HandoffFlowOfferTests(unittest.TestCase):
|
||
"""海运:首次补问不计数,之后连问三次仍不齐出卡;点卡前仍可继续补字段。"""
|
||
|
||
def setUp(self) -> None:
|
||
hh.reset_virtual_seq_for_test()
|
||
from agent.policy.sea_text_flow import reset_sea_text_flow_for_test
|
||
|
||
self.flow = reset_sea_text_flow_for_test()
|
||
self.replies: list[str] = []
|
||
|
||
def reply(text: str, extra=None):
|
||
self.replies.append(text)
|
||
return "out"
|
||
|
||
self.reply = reply
|
||
|
||
def _partial(self, text: str) -> str:
|
||
return self.flow.on_text(sender_id="s1", text=text, reply=self.reply)
|
||
|
||
def test_three_same_clarify_then_card(self) -> None:
|
||
self._partial("海运询价")
|
||
self._partial("还是那些")
|
||
self._partial("不知道")
|
||
phase = self._partial("还没有")
|
||
self.assertEqual(phase, "handoff_offer")
|
||
joined = "\n".join(self.replies)
|
||
self.assertIn("参数提取不全(3/3)", joined)
|
||
self.assertIn(copy.BTN_PULL_COLLAB, joined)
|
||
idx3 = next(i for i, t in enumerate(self.replies) if "参数提取不全(3/3)" in t)
|
||
self.assertLess(idx3 + 1, len(self.replies))
|
||
self.assertIn(copy.BTN_PULL_COLLAB, self.replies[idx3 + 1])
|
||
sess = self.flow.session_of("s1")
|
||
self.assertEqual(sess.handoff_offer, "clarify")
|
||
self.assertIn(copy.BTN_PULL_COLLAB, sess.allowed)
|
||
|
||
def test_click_pull_sends_handoff_brief_without_tms(self) -> None:
|
||
class FakeGroup:
|
||
def __init__(self) -> None:
|
||
self.created: list[dict] = []
|
||
self.sent: list[dict] = []
|
||
|
||
def create_group(self, *, name: str, userids: list[str]) -> dict:
|
||
self.created.append({"name": name, "userids": list(userids)})
|
||
return {"ok": True, "chat_id": "wr_h1", "error": ""}
|
||
|
||
def send_group(self, *, chat_id: str, content: str, mention_userids=None) -> dict:
|
||
self.sent.append({"chat_id": chat_id, "content": content})
|
||
return {"ok": True}
|
||
|
||
def send_group_file(self, **kwargs) -> dict:
|
||
return {"ok": True}
|
||
|
||
groups = FakeGroup()
|
||
self.flow._group_client = groups
|
||
self.flow.ledger.set_sea_staff(
|
||
[
|
||
{
|
||
"name": "林绵勋",
|
||
"wecomId": "prod1",
|
||
"roleCode": "sea",
|
||
"routes": "海运·全部线路",
|
||
"status": "active",
|
||
}
|
||
]
|
||
)
|
||
for text in ("海运询价", "还是那些", "不知道", "还没有"):
|
||
self._partial(text)
|
||
phase = self.flow.on_button(
|
||
sender_id="s1",
|
||
action=copy.BTN_PULL_COLLAB,
|
||
reply=self.reply,
|
||
)
|
||
self.assertEqual(phase, "handoff_done")
|
||
self.assertTrue(groups.created)
|
||
self.assertTrue(groups.created[0]["name"].startswith("VT"))
|
||
brief = groups.sent[0]["content"]
|
||
self.assertIn("转人工", brief)
|
||
self.assertNotIn("TMS报价", brief)
|
||
sess = self.flow.session_of("s1")
|
||
self.assertTrue(sess.handoff_committed)
|
||
self.assertTrue(sess.work_order_no == "" or sess.status == "转人工")
|
||
|
||
def test_new_inquiry_after_commit_starts_fresh(self) -> None:
|
||
class FakeGroup:
|
||
def create_group(self, *, name: str, userids: list[str]) -> dict:
|
||
return {"ok": True, "chat_id": "wr_h1", "error": ""}
|
||
|
||
def send_group(self, *, chat_id: str, content: str, mention_userids=None) -> dict:
|
||
return {"ok": True}
|
||
|
||
def send_group_file(self, **kwargs) -> dict:
|
||
return {"ok": True}
|
||
|
||
self.flow._group_client = FakeGroup()
|
||
self.flow.ledger.set_sea_staff(
|
||
[
|
||
{
|
||
"name": "林绵勋",
|
||
"wecomId": "prod1",
|
||
"roleCode": "sea",
|
||
"routes": "海运·全部线路",
|
||
"status": "active",
|
||
}
|
||
]
|
||
)
|
||
for text in ("海运询价", "还是那些", "不知道", "还没有"):
|
||
self._partial(text)
|
||
self.flow.on_button(
|
||
sender_id="s1",
|
||
action=copy.BTN_PULL_COLLAB,
|
||
reply=self.reply,
|
||
)
|
||
self.replies.clear()
|
||
phase = self._partial("海运询价")
|
||
self.assertNotEqual(phase, "handoff_done")
|
||
joined = "\n".join(self.replies)
|
||
self.assertNotIn("已转人工,本单不再自动跟进", joined)
|
||
self.assertIn("请确认并补充以下信息", joined)
|
||
sess = self.flow.session_of("s1")
|
||
self.assertFalse(sess.handoff_committed)
|
||
self.assertEqual(sess.phase, "clarify")
|
||
|
||
def test_already_has_group_only_prompts(self) -> None:
|
||
self.flow._save(
|
||
FlowSession(
|
||
sender_id="s1",
|
||
thread_id="s1",
|
||
phase="collab_group",
|
||
business_line="SEA",
|
||
work_order_no="WO202609200099",
|
||
collab_chat_id="wr_exist",
|
||
status="已报价",
|
||
)
|
||
)
|
||
phase = self.flow.offer_handoff(sender_id="s1", text="转人工", reply=self.reply)
|
||
self.assertEqual(phase, "handoff_already")
|
||
self.assertEqual(self.replies[-1], copy.HANDOFF_ALREADY_COLLAB)
|
||
self.assertFalse(self.flow.session_of("s1").handoff_committed)
|
||
|
||
def test_route_contract_accepts_handoff_human(self) -> None:
|
||
from agent.routing.deepseek_a import DeepSeekARouter
|
||
from agent.routing.ordered_actions import ACTION_INTENTS, parse_ordered_actions_arguments
|
||
|
||
self.assertEqual(ACTION_INTENTS.get("HANDOFF_HUMAN"), "handoff_human")
|
||
parsed = parse_ordered_actions_arguments(
|
||
{
|
||
"decision_id": "d-h",
|
||
"intent": "handoff_human",
|
||
"ordered_action": {
|
||
"action_type": "HANDOFF_HUMAN",
|
||
"order": 1,
|
||
"target": {"work_order_no": ""},
|
||
"evidence": {"source_ref": "inbound:u1", "content_digest": "a" * 64},
|
||
},
|
||
}
|
||
)
|
||
self.assertEqual(parsed.to_route_decision().intent, "handoff_human")
|
||
router = DeepSeekARouter.from_settings()
|
||
sys_msg, _user = router.build_messages(text="帮我转人工", sender_id="u1")
|
||
self.assertIn("handoff_human", sys_msg["content"])
|
||
self.assertIn("口令不写死", sys_msg["content"])
|
||
self.assertIn("抱怨慢", sys_msg["content"])
|
||
|
||
def test_first_clarify_does_not_count(self) -> None:
|
||
self._partial("海运询价")
|
||
self.assertTrue(self.replies)
|
||
self.assertNotIn("参数提取不全(1/3)", self.replies[0])
|
||
self.assertIn("请确认并补充以下信息", self.replies[0])
|
||
self._partial("还是那些")
|
||
self.assertTrue(self.replies[-1].startswith("参数提取不全(1/3)"))
|
||
|
||
def test_air_handoff_before_create_uses_virtual_not_old_wo(self) -> None:
|
||
from agent.policy.air_text_flow import reset_air_text_flow_for_test
|
||
|
||
flow = reset_air_text_flow_for_test()
|
||
replies: list[str] = []
|
||
sess = FlowSession(
|
||
sender_id="air1",
|
||
thread_id="air1",
|
||
phase="clarify",
|
||
business_line="AIR",
|
||
work_order_no="WO202609180018",
|
||
facts={"起运港": "ZUH", "目的港": "CRK", "品名": "衣服"},
|
||
)
|
||
flow._save(sess)
|
||
phase = flow._offer_handoff_now(
|
||
sender_id="air1",
|
||
reason="clarify",
|
||
reply=lambda t, extra=None: replies.append(t) or "out",
|
||
sess=sess,
|
||
)
|
||
self.assertEqual(phase, "handoff_offer")
|
||
self.assertTrue(replies)
|
||
self.assertIn("VT", replies[0])
|
||
self.assertNotIn("WO202609180018", replies[0])
|
||
self.assertTrue((sess.virtual_work_order_no or "").startswith("VT"))
|
||
self.assertEqual(sess.work_order_no, "")
|
||
|
||
def test_third_clarify_sends_card_even_if_routed_as_adjust(self) -> None:
|
||
from agent.channel.queue import get_message_store
|
||
from agent.channel.wecom.models import InboundMessage
|
||
from agent.handlers.text_inquiry import handle_text_inquiry
|
||
from agent.routing.decision import RouteDecision
|
||
from agent.routing.dispatch import dispatch_inbound
|
||
|
||
store = get_message_store()
|
||
handle_text_inquiry(
|
||
InboundMessage(sender_id="s1", message_id="h1", content="海运询价"),
|
||
store=store,
|
||
flow=self.flow,
|
||
)
|
||
handle_text_inquiry(
|
||
InboundMessage(sender_id="s1", message_id="h2", content="还是那些"),
|
||
store=store,
|
||
flow=self.flow,
|
||
)
|
||
handle_text_inquiry(
|
||
InboundMessage(sender_id="s1", message_id="h3", content="不知道"),
|
||
store=store,
|
||
flow=self.flow,
|
||
)
|
||
name = dispatch_inbound(
|
||
InboundMessage(sender_id="s1", message_id="h4", content="1"),
|
||
RouteDecision(intent="quote_adjust"),
|
||
)
|
||
self.assertEqual(name, "quote_adjust")
|
||
sess = self.flow.session_of("s1")
|
||
self.assertEqual(sess.phase, "handoff_offer")
|
||
bodies = "\n".join(i.content for i in store._outbox.values())
|
||
self.assertIn("参数提取不全(3/3)", bodies)
|
||
self.assertIn(copy.BTN_PULL_COLLAB, bodies)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|