377 lines
13 KiB
Python
377 lines
13 KiB
Python
"""续办历史工单:模型意图合同 + 回到当时节点。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import unittest
|
||
|
||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||
if ROOT not in sys.path:
|
||
sys.path.insert(0, ROOT)
|
||
|
||
os.environ.setdefault("YTD_ENV", "test")
|
||
os.environ.setdefault("REDIS_BACKEND", "memory")
|
||
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
|
||
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
|
||
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
|
||
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
|
||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||
|
||
from agent.channel.wecom.models import InboundMessage
|
||
from agent.handlers.continue_thread import handle_continue_thread
|
||
from agent.ledger.memory_ledger import MemoryLedger
|
||
from agent.policy import inquiry_copy as copy
|
||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||
from agent.routing import IntentRouter
|
||
from agent.routing.deepseek_a import DeepSeekARouter
|
||
from agent.routing.dispatch import dispatch_inbound
|
||
from agent.routing.ordered_actions import parse_ordered_actions_arguments
|
||
|
||
|
||
COMPLETE_AIR = {
|
||
"起运港": "上海",
|
||
"目的港": "洛杉矶",
|
||
"品名": "普货",
|
||
"件数": "3",
|
||
"毛重": "180",
|
||
"体积": "2.5",
|
||
"包装方式": "纸箱",
|
||
}
|
||
|
||
|
||
def _until_quoted(flow, **kwargs) -> str:
|
||
"""必填齐先出核对卡,单测自动回确定再建单查价。"""
|
||
phase = flow.on_text(**kwargs)
|
||
if phase == "wait_confirm":
|
||
phase = flow.on_text(
|
||
sender_id=kwargs["sender_id"],
|
||
text="确定",
|
||
reply=kwargs["reply"],
|
||
)
|
||
return phase
|
||
|
||
|
||
class ContinueTicketTests(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
from agent.redis_coord.runtime import shutdown_redis_runtime
|
||
|
||
shutdown_redis_runtime()
|
||
|
||
def test_model_contract_accepts_continue_intent(self) -> None:
|
||
digest = "a" * 64
|
||
parsed = parse_ordered_actions_arguments(
|
||
{
|
||
"decision_id": "d1",
|
||
"intent": "continue_thread",
|
||
"ordered_action": {
|
||
"action_type": "CONTINUE_HISTORICAL_TICKET",
|
||
"order": 1,
|
||
"target": {"work_order_no": "WO202609140034"},
|
||
"evidence": {
|
||
"source_ref": "inbound:u1",
|
||
"content_digest": digest,
|
||
},
|
||
},
|
||
}
|
||
)
|
||
decision = parsed.to_route_decision()
|
||
self.assertEqual(decision.intent, "continue_thread")
|
||
self.assertEqual(decision.thread_id, "WO202609140034")
|
||
|
||
def test_model_junk_digest_still_continues(self) -> None:
|
||
"""测服实测:模型会乱填 content_digest,不能因此整句判失败。"""
|
||
router = DeepSeekARouter.from_settings()
|
||
decision = router.route_from_model_args(
|
||
{
|
||
"decision_id": "x",
|
||
"intent": "continue_thread",
|
||
"ordered_action": {
|
||
"action_type": "CONTINUE_HISTORICAL_TICKET",
|
||
"order": "1",
|
||
"target": {"work_order_no": "WO202609140037"},
|
||
"evidence": {
|
||
"source_ref": "user",
|
||
"content_digest": "not-a-hash",
|
||
},
|
||
},
|
||
},
|
||
text="我要继续聊聊WO202609140037",
|
||
sender_id="WuJiLin",
|
||
)
|
||
self.assertEqual(decision.intent, "continue_thread")
|
||
self.assertEqual(decision.thread_id, "WO202609140037")
|
||
|
||
def test_stub_natural_language_not_fixed_command(self) -> None:
|
||
router = DeepSeekARouter.from_settings()
|
||
hit = router.route_stub_fixture(
|
||
text="我要继续咨询工单WO202609140034",
|
||
sender_id="WuJiLin",
|
||
)
|
||
self.assertEqual(hit.intent, "continue_thread")
|
||
self.assertEqual(hit.thread_id, "WO202609140034")
|
||
again = router.route_stub_fixture(
|
||
text="把刚才那单 WO202609140034 再出一下方案",
|
||
sender_id="WuJiLin",
|
||
)
|
||
self.assertEqual(again.intent, "continue_thread")
|
||
fresh = router.route_stub_fixture(
|
||
text="上海到洛杉矶空运,3件 180kg 茶叶",
|
||
sender_id="WuJiLin",
|
||
)
|
||
self.assertEqual(fresh.intent, "ordinary_text_inquiry")
|
||
self.assertEqual(fresh.thread_id, "")
|
||
# 无网夹具认不出「只贴工单号」;正式环境必须由模型认。
|
||
bare = router.route_stub_fixture(
|
||
text="WO202609140034",
|
||
sender_id="WuJiLin",
|
||
)
|
||
self.assertEqual(bare.intent, "ordinary_text_inquiry")
|
||
|
||
def test_card_click_skips_model(self) -> None:
|
||
router = IntentRouter(allow_network=False)
|
||
hit = router.route_inbound(
|
||
text="adopt_excel:WO202609140034",
|
||
sender_id="WuJiLin",
|
||
event="template_card_event",
|
||
)
|
||
self.assertEqual(hit.intent, "card_action")
|
||
|
||
def test_resume_scheme_from_session(self) -> None:
|
||
flow = AirTextInquiryFlow(MemoryLedger())
|
||
replies: list[str] = []
|
||
extras: list[dict] = []
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
replies.append(text)
|
||
extras.append(dict(extra) if extra else {})
|
||
|
||
_until_quoted(
|
||
flow,
|
||
sender_id="u1",
|
||
text="空运",
|
||
reply=reply,
|
||
injected_facts=COMPLETE_AIR,
|
||
injected_mode="AIR",
|
||
)
|
||
wo = flow.session_of("u1").work_order_no
|
||
replies.clear()
|
||
extras.clear()
|
||
flow.on_button(sender_id="u1", action=copy.BTN_SKIP_COLLAB, reply=reply)
|
||
self.assertEqual(flow.session_of("u1").phase, "wait_adopt")
|
||
replies.clear()
|
||
extras.clear()
|
||
phase = flow.resume_historical(
|
||
sender_id="u1", work_order_no=wo, reply=reply
|
||
)
|
||
self.assertEqual(phase, "wait_adopt")
|
||
self.assertTrue(any(f"{wo}报价方案" in x for x in replies))
|
||
self.assertTrue(
|
||
any((x.get("template_card") or {}).get("main_title", {}).get("title") == f"{wo}报价方案" for x in extras)
|
||
)
|
||
|
||
def test_resume_from_ledger_quoted(self) -> None:
|
||
ledger = MemoryLedger()
|
||
flow = AirTextInquiryFlow(ledger)
|
||
created = ledger.create_ticket(
|
||
sender_id="u2", business_line="AIR", facts=COMPLETE_AIR
|
||
)
|
||
wo = created["work_order_no"]
|
||
ledger.upsert_quote(
|
||
work_order_no=wo,
|
||
quote={
|
||
"total": "USD 1,280",
|
||
"eta": "3-5 天",
|
||
"valid_until": "2026-09-20",
|
||
"source_label": "TMS 标准报价",
|
||
"fee_lines": [{"name": "空运费", "amount": "USD 1,200"}],
|
||
},
|
||
to_status="已报价",
|
||
)
|
||
replies: list[str] = []
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
replies.append(text)
|
||
_ = extra
|
||
|
||
phase = flow.resume_historical(
|
||
sender_id="u2", work_order_no=wo, reply=reply
|
||
)
|
||
self.assertEqual(phase, "wait_adopt")
|
||
self.assertTrue(any("报价方案" in x for x in replies))
|
||
|
||
def test_resume_after_quote_file_is_deal(self) -> None:
|
||
ledger = MemoryLedger()
|
||
created = ledger.create_ticket(
|
||
sender_id="u6", business_line="AIR", facts=COMPLETE_AIR
|
||
)
|
||
wo = created["work_order_no"]
|
||
ledger.upsert_quote(
|
||
work_order_no=wo,
|
||
quote={"total": "USD 1", "eta": "-", "valid_until": "-"},
|
||
to_status="已报价",
|
||
)
|
||
ledger.render_quote(
|
||
work_order_no=wo,
|
||
business_line="AIR",
|
||
facts=COMPLETE_AIR,
|
||
quote={"total": "USD 1"},
|
||
)
|
||
flow = AirTextInquiryFlow(ledger)
|
||
replies: list[str] = []
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
replies.append(text)
|
||
_ = extra
|
||
|
||
phase = flow.resume_historical(
|
||
sender_id="u6", work_order_no=wo, reply=reply
|
||
)
|
||
self.assertEqual(phase, "wait_deal")
|
||
self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies))
|
||
|
||
def test_resume_missing_wo_asks(self) -> None:
|
||
flow = AirTextInquiryFlow(MemoryLedger())
|
||
replies: list[str] = []
|
||
phase = flow.resume_historical(
|
||
sender_id="u3", work_order_no="", reply=replies.append
|
||
)
|
||
self.assertEqual(phase, "need_work_order")
|
||
self.assertTrue(any("工单号" in x for x in replies))
|
||
|
||
def test_dispatch_passes_work_order(self) -> None:
|
||
ledger = MemoryLedger()
|
||
flow = AirTextInquiryFlow(ledger)
|
||
created = ledger.create_ticket(
|
||
sender_id="u4", business_line="AIR", facts=COMPLETE_AIR
|
||
)
|
||
wo = created["work_order_no"]
|
||
ledger.upsert_quote(
|
||
work_order_no=wo,
|
||
quote={"total": "USD 1", "eta": "-", "valid_until": "-"},
|
||
to_status="已报价",
|
||
)
|
||
from agent.channel.queue import MemoryMessageStore
|
||
from agent.routing.decision import RouteDecision
|
||
|
||
store = MemoryMessageStore()
|
||
msg = InboundMessage(
|
||
sender_id="u4",
|
||
message_id="m-cont",
|
||
content="帮我把那单再看一眼",
|
||
)
|
||
phase = handle_continue_thread(
|
||
msg, thread_id=wo, flow=flow, store=store
|
||
)
|
||
self.assertEqual(phase, "wait_adopt")
|
||
decision = RouteDecision(intent="continue_thread", thread_id=wo)
|
||
# dispatch 走全局 flow,这里只断言意图表仍指向 continue
|
||
self.assertEqual(decision.intent, "continue_thread")
|
||
_ = dispatch_inbound
|
||
|
||
def test_skip_collab_after_restart_from_ledger(self) -> None:
|
||
"""
|
||
测服 WO202609230031:进程重启后主账把空运已报价收成 wait_adopt,
|
||
点「不需要协同,直接生成报价单」不能再静默丢掉。
|
||
"""
|
||
ledger = MemoryLedger()
|
||
live = AirTextInquiryFlow(ledger)
|
||
sink: list[str] = []
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
sink.append(text)
|
||
_ = extra
|
||
|
||
created = ledger.create_ticket(
|
||
sender_id="u-skip-restart", business_line="AIR", facts=COMPLETE_AIR
|
||
)
|
||
wo = created["work_order_no"]
|
||
ledger.upsert_quote(
|
||
work_order_no=wo,
|
||
quote={
|
||
"total": "USD 1,280",
|
||
"eta": "3-5 天",
|
||
"valid_until": "2026-09-20",
|
||
"source_label": "TMS 标准报价",
|
||
},
|
||
to_status="已报价",
|
||
)
|
||
view = ledger.get_for_agent(work_order_no=wo, sender_id="u-skip-restart")
|
||
self.assertEqual(view.get("wait_phase"), "wait_adopt")
|
||
|
||
restarted = AirTextInquiryFlow(ledger)
|
||
phase = restarted.on_button(
|
||
sender_id="u-skip-restart",
|
||
action=copy.BTN_SKIP_COLLAB,
|
||
reply=reply,
|
||
card_meta={
|
||
"event_key": f"skip_collab:{wo}",
|
||
"task_id": f"price-{wo}-deadbeef",
|
||
"response_code": "rc-skip-restart",
|
||
},
|
||
)
|
||
self.assertEqual(phase, "wait_adopt")
|
||
joined = "\n".join(sink)
|
||
self.assertIn("已跳过内部协同", joined)
|
||
self.assertIn(f"{wo}报价方案", joined)
|
||
self.assertEqual(restarted.session_of("u-skip-restart").phase, "wait_adopt")
|
||
self.assertNotIn(copy.BTN_SKIP_COLLAB, restarted.session_of("u-skip-restart").allowed)
|
||
|
||
def test_adopt_excel_after_restart_stays_on_card_ticket(self) -> None:
|
||
"""点 0040 方案卡不能落到刚续办的 0037:进程无书签时按卡上工单号从主账收回。"""
|
||
ledger = MemoryLedger()
|
||
live = AirTextInquiryFlow(ledger)
|
||
sink: list[str] = []
|
||
|
||
def reply(text: str, extra=None) -> None:
|
||
sink.append(text)
|
||
_ = extra
|
||
|
||
_until_quoted(
|
||
live,
|
||
sender_id="u5",
|
||
text="空运",
|
||
reply=reply,
|
||
injected_facts=COMPLETE_AIR,
|
||
injected_mode="AIR",
|
||
)
|
||
wo_old = live.session_of("u5").work_order_no
|
||
live.on_button(sender_id="u5", action=copy.BTN_SKIP_COLLAB, reply=reply)
|
||
other = dict(COMPLETE_AIR)
|
||
other["品名"] = "服装"
|
||
_until_quoted(
|
||
live,
|
||
sender_id="u5",
|
||
text="空运",
|
||
reply=reply,
|
||
injected_facts=other,
|
||
injected_mode="AIR",
|
||
)
|
||
wo_new = live.session_of("u5").work_order_no
|
||
self.assertNotEqual(wo_old, wo_new)
|
||
|
||
restarted = AirTextInquiryFlow(ledger)
|
||
restarted.resume_historical(
|
||
sender_id="u5", work_order_no=wo_new, reply=reply
|
||
)
|
||
sink.clear()
|
||
phase = restarted.on_button(
|
||
sender_id="u5",
|
||
action=copy.BTN_ADOPT_EXCEL,
|
||
reply=reply,
|
||
card_meta={
|
||
"event_key": f"adopt_excel:{wo_old}",
|
||
"task_id": f"scheme-{wo_old}-deadbeef",
|
||
"response_code": "rc-old-scheme",
|
||
},
|
||
)
|
||
self.assertEqual(phase, "wait_deal")
|
||
joined = "\n".join(sink)
|
||
self.assertIn(wo_old, joined)
|
||
self.assertNotIn(wo_new, joined)
|
||
self.assertIn("Excel", joined)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|