feat: expand colloquial reply sets and support cross-image quote intent

This commit is contained in:
2026-02-28 23:23:51 +08:00
parent 5a73aa34d2
commit ca7e195d8f
3 changed files with 192 additions and 18 deletions

View File

@@ -310,6 +310,27 @@ class CustomerServiceAgent:
return ""
return cleaned
@staticmethod
def _colloquialize_reply(text: str) -> str:
"""把常见机械表达柔化为更口语的客服话术。"""
t = (text or "").strip()
if not t:
return t
repl = {
"确认我就安排": "你点头我就开做",
"可以的话我马上安排": "可以我就马上给你做",
"我这边马上安排": "我马上安排",
"立刻统一报价": "马上给你报价",
"统一报价": "一起给你报价",
"": "",
"请您": "",
"可选A": "可选:",
"流程完成": "已经安排好了",
}
for k, v in repl.items():
t = t.replace(k, v)
return t
def _register_tools(self):
"""注册所有 Tool让 Agent 可以主动调用"""
@@ -1493,7 +1514,7 @@ class CustomerServiceAgent:
has_incoming_urls=bool(incoming_urls),
):
quote_res = await self._quote_pending_images(state, message)
reply_text = quote_res.get("reply", "")
reply_text = self._colloquialize_reply(quote_res.get("reply", ""))
need_transfer = bool(quote_res.get("need_transfer"))
state.last_reply_at = datetime.now()
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
@@ -1504,7 +1525,7 @@ class CustomerServiceAgent:
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
)
ack = self._build_collect_ack(len(state.pending_image_urls))
ack = self._colloquialize_reply(self._build_collect_ack(len(state.pending_image_urls)))
state.last_reply_at = datetime.now()
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {ack}")
return AgentResponse(reply=ack, should_reply=True, need_transfer=False)
@@ -1519,7 +1540,7 @@ class CustomerServiceAgent:
has_incoming_urls=False,
):
quote_res = await self._quote_pending_images(state, message)
reply_text = quote_res.get("reply", "")
reply_text = self._colloquialize_reply(quote_res.get("reply", ""))
need_transfer = bool(quote_res.get("need_transfer"))
state.last_reply_at = datetime.now()
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
@@ -1530,7 +1551,7 @@ class CustomerServiceAgent:
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
)
remind = self._build_collect_remind(len(state.pending_image_urls))
remind = self._colloquialize_reply(self._build_collect_remind(len(state.pending_image_urls)))
state.last_reply_at = datetime.now()
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {remind}")
return AgentResponse(reply=remind, should_reply=True, need_transfer=False)
@@ -1597,7 +1618,7 @@ class CustomerServiceAgent:
result = await target_agent.run(user_prompt, deps=deps, message_history=history)
# 更新历史,最多保留最近 30 条消息防止 token 超限
self.message_histories[message.from_id] = result.all_messages()[-30:]
reply_text = self._normalize_reply_text(result.output)
reply_text = self._colloquialize_reply(self._normalize_reply_text(result.output))
# 价格谈判与信任建立固定策略(避免只回“最低了/先拍下”)
strategy_reply = self._negotiation_strategy_reply(message.msg, state)
@@ -1678,7 +1699,7 @@ class CustomerServiceAgent:
# AI 失败兜底:给一个不出错的万能回复
if not reply_text:
return AgentResponse(
reply="稍等,我看一下",
reply="嘞,你稍等,我这边看一下",
should_reply=True,
need_transfer=False
)
@@ -1955,19 +1976,34 @@ class CustomerServiceAgent:
return ""
if any(k in text for k in ["先发效果图", "先看效果", "不放心", "没法确认"]):
return (
f"小妹整理了一些案例图,亲点这个链接就能看到啦({CASE_LIBRARY_LINK})。"
"有什么想要的效果随时可以告诉我哈,我这边都可以按您的要求来做哦~/:065 效果不好不满意,我们这边包退的哦"
)
return random.choice([
f"小妹整理了一些案例图,亲点这个链接就能看到啦({CASE_LIBRARY_LINK})。有什么想要的效果随时告诉我哈,不满意我们这边包退。",
f"先给你看案例哈,链接在这({CASE_LIBRARY_LINK})。你想要什么效果直接说,我们按你要求做,不满意可退",
f"我把类似案例给你准备好了,点这个看就行({CASE_LIBRARY_LINK})。你放心说需求,效果不满意我们包退。",
f"怕效果不稳的话你先看案例,链接在这里({CASE_LIBRARY_LINK})。你确认风格后我按你要的做,不满意可以退。",
f"可以先看下我们做过的案例({CASE_LIBRARY_LINK}。你觉得方向OK再拍不满意我们这边支持退。",
])
if "有点贵" in text or "就是贵" in text:
# 约定示例两张优惠价默认45若已有单张价格则动态估算两张打包价
base = state.last_price if isinstance(state.last_price, int) and state.last_price > 0 else 25
two_pack = max(10, round(((base * 2) - 5) / 5) * 5)
return f"理解你的顾虑,这样吧,两张一起的话给你算 {two_pack} 元?"
return random.choice([
f"理解你这边的预算,我给你个实在点的:两张一起按 {two_pack} 元做,行不行?",
f"我懂你意思,这样吧,两张一起我给你算 {two_pack} 元。",
f"那我给你压一口价,两张打包 {two_pack} 元,你看可以我就开做。",
f"没问题,我给你优惠点,两张一起按 {two_pack} 元走。",
f"你这边要省点的话,两张一起我给你做到 {two_pack} 元。",
])
if any(k in text for k in ["优惠点", "便宜点", "少点", "打折"]):
return "看你要做的数量3张以上可以给你打包价"
return random.choice([
"可以的你这边数量上来我就好给价3张以上我给你打包价。",
"能优惠做得多会更划算3张以上我这边可以给你打包算。",
"没问题3张起我可以给你一口打包价会比单张省一些。",
"可以便宜点按量走更好谈3张以上我给你打包价。",
"你这边如果是多张做3张以上我能给你更划算的打包价。",
])
return ""
@@ -2115,21 +2151,49 @@ class CustomerServiceAgent:
elif len(state.pending_image_urls) >= 2:
if any(k in msg for k in multi_image_finish_kw) and not any(k in msg for k in hold_kw):
return True
if self._is_cross_image_composite_intent(msg) and not any(k in msg for k in hold_kw):
return True
return False
@staticmethod
def _is_cross_image_composite_intent(text: str) -> bool:
"""
识别多图跨图修改意图A图元素放到B图
A图的图案转到B图、这个图案放到另一张上。
"""
s = (text or "").strip()
if not s:
return False
pair_marks = ("a图", "b图", "第一张", "第二张", "这张", "那张", "上一张", "另一张")
op_kw = (
"转到", "换到", "放到", "贴到", "移到", "套到", "合成", "融合", "替换到",
"图案上去", "字放到", "元素放到", "logo放到",
)
return any(k in s.lower() for k in pair_marks) and any(k in s for k in op_kw)
def _build_collect_ack(self, count: int) -> str:
if count <= 1:
one_templates = [
"这张收到啦,还有图就继续发,我一起给你看。",
"图我看到了,后面还有就接着发,最后我一口价给你。",
"收到这张了,你有其他图也发来,我统一帮你算。",
"这张我先记上了,你那边还有的话接着发,我一起给你报。",
"第1张收到你继续发就行发完我这边一次给你算清楚。",
"这张没问题,我先收着。要是还有图,你直接连着发我就行。",
"我先看到了这张,你后面还有就一起发来,我统一给你报价。",
"这张图我已经记下了,后面有补充就继续甩过来哈。",
]
return random.choice(one_templates)
templates = [
"这几张我都收到了(现在{n}张)。还有的话继续发,我一起给你报。",
"好嘞,先看到{n}张了。你可以继续发,或者直接说“就这些”我现在就报价。",
"收到哈(共{n}张)。你还要补图就继续发,不补的话我现在也可以直接给价。",
"我这边先收到了{n}张。你继续补图,或者直接说“按这些算”我就开始报。",
"这波我已经记了{n}张,你要是还有就接着发,不补的话我立刻给总价。",
"先看到{n}张图了,后面你看是继续发,还是直接让我现在报价都可以。",
"好的,目前{n}张到位。你一句“就这些”,我马上给你打包价。",
"图我都看到了({n}张)。你还发我就继续收,不发我现在就给你报。",
]
return random.choice(templates).format(n=count)
@@ -2138,13 +2202,23 @@ class CustomerServiceAgent:
one_templates = [
"这个要求我记住了。你还有图就继续发,不补图我就按这张给你报价。",
"明白,这个需求我加上了。你继续发图也行,想直接报价也可以。",
"收到,我按你这个要求做。你要是就这张,我现在就给你报价。",
"懂你意思,这种能做。要不先按这张,我现在就给你报个实在价。",
"这个需求我收到了。你要是就做这张,我现在就给你报。",
"你这要求我记下了,后面还有图就发,没有的话我现在直接算价。",
"行,我按你这个要求来。继续补图也行,不补我就先报这张。",
"这个点我懂了,你还要补图就接着发,不补我立刻给你报价。",
"要求我已经加上了。你看是继续发,还是我现在直接报这张。",
]
return random.choice(one_templates)
templates = [
"需求我记下了(当前{n}张)。你继续补图,或者直接说“就这些”我现在报价。",
"好,这个要求也加上了(现在{n}张)。不再补图的话我立刻给你打包价。",
"收到(共{n}张)。你还发就继续,不发的话我现在就给总价。",
"这个需求我加进去了(现在{n}张)。你继续发也行,直接报价也行。",
"我这边都记好了({n}张+需求)。你一句“先按这些算”,我马上报价。",
"要求同步好了,目前{n}张。要补图继续发,不补图我现在就给你打包价。",
"行,需求和图片我都收着了({n}张)。你直接让我报价也可以。",
"好的,这条需求也算进去了(共{n}张)。你看要不要我现在直接报。",
]
return random.choice(templates).format(n=count)
@@ -2172,7 +2246,7 @@ class CustomerServiceAgent:
rules = [
(["分层", "psd", "源文件"], 30, "分层/源文件"),
(["去背景", "抠图", "透明底", "白底"], 5, "去背景"),
(["换背景", "换场景", "合成"], 10, "合成/换背景"),
(["换背景", "换场景", "合成", "转到", "换到", "放到", "贴到", "移到", "套到", "图案上去", "元素放到"], 10, "跨图合成/换背景"),
(["改字", "改文字", "替换文字", "排版"], 10, "改文字/排版"),
(["调色", "改色", "换色", "配色"], 5, "调色"),
(["多版本", "多个版本", "两版", "三版"], 10, "多版本"),
@@ -2224,6 +2298,11 @@ class CustomerServiceAgent:
"这张我看过了,先给你报下:",
"这张可以做,价格给你报下:",
"看了这张图,报价如下:",
"我先按这张给你算下:",
"这张处理没问题,我给你报个实在价:",
"我看完这张了,价格给你说下:",
"按这张图的难度,报价是:",
"这张我已经评估完了,先给你个价格:",
]
lines = [f"{random.choice(heads)}{line.split('', 1)[1]}"]
if req_hit:
@@ -2232,6 +2311,11 @@ class CustomerServiceAgent:
f"这张做下来共{single_total}元,定了我马上开工。",
f"合下来是{single_total}元,你点头我这边立刻安排。",
f"总价{single_total}元,可以的话我现在就给你做。",
f"这一张算下来{single_total}元,你说开做我就马上弄。",
f"给你按{single_total}元做,确定的话我现在就排上。",
f"这张我按{single_total}元给你做,没问题就直接开始。",
f"这张最终{single_total}元,你点头我立刻开干。",
f"这张就按{single_total}元走,你确认我就马上安排。",
]
lines.append(random.choice(tails))
return "\n".join(lines)
@@ -2240,14 +2324,31 @@ class CustomerServiceAgent:
"我先按这几张给你报一下:",
"这几张我都看过了,价格给你列一下:",
"我把每张价格先给你说清楚:",
"我先把这几张的价格拆开给你看:",
"这几张我都评估过了,报价给你写明白:",
"先别急,我把每张大概价给你列出来:",
"我按这批图先报个明细给你:",
"我先把每张费用和总价给你算出来:",
]
lines = [random.choice(heads)]
lines.extend(detail_lines)
if req_hit:
lines.append(f"需求加价:+{extra}元({req_hit}")
option_line = f"可选:按单张做(共{single_total}元),或打包做({bundle_price}元,会更省一点)。"
option_line = random.choice([
f"可选:按单张做(共{single_total}元),或打包做({bundle_price}元,会更省一点)。",
f"可选:单张算下来一共{single_total}元;打包给你{bundle_price}元,更划算。",
f"可选:你按单张做共{single_total}元,按打包做我给你{bundle_price}元。",
f"可选:分开做总共{single_total}元,打包做{bundle_price}元(省一点)。",
f"可选:按张算共{single_total}元;直接打包{bundle_price}元。",
])
lines.append(option_line)
lines.append("你定一个,我这边马上开工。")
lines.append(random.choice([
"你定一个,我这边马上开工。",
"你选个方案,我立刻给你安排上。",
"你拍板就行,我这边马上开做。",
"你看选哪个合适,我这边马上给你做。",
"你一句话定下来,我现在就给你安排。",
]))
return "\n".join(lines)
def _prepare_batch_intake(self, state: ConversationState) -> Dict[str, Any]:

