diff --git a/core/pydantic_ai_agent.py b/core/pydantic_ai_agent.py index 18bc574..3b35868 100755 --- a/core/pydantic_ai_agent.py +++ b/core/pydantic_ai_agent.py @@ -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]: diff --git a/core/websocket_client.py b/core/websocket_client.py index 60c3417..8d36005 100755 --- a/core/websocket_client.py +++ b/core/websocket_client.py @@ -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): """ 主动发送文本消息 diff --git a/tests/test_regression_pipeline.py b/tests/test_regression_pipeline.py index 14dad9f..6bdc89e 100644 --- a/tests/test_regression_pipeline.py +++ b/tests/test_regression_pipeline.py @@ -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()