""" 转人工:缺项连问、虚拟号、摘要骨架。 """ 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()