落地转人工:三次补问或明确接管后出入口,真正转人工后冻结本票并允许新开询价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 16:42:13 +08:00
co-authored by Cursor
parent 26dd5d72a1
commit 7eedf28e8d
23 changed files with 1499 additions and 132 deletions
+349
View File
@@ -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()
+29
View File
@@ -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(