落地转人工:三次补问或明确接管后出入口,真正转人工后冻结本票并允许新开询价。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
转人工:缺项连问、虚拟号、摘要骨架。
|
||||
"""
|
||||
|
||||
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()
|
||||
@@ -323,6 +323,35 @@ class LandDispatchTests(unittest.TestCase):
|
||||
self.assertNotIn("你好,我是询价机器人", last)
|
||||
self.assertIn("请说明要调哪一项费用", last)
|
||||
|
||||
def test_clarify_short_reply_misrouted_as_other_stays_on_inquiry(self) -> None:
|
||||
"""补问阶段回「111」即使被标闲聊,也不能发开场白。"""
|
||||
store = get_message_store()
|
||||
facts = {
|
||||
"起运港": "广州",
|
||||
"目的港": "深圳",
|
||||
"运输类型": "国内运输拼车",
|
||||
"线路类别": "国内长途/零担",
|
||||
"运输分类": "急件",
|
||||
}
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(sender_id="ld-111", message_id="m-111a", content="陆运广州到深圳"),
|
||||
injected_facts=facts,
|
||||
injected_mode="LAND",
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
name = dispatch_inbound(
|
||||
InboundMessage(sender_id="ld-111", message_id="m-111b", content="111"),
|
||||
RouteDecision(intent="ordinary_text_other"),
|
||||
)
|
||||
self.assertEqual(name, "ordinary_text_inquiry")
|
||||
sess = get_land_text_flow().session_of("ld-111")
|
||||
self.assertIsNotNone(sess)
|
||||
self.assertNotEqual(sess.phase, "")
|
||||
last = list(store._outbox.values())[-1].content
|
||||
self.assertNotIn("你好,我是询价机器人", last)
|
||||
self.assertNotIn("请直接描述客户需求", last)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1304,6 +1304,34 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
self.assertTrue(any("不能再上传附件" in t for t in texts))
|
||||
self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments)
|
||||
|
||||
def test_handoff_group_stays_silent(self) -> None:
|
||||
from agent.routing.group_intent import INTENT_FIELDS
|
||||
|
||||
wo, chat = self._open_group()
|
||||
self.ledger.transition(work_order_no=wo, to_status="转人工")
|
||||
sess = self.flow.session_of("sales1")
|
||||
sess.status = "转人工"
|
||||
sess.handoff_committed = True
|
||||
self.flow._save(sess)
|
||||
before = len(self.groups.sent)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="h1",
|
||||
content="客户名称张三",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent=INTENT_FIELDS,
|
||||
)
|
||||
self.assertEqual(phase, "handoff_ignore")
|
||||
after = [x.get("content") or "" for x in self.groups.sent[before:] if x.get("kind") == "text"]
|
||||
self.assertFalse(any("不能修改工单信息" in t for t in after))
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertNotIn("客户名称", ticket.collab_facts or {})
|
||||
|
||||
def test_dissolve_then_cannot_recreate(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
phase = handle_group_collab(
|
||||
|
||||
Reference in New Issue
Block a user