""" AI 左右互搏测试工具 客服AI(真实) vs 买家AI(模拟) 用来调试客服AI的回复质量,不需要真实客户 """ import asyncio import os import random import httpx from openai import AsyncOpenAI from dotenv import load_dotenv from pydantic_ai_agent import CustomerServiceAgent, CustomerMessage load_dotenv() WECHAT_WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=cc88bdef-a13f-4d7e-bdb6-ee51b68b8205" async def notify_wechat(content: str): """发送企业微信机器人通知""" try: async with httpx.AsyncClient(timeout=10) as client: await client.post(WECHAT_WEBHOOK, json={ "msgtype": "text", "text": {"content": content} }) except Exception as e: print(f"[通知] 企业微信发送失败: {e}") # ========== 颜色输出 ========== RESET = "\033[0m" BLUE = "\033[94m" # 买家 GREEN = "\033[92m" # 客服 YELLOW = "\033[93m" # 系统提示 GRAY = "\033[90m" # 分隔线 def print_buyer(msg): print(f"{BLUE}【买家】{msg}{RESET}") def print_shop(msg): print(f"{GREEN}【客服】{msg}{RESET}") def print_system(msg): print(f"{YELLOW}{msg}{RESET}") def print_sep(): print(f"{GRAY}{'─' * 50}{RESET}") # ========== 买家人设配置 ========== BUYER_PERSONAS = { "普通买家": """你是一个普通淘宝买家,想找一张图的高清版本。 行为:先打招呼,发图问价,可能小砍一次价,觉得合适就说要拍了。 说话简短自然,像手机上发消息,1-2句话。不要太正式。""", "砍价客": """你是个爱砍价的淘宝买家,总想便宜一点。 行为:问价后嫌贵,砍价2-3次,最后可能接受也可能走人。 说话直接,喜欢说"能不能便宜点""太贵了""别家更便宜"。""", "爱问细节": """你是个很谨慎的买家,买东西前喜欢问清楚。 行为:先问在不在,问能不能找到,问格式,问效果,问不满意能退吗,最后才考虑下单。 说话有点啰嗦,喜欢多问几个问题。""", "快节奏": """你是个很忙的买家,说话简短,不废话。 行为:直接发图问多少钱,价格合适立刻说拍了,不合适直接走。 每次只说3-5个字,极度简短。""", "售后投诉": """你是个已经付款的买家,但对收到的图片不满意,想要退款或重做。 行为:先说拿到图了但效果不行,描述哪里不满意(模糊/颜色不对/尺寸不够), 要求重做或者退款,态度有点不耐烦,反复追问进度。 说话带点情绪,但不至于骂人,就是那种"花钱了结果不行"的委屈感。""", "多图打包": """你是个需要批量处理图片的买家,手头有好几张图要找高清版。 行为:先问在不在,然后说有多张图要处理,询问能不能打包便宜点, 逐步发图或询问价格,跟客服商量总价,最终决定下单还是再想想。 说话随意,像在商量事情,不太在意每张的价格,更关注总价合不合适。""", "大批量砍价": """你是个手头有十几张图要处理的买家,量大,觉得自己有底气砍价。 行为:上来就说"我有很多图,你们量大能便宜吗", 告知大概数量(10-20张),用量大作为筹码反复压价, 问"做完这批还有下一批,能不能给个长期价""别家给我打6折了", 如果客服给的总价还算合理就下单,否则拉锯几个来回。 说话有点强势,觉得自己是大客户,应该享受优惠。""", "要分层PSD": """你是个做设计的买家,需要带分层的PSD格式,不是普通jpg。 行为:先问在不在,发图后问"这个能出PSD吗""要分层的那种", 追问能不能保留图层、格式是不是真的PSD,听到价格后可能嫌贵问能不能便宜, 最终决定下单或者放弃。 说话带点专业感,会说"图层""分层""PSD""源文件"之类的词。""", "问尺寸分辨率": """你是个有印刷需求的买家,非常在意图片的尺寸和分辨率。 行为:发图后先不问价,反复问"这个能做多大""分辨率能到300dpi吗""印出来会不会模糊", 问完尺寸再问价格,如果客服说能满足需求就考虑下单,否则犹豫很久。 说话里经常出现"厘米""像素""dpi""印刷""大图"。""", "问颜色效果": """你是个对颜色很挑剔的买家,担心高清版颜色和原图有色差。 行为:发图后问"颜色会变吗""饱和度能保持吗""跟原图一样的色调吧", 让客服保证颜色效果,追问不满意能不能改,反复确认才肯下单。 说话谨慎,总要客服给"保证",但说话不凶,就是很在意品质。""", "来源质疑": """你是个对店铺服务有疑虑的买家,总觉得有猫腻,想搞清楚原理。 行为:问"你们是怎么找的""原图是从哪里来的""是AI弄的吗还是真的原图", 追问来源和方法,对客服的回答将信将疑,继续追问, 最终可能被说服下单,也可能觉得不靠谱走人。 说话带点怀疑,喜欢反问,但不是来找茬的,是真的想搞清楚。""", } # 用于测试的图片URL TEST_IMAGE_URLS = [ "https://img.alicdn.com/imgextra/i1/O1CN01OilxfD1kr8tM4Ugg2_!!4611686018427387680-0-amp.jpg", ] class BuyerAgent: """模拟买家的AI""" def __init__(self, persona_name: str = "普通买家"): self.client = AsyncOpenAI( api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL"), ) self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") self.persona_name = persona_name self.persona = BUYER_PERSONAS.get(persona_name, BUYER_PERSONAS["普通买家"]) self.history = [] self.image_sent = False self.rounds = 0 async def next_message(self, shop_reply: str = None) -> str: """根据客服回复,生成下一条买家消息""" self.rounds += 1 if shop_reply: self.history.append({"role": "assistant", "content": f"客服说:{shop_reply}"}) # 第一轮:打招呼或直接发问 if self.rounds == 1: first_msgs = ["在不在", "在吗", "你好", "有人吗", "亲在吗"] msg = random.choice(first_msgs) self.history.append({"role": "user", "content": msg}) return msg # 第二轮:发图+问有没有 if self.rounds == 2 and not self.image_sent: self.image_sent = True url = random.choice(TEST_IMAGE_URLS) msg = f"有吗#*#{url}" self.history.append({"role": "user", "content": msg}) return msg # 后续:让AI根据对话历史自由生成 system = f"""{self.persona} 你正在和一家找图店的客服聊天,对话历史如下。 请根据你的人设生成下一条消息,简短自然,像真人发消息。 如果对话已经快结束(超过10轮),可以说"好的拍了"或"算了不要了"结束对话。 只输出你要发的消息内容,不要加任何前缀说明。""" resp = await self.client.chat.completions.create( model=self.model, messages=[ {"role": "system", "content": system}, *self.history, {"role": "user", "content": "(请生成你的下一条消息)"} ], max_tokens=80, temperature=0.9, ) msg = resp.choices[0].message.content.strip() self.history.append({"role": "user", "content": msg}) return msg async def run_battle(persona_name: str = "普通买家", max_rounds: int = 8): """运行一场对话模拟""" print_sep() print_system(f" 开始模拟 | 买家人设:{persona_name} | 最多 {max_rounds} 轮") print_sep() # 初始化双方 buyer = BuyerAgent(persona_name) shop = CustomerServiceAgent() buyer_id = "test_buyer_001" shop_id = "test_shop_001" shop_reply = None for i in range(max_rounds): # 买家发消息 buyer_msg = await buyer.next_message(shop_reply) print_buyer(buyer_msg) # 判断对话是否结束 end_words = ["算了", "不要了", "不买了", "拍了", "好的拍了", "下单了", "谢谢"] if any(w in buyer_msg for w in end_words): print_system(f"\n 对话结束(第 {i+1} 轮)") break # 构建消息对象 customer_msg = CustomerMessage( msg_id=f"test_{i}", acc_id=shop_id, msg=buyer_msg, from_id=buyer_id, from_name="测试买家", cy_id=buyer_id, acc_type="AliWorkbench", msg_type=0, cy_name="测试买家", goods_name="模糊图清晰处理专业代找原图素材淘宝图片找图修复服务", ) # 含图片URL时先打印等待语(模拟真实客服的即时回应) if "#*#" in buyer_msg or (buyer_msg.startswith(("http://", "https://")) and any( h in buyer_msg for h in ("alicdn.com", "imgextra", ".jpg", ".jpeg", ".png") )): print_shop("我找一下看看") print() # 客服AI处理 response = await shop.process_message(customer_msg) if response.need_transfer: print_shop("[转人工]") print_system("\n 客服决定转人工,对话结束") break shop_reply = response.reply if response.should_reply else None # 过滤 AI 误输出的内部独白(与生产环境保持一致) nonsense_patterns = [ "无需", "流程已完成", "不需要回复", "无需额外", "已完成", "无需回复", "不需要额外", "已经完成", "无需再", "操作已完成", "任务完成", "流程完成", "记录完成", "报价已", ] if shop_reply and any(p in shop_reply for p in nonsense_patterns): print_system(f" [已拦截无效回复: {shop_reply}]") shop_reply = None if shop_reply: print_shop(shop_reply) else: print_system(" (客服决定不回复)") shop_reply = "" print() await asyncio.sleep(0.3) print_sep() print_system(" 模拟结束") print_sep() async def main(): import sys personas = list(BUYER_PERSONAS.keys()) print(f"\n{YELLOW}{'=' * 50}") print(" AI 左右互搏测试工具") print(f"{'=' * 50}{RESET}") print(f"{GRAY}用法: python test_battle.py [人设编号|all]") print(f" 1=普通买家 2=砍价客 3=爱问细节 4=快节奏") print(f" 5=售后投诉 6=多图打包 7=大批量砍价 8=要分层PSD") print(f" 9=问尺寸分辨率 10=问颜色效果 11=来源质疑 0=全部{RESET}\n") arg = sys.argv[1] if len(sys.argv) > 1 else "1" if arg == "0" or arg == "all": for persona in personas: await run_battle(persona, max_rounds=14) print() await asyncio.sleep(1) else: try: idx = int(arg) - 1 persona = personas[idx] if 0 <= idx < len(personas) else personas[0] except ValueError: persona = personas[0] await run_battle(persona, max_rounds=14) if __name__ == "__main__": try: asyncio.run(main()) except Exception as e: err_str = str(e) if "AccountOverdueError" in err_str or "overdue" in err_str.lower(): msg = "⚠️ 火山引擎 API 欠费,请立即充值!\n地址:https://console.volcengine.com/ark" else: msg = f"⚠️ 测试脚本异常退出:{err_str[:200]}" print(f"\n[通知] {msg}") asyncio.run(notify_wechat(msg)) raise