View File

@@ -522,9 +522,10 @@ class QingjianAPIClient:
low, high = max(2.0, base * 0.5), base
# 发图后的需求描述,优先“多等一点”收集完整需求,减少半句回复
# 约束到 12-14s避免等待过长。
if is_req and not has_img:
low = max(low, min(10.0, base * 0.8))
high = max(high, min(20.0, base + 4.0))
low = max(low, 12.0)
high = min(14.0, max(high, 12.6))
# 短句更快,长句稍慢,避免把连续半句拆开
text_len = len((msg or "").strip())
@@ -1460,6 +1461,8 @@ class QingjianAPIClient:
print(f"[{self.get_time()}] 错误: 未连接到服务器")
return
reply_content = self._colloquialize_outbound_reply(reply_content)
# 同一客户外发限流N 秒内最多 1 条
try:
from config.config import OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS
@@ -1499,6 +1502,50 @@ class QingjianAPIClient:
self._log_outbound_once(original_msg, str(reply_content))
await self.send_message(reply)
def _colloquialize_outbound_reply(self, text: Any) -> Any:
"""统一外发口语化处理,避免机械话术。"""
if not isinstance(text, str):
return text
raw = text.strip()
if not raw:
return text
# 控制指令/转接命令不得改写
if raw.startswith("话术|") or "[转移会话]" in raw:
return text
# 纯链接不改
if re.fullmatch(r"https?://\S+", raw):
return text
out = raw
replacements = {
"我这边": "我这边",
"请您": "",
"您好": "你好",
"稍后": "一会儿",
"可以的话": "可以的话",
"请稍等": "稍等哈",
"先不乱报价": "先不急着给你乱报",
"建议转人工评估更稳": "建议转人工看会更稳",
"统一报价": "一起报价",
"马上安排": "马上给你安排",
"确认我就安排": "你点头我就开做",
"收到,我看看哈": "收到,我先看下",
"收到,我找找刚才那几张": "收到,我把刚才那几张一起看下",
"这组图我这边暂时识别不稳定": "这组图我这边识别得不太稳",
"这组图我这边暂时识别异常": "这组图我这边刚才识别有点异常",
"你可以换一张更清晰的,我再给你准报价。": "你换张更清晰的发我,我再给你报准点。",
"你可以换清晰图再发我。": "你换张清晰点的再发我哈。",
"你可以稍后再发我。": "你晚点再发我也行。",
"收到付款,我马上安排处理,有需要第一时间联系您": "收到付款啦,我马上安排处理,有进展第一时间告诉你",
"亲,正在为您转接人工客服,请稍等~": "我这就给你转人工,稍等哈~",
}
for k, v in replacements.items():
out = out.replace(k, v)
# 收尾语气柔化
out = out.replace("", "")
return out
async def send_text(self, cy_id, acc_type, content):
"""
主动发送文本消息

View File

@@ -113,6 +113,32 @@ class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("45", resp.reply)
agent._quote_pending_images.assert_awaited()
async def test_cross_image_composite_intent_triggers_quote(self):
agent = CustomerServiceAgent()
st = agent._get_conversation_state(self.customer_id)
st.pending_image_urls = ["https://img.alicdn.com/a.jpg", "https://img.alicdn.com/b.jpg"]
st.pending_requirements = []
agent._sync_pending_quote_state(self.customer_id, st)
agent._quote_pending_images = AsyncMock(return_value={"reply": "这个跨图合成可以做打包50元", "need_transfer": False})
msg = CustomerMessage(
msg_id="m5",
acc_id="test_shop",
msg="A图的图案转换到B图上去",
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("50", 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()