feat: role-based skills, AI-first replies, and deferred batch quote routing
This commit is contained in:
19
.env
19
.env
@@ -1,8 +1,8 @@
|
|||||||
# 火山引擎 Ark API
|
# 火山引擎 Ark API
|
||||||
OPENAI_API_KEY=cb2360ff-1a52-4289-abd6-ec29118376d0
|
OPENAI_API_KEY=cb2360ff-1a52-4289-abd6-ec29118376d0
|
||||||
OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
OPENAI_MODEL=doubao-seed-2-0-mini-260215
|
OPENAI_MODEL=doubao-seed-2-0-lite-260215
|
||||||
VISION_MODEL=doubao-seed-2-0-mini-260215
|
VISION_MODEL=doubao-seed-2-0-lite-260215
|
||||||
GEMINI_IMAGE_MODEL=gemini-3.1-flash-image-preview
|
GEMINI_IMAGE_MODEL=gemini-3.1-flash-image-preview
|
||||||
GEMINI_IMAGE_FALLBACK_MODEL=gemini-2.5-flash-image
|
GEMINI_IMAGE_FALLBACK_MODEL=gemini-2.5-flash-image
|
||||||
GEMINI_IMAGE_SIZE=1K
|
GEMINI_IMAGE_SIZE=1K
|
||||||
@@ -22,8 +22,17 @@ SMTP_PASSWORD=bnnppvaweytkcadc
|
|||||||
SENDER_NAME=修图客服
|
SENDER_NAME=修图客服
|
||||||
EMAIL_POLL_INTERVAL=30
|
EMAIL_POLL_INTERVAL=30
|
||||||
|
|
||||||
# 企业微信群机器人 Webhook(日报推送)
|
# 企业微信群机器人 Webhook(聊天记录推送专用)
|
||||||
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=cc88bdef-a13f-4d7e-bdb6-ee51b68b8205
|
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=b16b26a9-c8a7-402e-9bc9-8b5c0a83ebb4
|
||||||
|
|
||||||
|
# 收图阶段话术:true=优先AI生成(模板仅兜底)
|
||||||
|
AI_DYNAMIC_COLLECTION_REPLIES=true
|
||||||
|
# 所有最终回复都再经过AI润色(转人工指令除外)
|
||||||
|
AI_REWRITE_ALL_REPLIES=true
|
||||||
|
# 图片收齐后延后几轮消息再报价(1=先承接一轮,下一句再报价)
|
||||||
|
BATCH_QUOTE_DELAY_TURNS=1
|
||||||
|
# AI 客服人设(可随时改)
|
||||||
|
AI_REPLY_PERSONA=淘宝老店主,说话直接、有耐心、像真人微信聊天,不端着
|
||||||
|
|
||||||
# 每日日报接收邮箱(留空则不发邮件)
|
# 每日日报接收邮箱(留空则不发邮件)
|
||||||
SUMMARY_EMAIL=
|
SUMMARY_EMAIL=
|
||||||
@@ -57,6 +66,6 @@ TUHUI_DEFAULT_PRICE=20
|
|||||||
DB_TYPE=mysql
|
DB_TYPE=mysql
|
||||||
MYSQL_HOST=1.12.50.92
|
MYSQL_HOST=1.12.50.92
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
MYSQL_USER=root
|
MYSQL_USER=ai_cs_user
|
||||||
MYSQL_PASSWORD=Zuowei1216
|
MYSQL_PASSWORD=Zuowei1216
|
||||||
MYSQL_DATABASE=ai_cs
|
MYSQL_DATABASE=ai_cs
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from core.workflow_router import get_workflow_router
|
|||||||
from core.workflow_router import get_workflow_router
|
from core.workflow_router import get_workflow_router
|
||||||
|
|
||||||
# ========== 企业微信通知 ==========
|
# ========== 企业微信通知 ==========
|
||||||
_WECHAT_WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=cc88bdef-a13f-4d7e-bdb6-ee51b68b8205"
|
_WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
|
||||||
|
|
||||||
|
|
||||||
async def _notify_wechat(content: str, tag: str = "通知"):
|
async def _notify_wechat(content: str, tag: str = "通知"):
|
||||||
@@ -96,6 +96,8 @@ class ConversationState(BaseModel):
|
|||||||
image_count: int = 0 # 图片数量
|
image_count: int = 0 # 图片数量
|
||||||
pending_image_urls: List[str] = Field(default_factory=list) # 待统一报价图片
|
pending_image_urls: List[str] = Field(default_factory=list) # 待统一报价图片
|
||||||
pending_requirements: List[str] = Field(default_factory=list) # 待统一报价需求
|
pending_requirements: List[str] = Field(default_factory=list) # 待统一报价需求
|
||||||
|
quote_phase: str = "idle" # idle/collecting/ready_to_quote/waiting_result
|
||||||
|
quote_ready_turns: int = 0 # ready_to_quote 阶段还需等待的消息轮次
|
||||||
last_update: str = ""
|
last_update: str = ""
|
||||||
last_reply_at: Optional[datetime] = None # 最后一次回复客户的时间
|
last_reply_at: Optional[datetime] = None # 最后一次回复客户的时间
|
||||||
|
|
||||||
@@ -142,19 +144,23 @@ def _get_shop_type(acc_id: str = "", goods_name: str = "") -> str:
|
|||||||
return "find_image"
|
return "find_image"
|
||||||
|
|
||||||
|
|
||||||
def load_skill_md(skills_dir: str = "skills") -> str:
|
def load_skill_map(skills_dir: str = "skills") -> Dict[str, str]:
|
||||||
"""加载 skills 目录下的所有 SKILL.md 文件内容"""
|
"""按技能目录名加载 SKILL.md,返回 {skill_name: content}。"""
|
||||||
skill_contents = []
|
skill_map: Dict[str, str] = {}
|
||||||
skill_files = glob.glob(os.path.join(skills_dir, "**/SKILL.md"), recursive=True)
|
skill_files = glob.glob(os.path.join(skills_dir, "**/SKILL.md"), recursive=True)
|
||||||
for skill_file in skill_files:
|
for skill_file in skill_files:
|
||||||
try:
|
try:
|
||||||
with open(skill_file, 'r', encoding='utf-8') as f:
|
content = Path(skill_file).read_text(encoding="utf-8")
|
||||||
content = f.read()
|
skill_name = Path(skill_file).parent.name.strip().lower()
|
||||||
skill_contents.append(content)
|
if not skill_name:
|
||||||
|
continue
|
||||||
|
if skill_name in skill_map:
|
||||||
|
skill_map[skill_name] += "\n\n" + content
|
||||||
|
else:
|
||||||
|
skill_map[skill_name] = content
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"警告: 读取 {skill_file} 失败: {e}")
|
print(f"警告: 读取 {skill_file} 失败: {e}")
|
||||||
|
return skill_map
|
||||||
return "\n\n".join(skill_contents)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerServiceAgent:
|
class CustomerServiceAgent:
|
||||||
@@ -171,6 +177,13 @@ class CustomerServiceAgent:
|
|||||||
self.api_key = os.getenv("OPENAI_API_KEY")
|
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||||
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||||
self.model_name = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
self.model_name = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||||
|
self.reply_persona = os.getenv("AI_REPLY_PERSONA", "淘宝老店主,直爽利落,口语自然")
|
||||||
|
self.dynamic_collection_replies = os.getenv("AI_DYNAMIC_COLLECTION_REPLIES", "true").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
self.rewrite_all_replies = os.getenv("AI_REWRITE_ALL_REPLIES", "true").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
try:
|
||||||
|
self.batch_quote_delay_turns = max(0, int(os.getenv("BATCH_QUOTE_DELAY_TURNS", "1")))
|
||||||
|
except Exception:
|
||||||
|
self.batch_quote_delay_turns = 1
|
||||||
|
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise ValueError("请设置 OPENAI_API_KEY 环境变量")
|
raise ValueError("请设置 OPENAI_API_KEY 环境变量")
|
||||||
@@ -181,8 +194,13 @@ class CustomerServiceAgent:
|
|||||||
self.message_histories: Dict[str, list] = {}
|
self.message_histories: Dict[str, list] = {}
|
||||||
self.evolution_candidate = self._load_evolution_candidate()
|
self.evolution_candidate = self._load_evolution_candidate()
|
||||||
|
|
||||||
# 加载 skills 内容
|
# 加载技能并按角色拆分,避免所有 Agent 吃同一份大杂烩提示词
|
||||||
self.skills_content = load_skill_md(skills_dir)
|
self.skill_map = load_skill_map(skills_dir)
|
||||||
|
self.skill_style = self._compose_skill_content(["style-skill", "owner-style"])
|
||||||
|
self.skill_pre_sales = self._compose_skill_content(["pre-sales-skill"])
|
||||||
|
self.skill_pricing = self._compose_skill_content(["pricing-skill"])
|
||||||
|
self.skill_after_sale = self._compose_skill_content(["after-sales-skill"])
|
||||||
|
self.skill_risk = self._compose_skill_content(["risk-skill"])
|
||||||
|
|
||||||
# 创建 OpenAI 模型
|
# 创建 OpenAI 模型
|
||||||
model = OpenAIChatModel(
|
model = OpenAIChatModel(
|
||||||
@@ -218,6 +236,11 @@ class CustomerServiceAgent:
|
|||||||
deps_type=AgentDeps,
|
deps_type=AgentDeps,
|
||||||
system_prompt=self._get_similar_prompt()
|
system_prompt=self._get_similar_prompt()
|
||||||
)
|
)
|
||||||
|
self.agent_natural_reply = Agent(
|
||||||
|
model=model,
|
||||||
|
deps_type=AgentDeps,
|
||||||
|
system_prompt=self._get_natural_reply_prompt()
|
||||||
|
)
|
||||||
# 工作流程路由器
|
# 工作流程路由器
|
||||||
self.workflow_router = get_workflow_router()
|
self.workflow_router = get_workflow_router()
|
||||||
|
|
||||||
@@ -235,6 +258,22 @@ class CustomerServiceAgent:
|
|||||||
# 注册工具
|
# 注册工具
|
||||||
self._register_tools()
|
self._register_tools()
|
||||||
|
|
||||||
|
def _compose_skill_content(self, names: List[str]) -> str:
|
||||||
|
"""按技能名拼接技能文本,找不到则跳过。"""
|
||||||
|
parts: List[str] = []
|
||||||
|
for name in names:
|
||||||
|
key = (name or "").strip().lower()
|
||||||
|
if key and key in self.skill_map:
|
||||||
|
parts.append(self.skill_map[key])
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attach_skill_docs(prompt: str, *skill_docs: str) -> str:
|
||||||
|
docs = [d for d in skill_docs if d]
|
||||||
|
if not docs:
|
||||||
|
return prompt
|
||||||
|
return prompt + "\n\n=== 角色技能 ===\n" + "\n\n".join(docs)
|
||||||
|
|
||||||
def _load_evolution_candidate(self) -> Dict[str, Any]:
|
def _load_evolution_candidate(self) -> Dict[str, Any]:
|
||||||
"""读取自我进化候选配置(灰度策略),读取失败时返回空。"""
|
"""读取自我进化候选配置(灰度策略),读取失败时返回空。"""
|
||||||
try:
|
try:
|
||||||
@@ -331,6 +370,96 @@ class CustomerServiceAgent:
|
|||||||
t = t.replace(k, v)
|
t = t.replace(k, v)
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
async def _render_collection_reply_with_ai(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message: CustomerMessage,
|
||||||
|
state: ConversationState,
|
||||||
|
scene: str,
|
||||||
|
intent_hint: str,
|
||||||
|
fallback: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
收图阶段回复默认走 AI 改写,失败时回退到固定模板。
|
||||||
|
"""
|
||||||
|
if not self.dynamic_collection_replies:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
async def _rewrite_reply_with_ai(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
message: CustomerMessage,
|
||||||
|
state: ConversationState,
|
||||||
|
reply: str,
|
||||||
|
scene: str = "final_reply",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
对最终回复做 AI 润色,统一口吻。失败时返回原文。
|
||||||
|
"""
|
||||||
|
text = (reply or "").strip()
|
||||||
|
if not text or not self.rewrite_all_replies:
|
||||||
|
return text
|
||||||
|
transfer_keywords = ("TRANSFER_REQUESTED", "[转移会话]", "转移会话")
|
||||||
|
if any(k in text for k in transfer_keywords):
|
||||||
|
return text
|
||||||
|
try:
|
||||||
|
deps = AgentDeps(
|
||||||
|
msg_id=message.msg_id,
|
||||||
|
acc_id=message.acc_id,
|
||||||
|
from_id=message.from_id,
|
||||||
|
platform=message.acc_type,
|
||||||
|
)
|
||||||
|
history = self.message_histories.get(message.from_id, [])
|
||||||
|
pending_req = ";".join((state.pending_requirements or [])[-4:]) or "无"
|
||||||
|
prompt = (
|
||||||
|
"请把下面这句客服回复润色成更自然的微信聊天口吻,语义必须保持一致。\n"
|
||||||
|
f"场景: {scene}\n"
|
||||||
|
f"客户原话: {message.msg}\n"
|
||||||
|
f"当前已收图: {len(state.pending_image_urls)}张\n"
|
||||||
|
f"当前需求摘要: {pending_req}\n"
|
||||||
|
f"原回复: {text}\n"
|
||||||
|
"要求: 不要新增承诺/价格/流程;不超过2句话;只输出润色后的最终回复。"
|
||||||
|
)
|
||||||
|
result = await self.agent_natural_reply.run(prompt, deps=deps, message_history=history)
|
||||||
|
self.message_histories[message.from_id] = result.all_messages()[-30:]
|
||||||
|
polished = self._colloquialize_reply(self._normalize_reply_text(result.output))
|
||||||
|
if not polished:
|
||||||
|
return text
|
||||||
|
if any(k in polished for k in transfer_keywords):
|
||||||
|
return text
|
||||||
|
return polished
|
||||||
|
except Exception:
|
||||||
|
return text
|
||||||
|
try:
|
||||||
|
deps = AgentDeps(
|
||||||
|
msg_id=message.msg_id,
|
||||||
|
acc_id=message.acc_id,
|
||||||
|
from_id=message.from_id,
|
||||||
|
platform=message.acc_type,
|
||||||
|
)
|
||||||
|
history = self.message_histories.get(message.from_id, [])
|
||||||
|
pending_req = ";".join((state.pending_requirements or [])[-4:]) or "无"
|
||||||
|
user_prompt = (
|
||||||
|
"请按下面意图生成给客户的自然回复。\n"
|
||||||
|
f"场景: {scene}\n"
|
||||||
|
f"回复意图: {intent_hint}\n"
|
||||||
|
f"客户原话: {message.msg}\n"
|
||||||
|
f"当前已收图片数: {len(state.pending_image_urls)}\n"
|
||||||
|
f"当前需求摘要: {pending_req}\n"
|
||||||
|
"输出要求: 不超过2句话,像真人店主聊天。"
|
||||||
|
)
|
||||||
|
result = await self.agent_natural_reply.run(user_prompt, deps=deps, message_history=history)
|
||||||
|
self.message_histories[message.from_id] = result.all_messages()[-30:]
|
||||||
|
text = self._colloquialize_reply(self._normalize_reply_text(result.output))
|
||||||
|
if not text:
|
||||||
|
return fallback
|
||||||
|
transfer_keywords = ("TRANSFER_REQUESTED", "[转移会话]", "转移会话")
|
||||||
|
if any(k in text for k in transfer_keywords):
|
||||||
|
return fallback
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
def _register_tools(self):
|
def _register_tools(self):
|
||||||
"""注册所有 Tool,让 Agent 可以主动调用"""
|
"""注册所有 Tool,让 Agent 可以主动调用"""
|
||||||
|
|
||||||
@@ -951,6 +1080,7 @@ class CustomerServiceAgent:
|
|||||||
def _sync_pending_quote_state(self, customer_id: str, state: ConversationState):
|
def _sync_pending_quote_state(self, customer_id: str, state: ConversationState):
|
||||||
"""把待报价队列同步到客户库,避免重启丢失。"""
|
"""把待报价队列同步到客户库,避免重启丢失。"""
|
||||||
try:
|
try:
|
||||||
|
self._refresh_quote_phase(state)
|
||||||
from db.customer_db import db
|
from db.customer_db import db
|
||||||
db.update_pending_quote_state(
|
db.update_pending_quote_state(
|
||||||
customer_id,
|
customer_id,
|
||||||
@@ -968,9 +1098,49 @@ class CustomerServiceAgent:
|
|||||||
state.pending_image_urls = list(getattr(profile, "pending_quote_images", []) or [])
|
state.pending_image_urls = list(getattr(profile, "pending_quote_images", []) or [])
|
||||||
state.pending_requirements = list(getattr(profile, "pending_quote_requirements", []) or [])
|
state.pending_requirements = list(getattr(profile, "pending_quote_requirements", []) or [])
|
||||||
state.image_count = len(state.pending_image_urls)
|
state.image_count = len(state.pending_image_urls)
|
||||||
|
self._refresh_quote_phase(state)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _refresh_quote_phase(state: ConversationState, phase_hint: str = ""):
|
||||||
|
"""统一维护收图报价状态机。"""
|
||||||
|
if phase_hint in {"idle", "collecting", "ready_to_quote", "waiting_result"}:
|
||||||
|
state.quote_phase = phase_hint
|
||||||
|
if phase_hint == "idle":
|
||||||
|
state.quote_ready_turns = 0
|
||||||
|
return
|
||||||
|
if not state.pending_image_urls:
|
||||||
|
state.quote_phase = "idle"
|
||||||
|
state.quote_ready_turns = 0
|
||||||
|
return
|
||||||
|
if state.quote_phase in {"ready_to_quote", "waiting_result"}:
|
||||||
|
return
|
||||||
|
if state.pending_image_urls and state.pending_requirements:
|
||||||
|
state.quote_phase = "collecting"
|
||||||
|
return
|
||||||
|
state.quote_phase = "collecting"
|
||||||
|
|
||||||
|
def _should_defer_batch_quote(self, state: ConversationState, mark_ready: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
批量报价延后控制:
|
||||||
|
- 首次进入 ready_to_quote 时按配置等待 N 轮
|
||||||
|
- 等待轮次归零后,本轮即可报价
|
||||||
|
"""
|
||||||
|
if mark_ready and state.quote_phase != "ready_to_quote":
|
||||||
|
state.quote_phase = "ready_to_quote"
|
||||||
|
state.quote_ready_turns = max(0, int(self.batch_quote_delay_turns))
|
||||||
|
if state.quote_phase == "ready_to_quote" and state.quote_ready_turns > 0:
|
||||||
|
state.quote_ready_turns -= 1
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _mark_quote_ready(self, state: ConversationState):
|
||||||
|
"""仅标记 ready 状态,不消费等待轮次。"""
|
||||||
|
if state.quote_phase != "ready_to_quote":
|
||||||
|
state.quote_phase = "ready_to_quote"
|
||||||
|
state.quote_ready_turns = max(0, int(self.batch_quote_delay_turns))
|
||||||
|
|
||||||
def _build_reject_message(self, reason: str = "") -> str:
|
def _build_reject_message(self, reason: str = "") -> str:
|
||||||
templates = [
|
templates = [
|
||||||
"这类图文字内容太密了,我们这边不接这单哈,建议精简后再发我看看。",
|
"这类图文字内容太密了,我们这边不接这单哈,建议精简后再发我看看。",
|
||||||
@@ -1088,14 +1258,22 @@ class CustomerServiceAgent:
|
|||||||
- 回复不超过2句话
|
- 回复不超过2句话
|
||||||
- 绝对禁止输出任何内部独白或状态说明,包括但不限于:"无需回复""已完成""已经完成""不需要回复""流程结束""操作完成""任务完成""记录完成""报价已记录"等
|
- 绝对禁止输出任何内部独白或状态说明,包括但不限于:"无需回复""已完成""已经完成""不需要回复""流程结束""操作完成""任务完成""记录完成""报价已记录"等
|
||||||
- 每次必须输出真实的、发给客户看的回复文字,哪怕只有一句话"""
|
- 每次必须输出真实的、发给客户看的回复文字,哪怕只有一句话"""
|
||||||
|
base_prompt += f"\n\n【人设语气】\n- 人设:{self.reply_persona}\n- 语气像真人店主,不官腔,不机械,不背模板。"
|
||||||
|
|
||||||
if self.skills_content:
|
return self._attach_skill_docs(base_prompt, self.skill_pre_sales, self.skill_style)
|
||||||
base_prompt += f"\n\n=== 技能文档 ===\n{self.skills_content}"
|
|
||||||
|
|
||||||
return base_prompt
|
def _get_natural_reply_prompt(self) -> str:
|
||||||
|
base = f"""你是淘宝店主客服,专门把系统给你的“回复意图”改写成自然的一句话或两句话。
|
||||||
|
人设:{self.reply_persona}
|
||||||
|
规则:
|
||||||
|
- 只输出发给客户的话,不要解释你的思考。
|
||||||
|
- 口语化、简短、有温度,避免“这个需求我收到了”这类机械表达。
|
||||||
|
- 不要编造价格、订单、进度;只按输入意图表达。
|
||||||
|
- 默认不超过2句话。"""
|
||||||
|
return self._attach_skill_docs(base, self.skill_style)
|
||||||
|
|
||||||
def _get_after_sale_prompt(self) -> str:
|
def _get_after_sale_prompt(self) -> str:
|
||||||
return """你是淘宝客服的售后助手,负责售后阶段的自然沟通与处理进度反馈。
|
base = """你是淘宝客服的售后助手,负责售后阶段的自然沟通与处理进度反馈。
|
||||||
核心:简洁、自然、不解释技术细节、尽量不调用报价相关工具。
|
核心:简洁、自然、不解释技术细节、尽量不调用报价相关工具。
|
||||||
规则:
|
规则:
|
||||||
- 已付款客户优先:确认安排、说明进度、承诺时间点
|
- 已付款客户优先:确认安排、说明进度、承诺时间点
|
||||||
@@ -1103,6 +1281,7 @@ class CustomerServiceAgent:
|
|||||||
- 催进度:自然回复在做了/快了/马上好,给预计时间
|
- 催进度:自然回复在做了/快了/马上好,给预计时间
|
||||||
- 投诉/情绪激动/退款:转人工
|
- 投诉/情绪激动/退款:转人工
|
||||||
- 输出不超过2句话,不说内部状态"""
|
- 输出不超过2句话,不说内部状态"""
|
||||||
|
return self._attach_skill_docs(base, self.skill_after_sale, self.skill_style)
|
||||||
|
|
||||||
def _get_pricing_prompt(self) -> str:
|
def _get_pricing_prompt(self) -> str:
|
||||||
try:
|
try:
|
||||||
@@ -1110,7 +1289,7 @@ class CustomerServiceAgent:
|
|||||||
floor = MIN_PRICE_FLOOR
|
floor = MIN_PRICE_FLOOR
|
||||||
except Exception:
|
except Exception:
|
||||||
floor = 15
|
floor = 15
|
||||||
return f"""你是淘宝客服的报价助手,负责在客户明确提到价格/询价时快速给出自然报价并推动成交。
|
base = f"""你是淘宝客服的报价助手,负责在客户明确提到价格/询价时快速给出自然报价并推动成交。
|
||||||
规则:
|
规则:
|
||||||
- 收到图片或历史有图片依据时尽量结合复杂度给出单价,价格为5的整数倍
|
- 收到图片或历史有图片依据时尽量结合复杂度给出单价,价格为5的整数倍
|
||||||
- 没有图片时引导发图,不给价格区间
|
- 没有图片时引导发图,不给价格区间
|
||||||
@@ -1122,33 +1301,38 @@ class CustomerServiceAgent:
|
|||||||
有什么想要的效果随时告诉我哈,我这边都可以按您的要求来做哦~/:065 效果不好不满意,我们这边包退的哦。
|
有什么想要的效果随时告诉我哈,我这边都可以按您的要求来做哦~/:065 效果不好不满意,我们这边包退的哦。
|
||||||
- 最低价不低于{floor}元,客户出价低于底线时礼貌拒绝(不好意思)
|
- 最低价不低于{floor}元,客户出价低于底线时礼貌拒绝(不好意思)
|
||||||
- 输出不超过2句话"""
|
- 输出不超过2句话"""
|
||||||
|
return self._attach_skill_docs(base, self.skill_pricing, self.skill_style)
|
||||||
|
|
||||||
def _get_processing_prompt(self) -> str:
|
def _get_processing_prompt(self) -> str:
|
||||||
return """你是淘宝客服的处理助手,负责在客户说安排/处理/开始做或已付款的场景下进行处理安排与进度反馈。
|
base = """你是淘宝客服的处理助手,负责在客户说安排/处理/开始做或已付款的场景下进行处理安排与进度反馈。
|
||||||
规则:
|
规则:
|
||||||
- 已付款或明确要求开始时,确认安排并给预计时间点
|
- 已付款或明确要求开始时,确认安排并给预计时间点
|
||||||
- 可调用处理流程工具
|
- 可调用处理流程工具
|
||||||
- 投诉/退款时转人工
|
- 投诉/退款时转人工
|
||||||
- 输出不超过2句话"""
|
- 输出不超过2句话"""
|
||||||
|
return self._attach_skill_docs(base, self.skill_after_sale, self.skill_style)
|
||||||
|
|
||||||
def _get_similar_prompt(self) -> str:
|
def _get_similar_prompt(self) -> str:
|
||||||
return """你是淘宝客服的相似图助手,客户问“有一样的吗/类似的吗/同款吗”时,给出自然回复与参考建议。
|
base = """你是淘宝客服的相似图助手,客户问“有一样的吗/类似的吗/同款吗”时,给出自然回复与参考建议。
|
||||||
规则:
|
规则:
|
||||||
- 先确认可以找类似款,建议拍后我发参考图
|
- 先确认可以找类似款,建议拍后我发参考图
|
||||||
- 如已知图案/类型,简要说明“同类型都有”,推动成交
|
- 如已知图案/类型,简要说明“同类型都有”,推动成交
|
||||||
- 输出不超过2句话"""
|
- 输出不超过2句话"""
|
||||||
|
return self._attach_skill_docs(base, self.skill_pre_sales, self.skill_style)
|
||||||
|
|
||||||
def _get_order_prompt(self) -> str:
|
def _get_order_prompt(self) -> str:
|
||||||
return """你是淘宝客服的订单助手,负责系统订单通知的处理。
|
base = """你是淘宝客服的订单助手,负责系统订单通知的处理。
|
||||||
规则:
|
规则:
|
||||||
- 已付款时自然确认安排;其他状态静默(输出空字符串)
|
- 已付款时自然确认安排;其他状态静默(输出空字符串)
|
||||||
- 输出不超过1句话"""
|
- 输出不超过1句话"""
|
||||||
|
return self._attach_skill_docs(base, self.skill_after_sale, self.skill_style)
|
||||||
|
|
||||||
def _get_risk_prompt(self) -> str:
|
def _get_risk_prompt(self) -> str:
|
||||||
return """你是淘宝客服的风控助手,负责敏感/违规内容的前置拦截与替代话术。
|
base = """你是淘宝客服的风控助手,负责敏感/违规内容的前置拦截与替代话术。
|
||||||
规则:
|
规则:
|
||||||
- 黄色/擦边/涉政/政治人物/政治事件/政治图片等不接单,礼貌拒绝
|
- 黄色/擦边/涉政/政治人物/政治事件/政治图片等不接单,礼貌拒绝
|
||||||
- 输出不超过1句话"""
|
- 输出不超过1句话"""
|
||||||
|
return self._attach_skill_docs(base, self.skill_risk, self.skill_style)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_political_inquiry(text: str) -> bool:
|
def _is_political_inquiry(text: str) -> bool:
|
||||||
@@ -1435,7 +1619,12 @@ class CustomerServiceAgent:
|
|||||||
state.pending_image_urls.clear()
|
state.pending_image_urls.clear()
|
||||||
state.pending_requirements.clear()
|
state.pending_requirements.clear()
|
||||||
self._sync_pending_quote_state(message.from_id, state)
|
self._sync_pending_quote_state(message.from_id, state)
|
||||||
reply = "这类不做哈,政治相关图片和人物都不接。"
|
reply = await self._rewrite_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
reply="这类不做哈,政治相关图片和人物都不接。",
|
||||||
|
scene="risk_reject",
|
||||||
|
)
|
||||||
state.last_reply_at = datetime.now()
|
state.last_reply_at = datetime.now()
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply}")
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply}")
|
||||||
return AgentResponse(reply=reply, should_reply=True, need_transfer=False)
|
return AgentResponse(reply=reply, should_reply=True, need_transfer=False)
|
||||||
@@ -1444,7 +1633,12 @@ class CustomerServiceAgent:
|
|||||||
state.pending_image_urls.clear()
|
state.pending_image_urls.clear()
|
||||||
state.pending_requirements.clear()
|
state.pending_requirements.clear()
|
||||||
self._sync_pending_quote_state(message.from_id, state)
|
self._sync_pending_quote_state(message.from_id, state)
|
||||||
reply = "这类不做哈,政治相关图片和人物都不接。"
|
reply = await self._rewrite_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
reply="这类不做哈,政治相关图片和人物都不接。",
|
||||||
|
scene="risk_reject",
|
||||||
|
)
|
||||||
state.last_reply_at = datetime.now()
|
state.last_reply_at = datetime.now()
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply}")
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply}")
|
||||||
return AgentResponse(reply=reply, should_reply=True, need_transfer=False)
|
return AgentResponse(reply=reply, should_reply=True, need_transfer=False)
|
||||||
@@ -1498,14 +1692,19 @@ class CustomerServiceAgent:
|
|||||||
if shop_type == "find_image" and self._is_batch_quote_enabled(message.from_id, message.acc_id):
|
if shop_type == "find_image" and self._is_batch_quote_enabled(message.from_id, message.acc_id):
|
||||||
incoming_urls = self._extract_image_urls(customer_text)
|
incoming_urls = self._extract_image_urls(customer_text)
|
||||||
text_without_urls = self._strip_urls_from_text(customer_text)
|
text_without_urls = self._strip_urls_from_text(customer_text)
|
||||||
|
short_intent = self._classify_short_customer_text(text_without_urls)
|
||||||
|
|
||||||
if incoming_urls:
|
if incoming_urls:
|
||||||
|
is_related_followup = bool(text_without_urls and self._is_related_image_followup_intent(text_without_urls))
|
||||||
for u in incoming_urls:
|
for u in incoming_urls:
|
||||||
if u not in state.pending_image_urls:
|
if u not in state.pending_image_urls:
|
||||||
state.pending_image_urls.append(u)
|
state.pending_image_urls.append(u)
|
||||||
if text_without_urls:
|
if text_without_urls:
|
||||||
self._append_requirement(state, text_without_urls)
|
self._append_requirement(state, text_without_urls)
|
||||||
|
if is_related_followup:
|
||||||
|
self._append_requirement(state, "与上一张相关(截图/局部细节)")
|
||||||
state.image_count = len(state.pending_image_urls)
|
state.image_count = len(state.pending_image_urls)
|
||||||
|
self._refresh_quote_phase(state, "collecting")
|
||||||
self._sync_pending_quote_state(message.from_id, state)
|
self._sync_pending_quote_state(message.from_id, state)
|
||||||
|
|
||||||
if self._is_batch_finish_intent(
|
if self._is_batch_finish_intent(
|
||||||
@@ -1513,8 +1712,28 @@ class CustomerServiceAgent:
|
|||||||
state=state,
|
state=state,
|
||||||
has_incoming_urls=bool(incoming_urls),
|
has_incoming_urls=bool(incoming_urls),
|
||||||
):
|
):
|
||||||
|
should_defer = self._should_defer_batch_quote(state, mark_ready=True)
|
||||||
|
self._sync_pending_quote_state(message.from_id, state)
|
||||||
|
if should_defer:
|
||||||
|
defer_fallback = "图片和需求我都收齐了,我先整理下,马上给你报总价。"
|
||||||
|
defer_reply = await self._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="quote_defer_notice",
|
||||||
|
intent_hint="确认已收齐图片与需求,先承接,告知稍后马上报价。",
|
||||||
|
fallback=defer_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {defer_reply}")
|
||||||
|
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
|
||||||
quote_res = await self._quote_pending_images(state, message)
|
quote_res = await self._quote_pending_images(state, message)
|
||||||
reply_text = self._colloquialize_reply(quote_res.get("reply", ""))
|
reply_text = self._colloquialize_reply(quote_res.get("reply", ""))
|
||||||
|
reply_text = await self._rewrite_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
reply=reply_text,
|
||||||
|
scene="batch_quote_reply",
|
||||||
|
)
|
||||||
need_transfer = bool(quote_res.get("need_transfer"))
|
need_transfer = bool(quote_res.get("need_transfer"))
|
||||||
state.last_reply_at = datetime.now()
|
state.last_reply_at = datetime.now()
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
||||||
@@ -1525,25 +1744,96 @@ class CustomerServiceAgent:
|
|||||||
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
ack = self._colloquialize_reply(self._build_collect_ack(len(state.pending_image_urls)))
|
ack_fallback = "图片收到了,你有补充就继续发,我这边一起看。"
|
||||||
|
ack_intent = (
|
||||||
|
"告知图片已收到;如果客户继续发图就继续收,发完可统一报价。"
|
||||||
|
if not is_related_followup
|
||||||
|
else "告知这是和上一张相关的截图/局部图,已按同一需求一起处理。"
|
||||||
|
)
|
||||||
|
ack = await self._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="collect_ack",
|
||||||
|
intent_hint=ack_intent,
|
||||||
|
fallback=ack_fallback,
|
||||||
|
)
|
||||||
state.last_reply_at = datetime.now()
|
state.last_reply_at = datetime.now()
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {ack}")
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {ack}")
|
||||||
return AgentResponse(reply=ack, should_reply=True, need_transfer=False)
|
return AgentResponse(reply=ack, should_reply=True, need_transfer=False)
|
||||||
|
|
||||||
if state.pending_image_urls:
|
if state.pending_image_urls:
|
||||||
if text_without_urls:
|
if text_without_urls:
|
||||||
|
# 短句先分类再路由,避免误追加为需求导致上下文漂移
|
||||||
|
if short_intent == "finish_signal":
|
||||||
|
self._mark_quote_ready(state)
|
||||||
|
elif short_intent == "progress_query":
|
||||||
|
if state.quote_phase != "ready_to_quote":
|
||||||
|
self._refresh_quote_phase(state, "waiting_result")
|
||||||
|
elif short_intent == "ack":
|
||||||
|
if state.quote_phase != "ready_to_quote":
|
||||||
|
self._refresh_quote_phase(state, "collecting")
|
||||||
|
else:
|
||||||
self._append_requirement(state, text_without_urls)
|
self._append_requirement(state, text_without_urls)
|
||||||
|
self._refresh_quote_phase(state, "collecting")
|
||||||
self._sync_pending_quote_state(message.from_id, state)
|
self._sync_pending_quote_state(message.from_id, state)
|
||||||
# 客户明确“找图,不是做图”时,先澄清意图,不继续报价链路
|
# 客户明确“找图,不是做图”时,先澄清意图,不继续报价链路
|
||||||
if self._is_find_image_not_edit_conflict(text_without_urls):
|
if self._is_find_image_not_edit_conflict(text_without_urls):
|
||||||
clarify = self._build_find_image_clarify_reply(state)
|
clarify_fallback = "明白你是要找图,不是做图。你说下要找原图、同款还是高清版,我按这个给你找。"
|
||||||
|
clarify = await self._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="find_not_edit_clarify",
|
||||||
|
intent_hint="确认客户要找图不是做图,并追问是找原图/同款/高清版。",
|
||||||
|
fallback=clarify_fallback,
|
||||||
|
)
|
||||||
state.last_reply_at = datetime.now()
|
state.last_reply_at = datetime.now()
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {clarify}")
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {clarify}")
|
||||||
return AgentResponse(reply=clarify, should_reply=True, need_transfer=False)
|
return AgentResponse(reply=clarify, should_reply=True, need_transfer=False)
|
||||||
|
|
||||||
|
# 已到报价就绪阶段且等待轮次结束:对“有吗/进度”等追问直接报价
|
||||||
|
if state.quote_phase == "ready_to_quote" and state.quote_ready_turns <= 0 and short_intent in {"progress_query", "ack", "finish_signal"}:
|
||||||
|
quote_res = await self._quote_pending_images(state, message)
|
||||||
|
reply_text = self._colloquialize_reply(quote_res.get("reply", ""))
|
||||||
|
reply_text = await self._rewrite_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
reply=reply_text,
|
||||||
|
scene="batch_quote_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}")
|
||||||
|
return AgentResponse(
|
||||||
|
reply=reply_text,
|
||||||
|
should_reply=not need_transfer,
|
||||||
|
need_transfer=need_transfer,
|
||||||
|
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 客户在追问“找到了吗/没找到吗/多久好”时,优先给进度承接,不走“没听懂”
|
||||||
|
if short_intent == "progress_query" or self._is_result_followup_query(text_without_urls):
|
||||||
|
progress_fallback = "我这边在跟进了,一有结果马上发你。"
|
||||||
|
progress = await self._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="collect_progress",
|
||||||
|
intent_hint="承接客户的进度/结果追问,简短说明正在跟进,有结果会第一时间回复。",
|
||||||
|
fallback=progress_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {progress}")
|
||||||
|
return AgentResponse(reply=progress, should_reply=True, need_transfer=False)
|
||||||
|
|
||||||
# 信息不足时先追问,避免误判为“直接报价”
|
# 信息不足时先追问,避免误判为“直接报价”
|
||||||
if self._needs_clarification_in_collecting(text_without_urls):
|
if self._needs_clarification_in_collecting(text_without_urls):
|
||||||
ask = self._build_not_understood_reply()
|
ask_fallback = "你再补一句具体要什么效果,我马上按你的要求来。"
|
||||||
|
ask = await self._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="collect_clarify",
|
||||||
|
intent_hint="客户表达不清,礼貌请对方补充一句关键需求,不要机械,不要生硬。",
|
||||||
|
fallback=ask_fallback,
|
||||||
|
)
|
||||||
state.last_reply_at = datetime.now()
|
state.last_reply_at = datetime.now()
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {ask}")
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {ask}")
|
||||||
return AgentResponse(reply=ask, should_reply=True, need_transfer=False)
|
return AgentResponse(reply=ask, should_reply=True, need_transfer=False)
|
||||||
@@ -1552,8 +1842,28 @@ class CustomerServiceAgent:
|
|||||||
state=state,
|
state=state,
|
||||||
has_incoming_urls=False,
|
has_incoming_urls=False,
|
||||||
):
|
):
|
||||||
|
should_defer = self._should_defer_batch_quote(state, mark_ready=True)
|
||||||
|
self._sync_pending_quote_state(message.from_id, state)
|
||||||
|
if should_defer:
|
||||||
|
defer_fallback = "收到,我先把这批图过一遍,马上给你总价。"
|
||||||
|
defer_reply = await self._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="quote_defer_notice",
|
||||||
|
intent_hint="确认已收齐,先承接并告知稍后马上报价。",
|
||||||
|
fallback=defer_fallback,
|
||||||
|
)
|
||||||
|
state.last_reply_at = datetime.now()
|
||||||
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {defer_reply}")
|
||||||
|
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
|
||||||
quote_res = await self._quote_pending_images(state, message)
|
quote_res = await self._quote_pending_images(state, message)
|
||||||
reply_text = self._colloquialize_reply(quote_res.get("reply", ""))
|
reply_text = self._colloquialize_reply(quote_res.get("reply", ""))
|
||||||
|
reply_text = await self._rewrite_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
reply=reply_text,
|
||||||
|
scene="batch_quote_reply",
|
||||||
|
)
|
||||||
need_transfer = bool(quote_res.get("need_transfer"))
|
need_transfer = bool(quote_res.get("need_transfer"))
|
||||||
state.last_reply_at = datetime.now()
|
state.last_reply_at = datetime.now()
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
||||||
@@ -1564,7 +1874,14 @@ class CustomerServiceAgent:
|
|||||||
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
remind = self._colloquialize_reply(self._build_collect_remind(len(state.pending_image_urls)))
|
remind_fallback = "需求我记上了,你继续发图,或者让我直接给你报价都行。"
|
||||||
|
remind = await self._render_collection_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
scene="collect_remind",
|
||||||
|
intent_hint="确认需求已记录,引导客户继续补图或直接让你报价。",
|
||||||
|
fallback=remind_fallback,
|
||||||
|
)
|
||||||
state.last_reply_at = datetime.now()
|
state.last_reply_at = datetime.now()
|
||||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {remind}")
|
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {remind}")
|
||||||
return AgentResponse(reply=remind, should_reply=True, need_transfer=False)
|
return AgentResponse(reply=remind, should_reply=True, need_transfer=False)
|
||||||
@@ -1711,8 +2028,14 @@ class CustomerServiceAgent:
|
|||||||
|
|
||||||
# AI 失败兜底:给一个不出错的万能回复
|
# AI 失败兜底:给一个不出错的万能回复
|
||||||
if not reply_text:
|
if not reply_text:
|
||||||
return AgentResponse(
|
fallback_text = await self._rewrite_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
reply="好嘞,你稍等下,我这边看一下",
|
reply="好嘞,你稍等下,我这边看一下",
|
||||||
|
scene="fallback_reply",
|
||||||
|
)
|
||||||
|
return AgentResponse(
|
||||||
|
reply=fallback_text,
|
||||||
should_reply=True,
|
should_reply=True,
|
||||||
need_transfer=False
|
need_transfer=False
|
||||||
)
|
)
|
||||||
@@ -1778,6 +2101,14 @@ class CustomerServiceAgent:
|
|||||||
if evo_hit and need_transfer and self._evolution_has_proposal("tone-empathy-pack"):
|
if evo_hit and need_transfer and self._evolution_has_proposal("tone-empathy-pack"):
|
||||||
should_reply = True
|
should_reply = True
|
||||||
|
|
||||||
|
if should_reply:
|
||||||
|
reply_text = await self._rewrite_reply_with_ai(
|
||||||
|
message=message,
|
||||||
|
state=state,
|
||||||
|
reply=reply_text,
|
||||||
|
scene="final_reply",
|
||||||
|
)
|
||||||
|
|
||||||
# 记录本次回复时间,供冷却期判断
|
# 记录本次回复时间,供冷却期判断
|
||||||
if should_reply:
|
if should_reply:
|
||||||
state.last_reply_at = datetime.now()
|
state.last_reply_at = datetime.now()
|
||||||
@@ -2108,9 +2439,13 @@ class CustomerServiceAgent:
|
|||||||
"""客户是否表达“图发完了,可以统一报价”。"""
|
"""客户是否表达“图发完了,可以统一报价”。"""
|
||||||
if not text:
|
if not text:
|
||||||
return False
|
return False
|
||||||
|
if self._classify_short_customer_text(text) == "finish_signal":
|
||||||
|
return True
|
||||||
finish_keywords = [
|
finish_keywords = [
|
||||||
"发完了", "都发完了", "发齐了", "齐了", "先这些", "就这些", "全部", "一起报", "统一报价",
|
"发完了", "都发完了", "发齐了", "齐了", "先这些", "就这些", "全部", "一起报", "统一报价",
|
||||||
"总共多少钱", "一共多少钱", "打包价", "总价", "报价吧", "报个总价", "给个总价",
|
"总共多少钱", "一共多少钱", "打包价", "总价", "报价吧", "报个总价", "给个总价",
|
||||||
|
"没了", "没有了", "没图了", "就这", "就这张", "就这一张", "就这一个", "就一个",
|
||||||
|
"先报吧", "报下价", "报个价", "可以报价了", "能报吗",
|
||||||
]
|
]
|
||||||
return any(k in text for k in finish_keywords)
|
return any(k in text for k in finish_keywords)
|
||||||
|
|
||||||
@@ -2185,7 +2520,84 @@ class CustomerServiceAgent:
|
|||||||
)
|
)
|
||||||
return any(k in s.lower() for k in pair_marks) and any(k in s for k in op_kw)
|
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:
|
@staticmethod
|
||||||
|
def _is_related_image_followup_intent(text: str) -> bool:
|
||||||
|
"""
|
||||||
|
识别“新发的是上一张的截图/局部细节”的关联意图。
|
||||||
|
这类输入应与前图关联处理,避免当成完全独立需求。
|
||||||
|
"""
|
||||||
|
s = (text or "").strip().lower()
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
relation_kw = (
|
||||||
|
"截图", "截屏", "局部", "细节", "放大", "裁剪", "同一张", "同一幅",
|
||||||
|
"上一张", "上张", "前一张", "前面那张", "刚才那张", "这个是上面",
|
||||||
|
"这个是那张", "补一张细节", "补个截图",
|
||||||
|
)
|
||||||
|
return any(k in s for k in relation_kw)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_result_followup_query(text: str) -> bool:
|
||||||
|
"""识别客户在找图流程中的结果/进度追问。"""
|
||||||
|
short_type = CustomerServiceAgent._classify_short_customer_text(text)
|
||||||
|
if short_type == "progress_query":
|
||||||
|
return True
|
||||||
|
s = (text or "").strip()
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
followup_kw = (
|
||||||
|
"找到了吗", "没找到吗", "找到没", "找到没有", "找到了没", "有吗", "有没", "有没有",
|
||||||
|
"有结果吗", "结果呢",
|
||||||
|
"进度", "多久好", "什么时候好", "好了没", "弄好了吗", "做了没",
|
||||||
|
"你重新发", "重新发给我", "高清", "发我",
|
||||||
|
)
|
||||||
|
if any(k in s for k in followup_kw):
|
||||||
|
return True
|
||||||
|
return s in {"?", "?", "在吗", "人呢"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _classify_short_customer_text(text: str) -> str:
|
||||||
|
"""
|
||||||
|
短句分类器(状态机前置):
|
||||||
|
- finish_signal: 发图完成,可报价
|
||||||
|
- progress_query: 追问进度/结果
|
||||||
|
- ack: 简短确认
|
||||||
|
- unknown: 未识别
|
||||||
|
"""
|
||||||
|
s = (text or "").strip()
|
||||||
|
if not s:
|
||||||
|
return "unknown"
|
||||||
|
if len(s) > 8:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
finish_kw = (
|
||||||
|
"没了", "没有了", "就这", "就这张", "就这一张", "就这一个", "就一个",
|
||||||
|
"先这些", "就这些", "发完了", "都发完了",
|
||||||
|
)
|
||||||
|
if any(k in s for k in finish_kw):
|
||||||
|
return "finish_signal"
|
||||||
|
|
||||||
|
progress_kw = (
|
||||||
|
"有吗", "有没", "有没有", "找到了吗", "找到了没", "没找到吗", "找到没", "找到没有",
|
||||||
|
"进度", "结果", "多久好", "什么时候好", "好了没", "弄好了吗", "做了没",
|
||||||
|
"高清", "发我", "重新发", "你重新发给我",
|
||||||
|
)
|
||||||
|
if any(k in s for k in progress_kw) or s in {"?", "?", "在吗", "人呢"}:
|
||||||
|
return "progress_query"
|
||||||
|
|
||||||
|
ack_kw = ("嗯", "嗯嗯", "好", "好的", "行", "可以", "ok", "OK", "收到", "明白")
|
||||||
|
if s in ack_kw:
|
||||||
|
return "ack"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def _build_collect_ack(self, count: int, related_followup: bool = False) -> str:
|
||||||
|
if related_followup and count >= 2:
|
||||||
|
related_templates = [
|
||||||
|
"这张我收到了,看起来是上一张的截图/细节图,我按同一单一起处理。还有补充就继续发。",
|
||||||
|
"收到,这张是关联补图我记上了(按同一需求处理)。你还有图就继续发。",
|
||||||
|
"明白,这张是前图的局部截图,我会和前面那张一起算,不会分开漏掉。",
|
||||||
|
]
|
||||||
|
return random.choice(related_templates)
|
||||||
if count <= 1:
|
if count <= 1:
|
||||||
one_templates = [
|
one_templates = [
|
||||||
"这张收到啦,还有图就继续发,我一起给你看。",
|
"这张收到啦,还有图就继续发,我一起给你看。",
|
||||||
@@ -2210,13 +2622,28 @@ class CustomerServiceAgent:
|
|||||||
]
|
]
|
||||||
return random.choice(templates).format(n=count)
|
return random.choice(templates).format(n=count)
|
||||||
|
|
||||||
|
def _build_collect_progress_reply(self, count: int) -> str:
|
||||||
|
if count <= 1:
|
||||||
|
templates = [
|
||||||
|
"我这边在处理了,这张有结果我第一时间回你。",
|
||||||
|
"在跟进中,这张一有进展我马上发你。",
|
||||||
|
"这张我正在看,稍等我一会儿,结果出来就回你。",
|
||||||
|
]
|
||||||
|
return random.choice(templates)
|
||||||
|
templates = [
|
||||||
|
"我这边在按你这{n}张一起处理,有结果我立刻同步你。",
|
||||||
|
"正在跟进这{n}张,出结果我第一时间发你,不会漏。",
|
||||||
|
"进度在跑了(共{n}张),你稍等一下,我这边有结果马上回。",
|
||||||
|
]
|
||||||
|
return random.choice(templates).format(n=count)
|
||||||
|
|
||||||
def _build_collect_remind(self, count: int) -> str:
|
def _build_collect_remind(self, count: int) -> str:
|
||||||
if count <= 1:
|
if count <= 1:
|
||||||
one_templates = [
|
one_templates = [
|
||||||
"这个要求我记住了。你还有图就继续发,不补图我就按这张给你报价。",
|
"这个要求我记住了。你还有图就继续发,不补图我就按这张给你报价。",
|
||||||
"明白,这个需求我加上了。你继续发图也行,想直接报价也可以。",
|
"明白,这个需求我加上了。你继续发图也行,想直接报价也可以。",
|
||||||
"我先记下这张。你如果是要我找图,不是做图,直接说一声,我按找图思路给你走。",
|
"我先记下这张。你如果是要我找图,不是做图,直接说一声,我按找图思路给你走。",
|
||||||
"这个需求我收到了。你要是就做这张,我现在就给你报。",
|
"收到,这张我先按你的要求记好了。就做这一张的话,我现在直接给你报实价。",
|
||||||
"你这要求我记下了,后面还有图就发,没有的话我现在直接算价。",
|
"你这要求我记下了,后面还有图就发,没有的话我现在直接算价。",
|
||||||
"行,我按你这个要求来。继续补图也行,不补我就先报这张。",
|
"行,我按你这个要求来。继续补图也行,不补我就先报这张。",
|
||||||
"这个点我懂了,你还要补图就接着发,不补我立刻给你报价。",
|
"这个点我懂了,你还要补图就接着发,不补我立刻给你报价。",
|
||||||
@@ -2254,7 +2681,14 @@ class CustomerServiceAgent:
|
|||||||
s = (text or "").strip()
|
s = (text or "").strip()
|
||||||
if not s:
|
if not s:
|
||||||
return False
|
return False
|
||||||
|
short_non_vague_kw = (
|
||||||
|
"?", "?", "没了", "没有了", "就这", "行", "好的", "ok", "报价",
|
||||||
|
"找到了吗", "没找到吗", "找到没", "找到了没", "有吗", "有没", "有没有",
|
||||||
|
"多久好", "什么时候好", "高清",
|
||||||
|
)
|
||||||
if len(s) <= 4:
|
if len(s) <= 4:
|
||||||
|
if any(k in s for k in short_non_vague_kw):
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
vague_kw = (
|
vague_kw = (
|
||||||
"这个也是", "一共几个图", "几个图", "啥意思", "没明白", "什么意思",
|
"这个也是", "一共几个图", "几个图", "啥意思", "没明白", "什么意思",
|
||||||
@@ -2585,6 +3019,7 @@ class CustomerServiceAgent:
|
|||||||
pass
|
pass
|
||||||
state.pending_image_urls.clear()
|
state.pending_image_urls.clear()
|
||||||
state.pending_requirements.clear()
|
state.pending_requirements.clear()
|
||||||
|
self._refresh_quote_phase(state, "idle")
|
||||||
self._sync_pending_quote_state(customer_id, state)
|
self._sync_pending_quote_state(customer_id, state)
|
||||||
|
|
||||||
async def _quote_pending_images(self, state: ConversationState, message: CustomerMessage) -> Dict[str, Any]:
|
async def _quote_pending_images(self, state: ConversationState, message: CustomerMessage) -> Dict[str, Any]:
|
||||||
|
|||||||
20
skills/after-sales-skill/SKILL.md
Normal file
20
skills/after-sales-skill/SKILL.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: after-sales-skill
|
||||||
|
description: 售后沟通技能,覆盖催进度、修改反馈、交付确认与情绪安抚。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 售后技能
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 售后回复简洁稳定,先安抚再推进处理。
|
||||||
|
|
||||||
|
## 执行规则
|
||||||
|
|
||||||
|
- 催进度:直接给状态和下一步,不空话。
|
||||||
|
- 修改需求:先确认收到,再问关键修改点。
|
||||||
|
- 已付款客户:优先确认已安排。
|
||||||
|
- 客户不满:先接情绪,再给处理动作。
|
||||||
|
- 退款/投诉场景:按流程转人工。
|
||||||
|
- 输出 1-2 句,避免解释技术细节。
|
||||||
|
|
||||||
@@ -9,6 +9,30 @@ description: 找原图店客服 - 售前咨询、报价成交、售后处理
|
|||||||
|
|
||||||
> **像真人聊天,不要套模板。** 每次说法自然变化,跟着客户语气走,禁止背台词、固定句式。
|
> **像真人聊天,不要套模板。** 每次说法自然变化,跟着客户语气走,禁止背台词、固定句式。
|
||||||
|
|
||||||
|
## 0) 上下文优先(最高优先级)
|
||||||
|
|
||||||
|
回复前先做 3 步:
|
||||||
|
|
||||||
|
1. 先看最近 6-10 条对话,不要只看当前一句。
|
||||||
|
2. 判断客户当前阶段:收图中 / 等结果 / 询价 / 砍价 / 售后。
|
||||||
|
3. 再回复 1-2 句自然口语。
|
||||||
|
|
||||||
|
短句必须结合上下文,不要机械回“没听懂”。
|
||||||
|
|
||||||
|
### 短句意图映射(收图或待结果阶段)
|
||||||
|
|
||||||
|
- 「有吗 / 有没 / 有没有 / 找到了吗 / 找到了没 / 没找到吗」=> 结果追问(承接进度)
|
||||||
|
- 「就这一个 / 没有了 / 先这些 / 就这些」=> 发图完成(进入报价)
|
||||||
|
- 「高清 / 重新发 / 发我 / 在吗 / ?」=> 跟进催办(先承接,不要反问)
|
||||||
|
|
||||||
|
### 多图关联识别
|
||||||
|
|
||||||
|
客户发第二张图时,若有以下表达,默认按“同一需求补充图”处理,不要当新单:
|
||||||
|
|
||||||
|
- 「上一张」「前面那张」「这个是截图」「局部」「细节」「放大图」「补个截图」
|
||||||
|
|
||||||
|
回复里要明确“已按同一需求一起处理”,避免断上下文。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 【重要】业务背景
|
## 【重要】业务背景
|
||||||
@@ -73,6 +97,10 @@ description: 找原图店客服 - 售前咨询、报价成交、售后处理
|
|||||||
|
|
||||||
不解释,自然推下单发图、不满意退。说正面的。
|
不解释,自然推下单发图、不满意退。说正面的。
|
||||||
|
|
||||||
|
如果当前已经在收图/待结果阶段,像「有吗」「没找到吗」这类短句,优先按“催进度”承接:
|
||||||
|
- 可用话术方向:在跟进中 / 有进展马上发你 / 这边正在看
|
||||||
|
- 不要回“你说具体点”“没听懂”
|
||||||
|
|
||||||
**⚠️ 前后一致**:若上一句刚说了「这类不做」「不接」某张图,客户接着问「能找到吗」「可以吗」→ 必须明确区分:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,否则会让客户以为刚才拒绝的那张也能做,前后矛盾。
|
**⚠️ 前后一致**:若上一句刚说了「这类不做」「不接」某张图,客户接着问「能找到吗」「可以吗」→ 必须明确区分:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,否则会让客户以为刚才拒绝的那张也能做,前后矛盾。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ description: 店主个人说话风格
|
|||||||
- 开头不用每次都加"好的",直接说正事也行
|
- 开头不用每次都加"好的",直接说正事也行
|
||||||
- 报价可以有多种说法:30块 / 30元 / 30 / 这张30 / 价格30,轮换着用
|
- 报价可以有多种说法:30块 / 30元 / 30 / 这张30 / 价格30,轮换着用
|
||||||
|
|
||||||
|
### 上下文承接(必须)
|
||||||
|
|
||||||
|
- 客户短句如「有吗」「没找到吗」「高清」「?」,先按进度追问承接,不要直接回“没听懂”。
|
||||||
|
- 客户说「就这一个」「没有了」「先这些」,默认进入可报价状态。
|
||||||
|
- 客户说「这是上一张截图/局部」,按同一需求补充处理,不当成新单。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 报价风格
|
## 报价风格
|
||||||
|
|||||||
22
skills/pre-sales-skill/SKILL.md
Normal file
22
skills/pre-sales-skill/SKILL.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: pre-sales-skill
|
||||||
|
description: 售前接待与收图阶段技能,强调上下文承接、短句意图识别和多图关联理解。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 售前技能
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 收图阶段回复自然,快速推进到“发完图 -> 报价”。
|
||||||
|
- 短句不误判,优先承接上下文。
|
||||||
|
|
||||||
|
## 执行规则
|
||||||
|
|
||||||
|
- 先看最近对话再回复,不只看当前一句。
|
||||||
|
- 收图阶段客户短句:
|
||||||
|
- 「有吗/有没/有没有/找到了吗/没找到吗」=> 按进度追问承接。
|
||||||
|
- 「就这一个/没有了/先这些/就这些」=> 视为发图完成,进入报价。
|
||||||
|
- 「高清/重新发/发我/?」=> 按跟进催办承接。
|
||||||
|
- 若客户说「上一张/截图/局部/细节/补图」,按同一需求补充处理,不当新单。
|
||||||
|
- 输出 1-2 句,口语化,不官腔。
|
||||||
|
|
||||||
20
skills/pricing-skill/SKILL.md
Normal file
20
skills/pricing-skill/SKILL.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: pricing-skill
|
||||||
|
description: 报价与成交推进技能,约束价格表达、打包优惠和压价应对。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 报价技能
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 快速给明确价格并推动成交,不拖沓。
|
||||||
|
|
||||||
|
## 执行规则
|
||||||
|
|
||||||
|
- 价格用 5 的整数倍表达。
|
||||||
|
- 没图不报价,只引导发图。
|
||||||
|
- 报价后紧跟一句推进成交。
|
||||||
|
- 多图优先给总价或打包价,不逐张拉扯。
|
||||||
|
- 客户压价时先让一次,仍不成交再明确到底线。
|
||||||
|
- 不给价格区间,不输出模糊话术。
|
||||||
|
|
||||||
18
skills/risk-skill/SKILL.md
Normal file
18
skills/risk-skill/SKILL.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: risk-skill
|
||||||
|
description: 风控拒绝技能,覆盖敏感内容拦截、拒绝边界和安全回复约束。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 风控技能
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 对敏感内容快速、稳定、礼貌拒绝,避免前后矛盾。
|
||||||
|
|
||||||
|
## 执行规则
|
||||||
|
|
||||||
|
- 政治/色情/暴力/明显违规内容:直接拒绝,不报价。
|
||||||
|
- 拒绝后若客户追问「能做吗/有吗」,保持一致,不反复改口。
|
||||||
|
- 不输出技术解释,不展开争论。
|
||||||
|
- 句子短、边界清晰、语气克制。
|
||||||
|
|
||||||
19
skills/style-skill/SKILL.md
Normal file
19
skills/style-skill/SKILL.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: style-skill
|
||||||
|
description: 全局语气技能,统一店主口吻,减少模板腔与机械回复。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 风格技能
|
||||||
|
|
||||||
|
## 语气
|
||||||
|
|
||||||
|
- 像真人店主聊天,简短直接,不官腔。
|
||||||
|
- 同意图回复换说法,避免连续复读。
|
||||||
|
- 默认 1-2 句,不堆表情和感叹号。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 不说内部流程、系统状态、模型行为。
|
||||||
|
- 不输出“未理解你的意思”这类机械句,优先结合上下文承接。
|
||||||
|
- 不编造未确认的事实或承诺。
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@ class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
os.environ["FEATURE_BATCH_QUOTE_ENABLED"] = "true"
|
os.environ["FEATURE_BATCH_QUOTE_ENABLED"] = "true"
|
||||||
os.environ["FEATURE_BATCH_QUOTE_PERCENT"] = "100"
|
os.environ["FEATURE_BATCH_QUOTE_PERCENT"] = "100"
|
||||||
os.environ["FEATURE_BATCH_QUOTE_SHOPS"] = ""
|
os.environ["FEATURE_BATCH_QUOTE_SHOPS"] = ""
|
||||||
|
os.environ["AI_DYNAMIC_COLLECTION_REPLIES"] = "false"
|
||||||
|
os.environ["AI_REWRITE_ALL_REPLIES"] = "false"
|
||||||
|
os.environ["BATCH_QUOTE_DELAY_TURNS"] = "0"
|
||||||
|
|
||||||
async def test_collect_images_then_ack(self):
|
async def test_collect_images_then_ack(self):
|
||||||
agent = CustomerServiceAgent()
|
agent = CustomerServiceAgent()
|
||||||
@@ -31,7 +34,7 @@ class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
resp = await agent.process_message(msg)
|
resp = await agent.process_message(msg)
|
||||||
self.assertTrue(resp.should_reply)
|
self.assertTrue(resp.should_reply)
|
||||||
self.assertIn("张", resp.reply)
|
self.assertTrue(resp.reply.strip())
|
||||||
st = agent._get_conversation_state(self.customer_id)
|
st = agent._get_conversation_state(self.customer_id)
|
||||||
self.assertEqual(len(st.pending_image_urls), 2)
|
self.assertEqual(len(st.pending_image_urls), 2)
|
||||||
|
|
||||||
@@ -113,6 +116,78 @@ class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("45", resp.reply)
|
self.assertIn("45", resp.reply)
|
||||||
agent._quote_pending_images.assert_awaited()
|
agent._quote_pending_images.assert_awaited()
|
||||||
|
|
||||||
|
async def test_finish_signal_with_meile_triggers_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="m4b",
|
||||||
|
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_finish_signal_defers_quote_when_delay_enabled(self):
|
||||||
|
os.environ["BATCH_QUOTE_DELAY_TURNS"] = "1"
|
||||||
|
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": "这张20元,确认就开做", "need_transfer": False})
|
||||||
|
|
||||||
|
msg1 = CustomerMessage(
|
||||||
|
msg_id="m4c-1",
|
||||||
|
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="",
|
||||||
|
)
|
||||||
|
resp1 = await agent.process_message(msg1)
|
||||||
|
self.assertTrue(resp1.should_reply)
|
||||||
|
self.assertNotIn("20", resp1.reply)
|
||||||
|
agent._quote_pending_images.assert_not_awaited()
|
||||||
|
|
||||||
|
msg2 = CustomerMessage(
|
||||||
|
msg_id="m4c-2",
|
||||||
|
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="",
|
||||||
|
)
|
||||||
|
resp2 = await agent.process_message(msg2)
|
||||||
|
self.assertTrue(resp2.should_reply)
|
||||||
|
self.assertIn("20", resp2.reply)
|
||||||
|
agent._quote_pending_images.assert_awaited()
|
||||||
|
os.environ["BATCH_QUOTE_DELAY_TURNS"] = "0"
|
||||||
|
|
||||||
async def test_cross_image_composite_intent_triggers_quote(self):
|
async def test_cross_image_composite_intent_triggers_quote(self):
|
||||||
agent = CustomerServiceAgent()
|
agent = CustomerServiceAgent()
|
||||||
st = agent._get_conversation_state(self.customer_id)
|
st = agent._get_conversation_state(self.customer_id)
|
||||||
@@ -139,6 +214,95 @@ class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("50", resp.reply)
|
self.assertIn("50", resp.reply)
|
||||||
agent._quote_pending_images.assert_awaited()
|
agent._quote_pending_images.assert_awaited()
|
||||||
|
|
||||||
|
async def test_result_followup_query_uses_progress_reply(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": "不应触发报价", "need_transfer": False})
|
||||||
|
|
||||||
|
msg = CustomerMessage(
|
||||||
|
msg_id="m5b",
|
||||||
|
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.assertNotIn("不太懂你的意思", resp.reply)
|
||||||
|
self.assertNotIn("没完全理解", resp.reply)
|
||||||
|
self.assertNotIn("没听明白", resp.reply)
|
||||||
|
agent._quote_pending_images.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_short_youma_followup_uses_progress_reply(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": "不应触发报价", "need_transfer": False})
|
||||||
|
|
||||||
|
msg = CustomerMessage(
|
||||||
|
msg_id="m5bb",
|
||||||
|
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.assertNotIn("不太懂你的意思", resp.reply)
|
||||||
|
self.assertNotIn("没完全理解", resp.reply)
|
||||||
|
self.assertNotIn("没听明白", resp.reply)
|
||||||
|
st2 = agent._get_conversation_state(self.customer_id)
|
||||||
|
self.assertEqual(st2.quote_phase, "waiting_result")
|
||||||
|
agent._quote_pending_images.assert_not_awaited()
|
||||||
|
|
||||||
|
def test_short_text_classifier(self):
|
||||||
|
agent = CustomerServiceAgent()
|
||||||
|
self.assertEqual(agent._classify_short_customer_text("有吗"), "progress_query")
|
||||||
|
self.assertEqual(agent._classify_short_customer_text("没有了"), "finish_signal")
|
||||||
|
self.assertEqual(agent._classify_short_customer_text("好的"), "ack")
|
||||||
|
|
||||||
|
async def test_related_screenshot_followup_is_marked(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)
|
||||||
|
|
||||||
|
msg = CustomerMessage(
|
||||||
|
msg_id="m5c",
|
||||||
|
acc_id="test_shop",
|
||||||
|
msg="这是上一张的截图#*#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)
|
||||||
|
st2 = agent._get_conversation_state(self.customer_id)
|
||||||
|
self.assertIn("与上一张相关(截图/局部细节)", st2.pending_requirements)
|
||||||
|
|
||||||
async def test_find_image_not_edit_conflict_triggers_clarification(self):
|
async def test_find_image_not_edit_conflict_triggers_clarification(self):
|
||||||
agent = CustomerServiceAgent()
|
agent = CustomerServiceAgent()
|
||||||
st = agent._get_conversation_state(self.customer_id)
|
st = agent._get_conversation_state(self.customer_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user