新增功能: - 天网协作系统 (HTTP API 端口 6060) - 三种工作流 (查找图片/处理图片/转人工派单) - 图片任务数据库 (支持客户后续增加需求) - 图绘派单系统集成 (API: 8005) - 文字检测与加价 (60-80 元高价值订单) - 风险评估与接单判断 - 作图失败自动转人工 新增文档: - 项目功能汇总.md - 三种工作流功能说明.md - 文字加价功能说明.md - 风险评估功能说明.md - 图片任务数据库功能说明.md - 图绘派单系统集成说明.md - 作图失败转接人工说明.md - DEPLOYMENT.md - TIANWANG_INTEGRATION.md 核心修改: - core/pydantic_ai_agent.py - core/workflow.py - core/websocket_client.py - image/image_analyzer.py - services/service_tuhui_dispatch.py - db/image_tasks_db.py 版本:v1.0 日期:2026-02-28
299 lines
12 KiB
Python
Executable File
299 lines
12 KiB
Python
Executable File
"""
|
||
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
|