feat: upgrade客服多店铺分流、批量报价与稳定性防护

This commit is contained in:
2026-02-28 18:52:31 +08:00
parent c39840fe15
commit 46143be86c
16 changed files with 1329 additions and 37 deletions

View File

@@ -0,0 +1,66 @@
import unittest
from unittest.mock import AsyncMock, patch
from core.pydantic_ai_agent import CustomerMessage, CustomerServiceAgent
class BatchQuoteReplyFormatTest(unittest.IsolatedAsyncioTestCase):
async def test_batch_reply_contains_per_image_and_options(self):
agent = CustomerServiceAgent()
cid = "__batch_quote_case__"
st = agent._get_conversation_state(cid)
st.pending_image_urls = ["https://img.alicdn.com/a.jpg", "https://img.alicdn.com/b.jpg"]
st.pending_requirements = ["去背景", "加急"]
msg = CustomerMessage(
msg_id="m-batch-1",
acc_id="test_shop",
msg="发完了,统一报价",
from_id=cid,
from_name="t",
cy_id=cid,
acc_type="AliWorkbench",
msg_type=0,
cy_name="t",
goods_name="专业找图",
goods_order="",
)
fake_r1 = {
"complexity": "normal",
"reason": "常规处理",
"price_min": 15,
"price_max": 25,
"price_suggest": 20,
"feasibility": "yes",
"risk": "low",
"aspect_ratio": "1:1",
"perspective": "no",
}
fake_r2 = {
"complexity": "complex",
"reason": "细节较多",
"price_min": 20,
"price_max": 30,
"price_suggest": 25,
"feasibility": "yes",
"risk": "low",
"aspect_ratio": "1:1",
"perspective": "no",
}
with patch("image.image_analyzer.image_analyzer.analyze", new=AsyncMock(side_effect=[fake_r1, fake_r2])):
with patch("core.workflow.workflow.image_analysis_result", new=AsyncMock(return_value=None)):
res = await agent._quote_pending_images(st, msg)
self.assertFalse(res.get("need_transfer", False))
reply = res.get("reply", "")
self.assertIn("图1", reply)
self.assertIn("图2", reply)
self.assertIn("可选", reply)
self.assertIn("打包", reply)
self.assertIn("", reply)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,40 @@
import os
import unittest
from websockets.protocol import State
from core.websocket_client import QingjianAPIClient
class _DummyWS:
def __init__(self):
self.state = State.OPEN
self.sent = []
async def send(self, msg_json: str):
self.sent.append(msg_json)
class OutboundCooldownTest(unittest.IsolatedAsyncioTestCase):
def setUp(self):
os.environ["OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS"] = "5"
async def test_skip_second_reply_within_cooldown(self):
c = QingjianAPIClient(enable_agent=False)
c.websocket = _DummyWS()
msg = {
"acc_id": "shop_a",
"from_id": "u001",
"from_name": "u001",
"acc_type": "AliWorkbench",
}
await c.send_reply(msg, "第一条")
await c.send_reply(msg, "第二条")
self.assertEqual(len(c.websocket.sent), 1)
def tearDown(self):
os.environ.pop("OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS", None)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,34 @@
import os
import unittest
from core.websocket_client import QingjianAPIClient
class OversizeGuardTest(unittest.TestCase):
def setUp(self):
os.environ["MAX_SERVICE_SIZE_LONGEST_METERS"] = "10"
os.environ["MAX_SERVICE_SIZE_AREA_SQM"] = "20"
def test_extract_size_pairs(self):
c = QingjianAPIClient(enable_agent=False)
pairs = c._extract_size_pairs_m("15*6.4米 高度")
self.assertTrue(len(pairs) >= 1)
self.assertEqual(pairs[0], (15.0, 6.4))
def test_oversize_hits(self):
c = QingjianAPIClient(enable_agent=False)
r = c._oversize_reply_if_needed("15*6.4米")
self.assertIn("做不了", r)
def test_normal_size_not_hit(self):
c = QingjianAPIClient(enable_agent=False)
r = c._oversize_reply_if_needed("2.4*1.2米")
self.assertEqual(r, "")
def tearDown(self):
os.environ.pop("MAX_SERVICE_SIZE_LONGEST_METERS", None)
os.environ.pop("MAX_SERVICE_SIZE_AREA_SQM", None)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,76 @@
import os
import unittest
from unittest.mock import AsyncMock
from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage
from db.customer_db import db
class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.customer_id = "__regression_test_customer__"
db.clear_pending_quote_state(self.customer_id)
os.environ["FEATURE_BATCH_QUOTE_ENABLED"] = "true"
os.environ["FEATURE_BATCH_QUOTE_PERCENT"] = "100"
os.environ["FEATURE_BATCH_QUOTE_SHOPS"] = ""
async def test_collect_images_then_ack(self):
agent = CustomerServiceAgent()
msg = CustomerMessage(
msg_id="m1",
acc_id="test_shop",
msg="https://img.alicdn.com/a.jpg#*#https://img.alicdn.com/b.jpg",
from_id=self.customer_id,
from_name="t",
cy_id=self.customer_id,
acc_type="AliWorkbench",
msg_type=0,
cy_name="t",
goods_name="专业找图",
goods_order="",
)
resp = await agent.process_message(msg)
self.assertTrue(resp.should_reply)
self.assertIn("", resp.reply)
st = agent._get_conversation_state(self.customer_id)
self.assertEqual(len(st.pending_image_urls), 2)
async def test_finish_signal_triggers_batch_quote(self):
agent = CustomerServiceAgent()
st = agent._get_conversation_state(self.customer_id)
st.pending_image_urls = ["https://img.alicdn.com/a.jpg"]
st.pending_requirements = ["去背景"]
agent._sync_pending_quote_state(self.customer_id, st)
agent._quote_pending_images = AsyncMock(return_value={"reply": "打包15元确认我就安排", "need_transfer": False})
msg = CustomerMessage(
msg_id="m2",
acc_id="test_shop",
msg="发完了,报价吧",
from_id=self.customer_id,
from_name="t",
cy_id=self.customer_id,
acc_type="AliWorkbench",
msg_type=0,
cy_name="t",
goods_name="专业找图",
goods_order="",
)
resp = await agent.process_message(msg)
self.assertTrue(resp.should_reply)
self.assertIn("15", resp.reply)
agent._quote_pending_images.assert_awaited()
async def test_pending_state_restore(self):
db.update_pending_quote_state(self.customer_id, ["u1", "u2"], ["r1"])
agent = CustomerServiceAgent()
st = agent._get_conversation_state(self.customer_id)
self.assertEqual(st.pending_image_urls, ["u1", "u2"])
self.assertEqual(st.pending_requirements, ["r1"])
def tearDown(self):
db.clear_pending_quote_state(self.customer_id)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,70 @@
import os
import unittest
from unittest.mock import AsyncMock
from core.websocket_client import QingjianAPIClient
class SystemInquiryRulesTest(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.rules = {
"enabled": True,
"default_action": "silent",
"default_reply": "已收到",
"sender_keywords": ["系统客服", "官方客服"],
"message_keywords": ["系统询单", "代客咨询"],
"shops": {
"shop_reply": {
"enabled": True,
"action": "reply",
"reply": "店铺回复模板",
"sender_keywords": ["机器人客服"],
"message_keywords": ["询单"],
}
},
}
os.environ["SYSTEM_INQUIRY_ENABLED"] = "true"
os.environ["SYSTEM_INQUIRY_SHOPS"] = ""
async def test_detect_by_sender_keyword(self):
client = QingjianAPIClient(enable_agent=False)
client._system_inquiry_rules = self.rules
policy = client._resolve_system_inquiry_policy("shop_a")
data = {"acc_id": "shop_a", "from_name": "平台系统客服", "from_id": "kefu001", "msg": "你好"}
self.assertTrue(client._match_system_inquiry(data, policy))
async def test_shop_rule_reply_action(self):
client = QingjianAPIClient(enable_agent=False)
client._system_inquiry_rules = self.rules
client.send_reply = AsyncMock()
client.transfer_to_human = AsyncMock()
data = {
"acc_id": "shop_reply",
"from_name": "机器人客服A",
"from_id": "robot_01",
"msg": "有个询单请处理",
"acc_type": "AliWorkbench",
}
handled = await client._handle_system_inquiry(data)
self.assertTrue(handled)
client.send_reply.assert_awaited_once()
client.transfer_to_human.assert_not_awaited()
async def test_shop_whitelist_blocks_other_shops(self):
os.environ["SYSTEM_INQUIRY_SHOPS"] = "shop_only"
client = QingjianAPIClient(enable_agent=False)
client._system_inquiry_rules = self.rules
client.send_reply = AsyncMock()
data = {"acc_id": "shop_other", "from_name": "系统客服", "from_id": "sys_1", "msg": "系统询单"}
handled = await client._handle_system_inquiry(data)
self.assertFalse(handled)
client.send_reply.assert_not_awaited()
def tearDown(self):
for k in ("SYSTEM_INQUIRY_ENABLED", "SYSTEM_INQUIRY_SHOPS"):
os.environ.pop(k, None)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,20 @@
import unittest
from core.websocket_client import QingjianAPIClient
class TransferGreetingContextTest(unittest.TestCase):
def test_transfer_greeting_is_non_empty(self):
c = QingjianAPIClient(enable_agent=False)
text = c._pick_transfer_greeting()
self.assertTrue(isinstance(text, str) and len(text) > 0)
def test_transfer_greeting_contains_presence_phrase(self):
c = QingjianAPIClient(enable_agent=False)
for _ in range(10):
text = c._pick_transfer_greeting()
self.assertTrue(("" in text) or ("我在" in text))
if __name__ == "__main__":
unittest.main(verbosity=2)