feat: enforce activity logs and tighten sizing/map reply policies
This commit is contained in:
@@ -12,10 +12,11 @@ import random
|
||||
import hashlib
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any, Tuple
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
@@ -30,6 +31,7 @@ from core.workflow_router import get_workflow_router
|
||||
|
||||
# ========== 企业微信通知 ==========
|
||||
_WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def _notify_wechat(content: str, tag: str = "通知"):
|
||||
@@ -64,6 +66,21 @@ async def _notify_wechat_overdue():
|
||||
# ========== 转接常量 ==========
|
||||
TRANSFER_MESSAGE = "话术|[转移会话],分组20252916034,无原因"
|
||||
CASE_LIBRARY_LINK = "https://www.yuque.com/zuowei-dfvpq/kge0in/mynala0g35b8cec5"
|
||||
TAOBAO_REPLY_TAILS = ("嗯", "哦", "好的", "嗯咯", "嗯啦")
|
||||
|
||||
|
||||
def _ensure_taobao_reply_tail(text: str) -> str:
|
||||
"""淘宝口吻收尾:最终回复结尾带简短口语尾词。"""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return ""
|
||||
transfer_keywords = ("TRANSFER_REQUESTED", "[转移会话]", "转移会话")
|
||||
if any(k in t for k in transfer_keywords):
|
||||
return t
|
||||
trimmed = t.rstrip(" \t\r\n。.!!?~~")
|
||||
if any(trimmed.endswith(tail) for tail in TAOBAO_REPLY_TAILS):
|
||||
return trimmed
|
||||
return f"{trimmed} 好的"
|
||||
|
||||
|
||||
# ========== 数据模型 ==========
|
||||
@@ -117,6 +134,12 @@ class AgentResponse(BaseModel):
|
||||
need_transfer: bool = False # 是否需要转人工
|
||||
transfer_msg: str = "" # 转接消息
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _ensure_reply_tail(self):
|
||||
if self.should_reply and self.reply:
|
||||
self.reply = _ensure_taobao_reply_tail(self.reply)
|
||||
return self
|
||||
|
||||
|
||||
def _get_shop_type(acc_id: str = "", goods_name: str = "") -> str:
|
||||
"""根据 acc_id 或 goods_name 判断店铺类型,返回 gemini_api / find_image / default"""
|
||||
@@ -173,6 +196,19 @@ class CustomerServiceAgent:
|
||||
C_MUTED = "\033[90m" # gray
|
||||
_DEFAULT_EVOLUTION_CANDIDATE = Path("config") / "evolution_candidate.json"
|
||||
|
||||
@staticmethod
|
||||
def _activity_log(event: str, **kwargs):
|
||||
safe = {}
|
||||
for k, v in kwargs.items():
|
||||
if isinstance(v, str):
|
||||
safe[k] = v[:240]
|
||||
else:
|
||||
safe[k] = v
|
||||
try:
|
||||
logger.info(f"[ACTIVITY] event={event} data={json.dumps(safe, ensure_ascii=False)}")
|
||||
except Exception:
|
||||
logger.info(f"[ACTIVITY] event={event} data={safe}")
|
||||
|
||||
def __init__(self, skills_dir: str = "skills"):
|
||||
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||
self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
@@ -1330,7 +1366,7 @@ class CustomerServiceAgent:
|
||||
def _get_risk_prompt(self) -> str:
|
||||
base = """你是淘宝客服的风控助手,负责敏感/违规内容的前置拦截与替代话术。
|
||||
规则:
|
||||
- 黄色/擦边/涉政/政治人物/政治事件/政治图片等不接单,礼貌拒绝
|
||||
- 黄色/擦边/涉政/政治人物/政治事件/政治图片/地图类内容等不接单,礼貌拒绝
|
||||
- 输出不超过1句话"""
|
||||
return self._attach_skill_docs(base, self.skill_risk, self.skill_style)
|
||||
|
||||
@@ -1353,6 +1389,19 @@ class CustomerServiceAgent:
|
||||
# 兜底:类似“有没有十大元帅的照片/图片”
|
||||
return bool(re.search(r"(元帅|将军|领导人|政治人物|政治事件).*(照片|图片|头像|原图)?", s))
|
||||
|
||||
@staticmethod
|
||||
def _is_map_inquiry(text: str) -> bool:
|
||||
"""地图类需求一律拒绝(按业务规则)。"""
|
||||
s = (text or "").strip().lower()
|
||||
if not s:
|
||||
return False
|
||||
kw = (
|
||||
"地图", "地形图", "行政区划图", "世界地图", "中国地图",
|
||||
"卫星地图", "导航图", "航海图", "作战地图", "军事地图",
|
||||
"map", "topographic map", "satellite map",
|
||||
)
|
||||
return any(k in s for k in kw)
|
||||
|
||||
def _get_customer_profile_context(self, customer_id: str) -> str:
|
||||
"""从数据库读取客户画像,注入给 AI。含个性化语气、报价策略、主动预测、近期对话。"""
|
||||
try:
|
||||
@@ -1601,6 +1650,13 @@ class CustomerServiceAgent:
|
||||
|
||||
async def process_message(self, message: CustomerMessage) -> AgentResponse:
|
||||
"""处理客户消息并生成回复"""
|
||||
self._activity_log(
|
||||
"agent_inbound",
|
||||
acc_id=message.acc_id,
|
||||
customer_id=message.from_id,
|
||||
msg=message.msg,
|
||||
msg_type=message.msg_type,
|
||||
)
|
||||
metrics_emit("inbound_msg", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
# 获取或创建对话状态
|
||||
state = self._get_conversation_state(message.from_id)
|
||||
@@ -1609,38 +1665,63 @@ class CustomerServiceAgent:
|
||||
if self._in_cooldown(state, message.msg):
|
||||
elapsed = int((datetime.now() - state.last_reply_at).total_seconds())
|
||||
print(f"[Agent] 冷却期静默(距上次回复 {elapsed}s):{message.msg!r}")
|
||||
self._activity_log("agent_cooldown_silent", customer_id=message.from_id, elapsed_s=elapsed)
|
||||
return AgentResponse(reply="", should_reply=False, need_transfer=False)
|
||||
|
||||
# 前置风控:客户文本一旦命中政治/敏感询问,直接拒绝,避免“发图我看看”类答非所问
|
||||
try:
|
||||
from utils.content_filter import should_block_customer
|
||||
if should_block_customer(message.msg) or self._is_political_inquiry(message.msg):
|
||||
map_hit = self._is_map_inquiry(message.msg)
|
||||
political_hit = self._is_political_inquiry(message.msg)
|
||||
if should_block_customer(message.msg) or political_hit or map_hit:
|
||||
# 命中敏感询问时清空待报价队列,避免旧图残留污染后续会话
|
||||
state.pending_image_urls.clear()
|
||||
state.pending_requirements.clear()
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
reject_text = "地图这类不做哈,这边不接地图相关需求。"
|
||||
if political_hit and not map_hit:
|
||||
reject_text = "这类不做哈,政治相关图片和人物都不接。"
|
||||
reply = await self._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply="这类不做哈,政治相关图片和人物都不接。",
|
||||
reply=reject_text,
|
||||
scene="risk_reject",
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply}")
|
||||
self._activity_log(
|
||||
"agent_risk_reject",
|
||||
customer_id=message.from_id,
|
||||
map_hit=map_hit,
|
||||
political_hit=political_hit,
|
||||
reply=reply,
|
||||
)
|
||||
return AgentResponse(reply=reply, should_reply=True, need_transfer=False)
|
||||
except Exception:
|
||||
if self._is_political_inquiry(message.msg):
|
||||
map_hit = self._is_map_inquiry(message.msg)
|
||||
political_hit = self._is_political_inquiry(message.msg)
|
||||
if political_hit or map_hit:
|
||||
state.pending_image_urls.clear()
|
||||
state.pending_requirements.clear()
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
reject_text = "地图这类不做哈,这边不接地图相关需求。"
|
||||
if political_hit and not map_hit:
|
||||
reject_text = "这类不做哈,政治相关图片和人物都不接。"
|
||||
reply = await self._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply="这类不做哈,政治相关图片和人物都不接。",
|
||||
reply=reject_text,
|
||||
scene="risk_reject",
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply}")
|
||||
self._activity_log(
|
||||
"agent_risk_reject",
|
||||
customer_id=message.from_id,
|
||||
map_hit=map_hit,
|
||||
political_hit=political_hit,
|
||||
reply=reply,
|
||||
)
|
||||
return AgentResponse(reply=reply, should_reply=True, need_transfer=False)
|
||||
|
||||
# 检测售前/售后
|
||||
@@ -1932,9 +2013,10 @@ class CustomerServiceAgent:
|
||||
"天安门", "政治人物", "政治事件", "领导人", "党政",
|
||||
"习近平", "毛泽东", "邓小平", "江泽民", "胡锦涛",
|
||||
"特朗普", "拜登", "普京", "泽连斯基",
|
||||
"地图", "地形图", "行政区划图", "卫星地图",
|
||||
]
|
||||
target_agent = self.agent_after_sale if state.stage == "售后" else self.agent
|
||||
risk_hit = any(k in msg_lower for k in risk_kw) or self._is_political_inquiry(message.msg)
|
||||
risk_hit = any(k in msg_lower for k in risk_kw) or self._is_political_inquiry(message.msg) or self._is_map_inquiry(message.msg)
|
||||
if risk_hit:
|
||||
target_agent = self.agent_risk
|
||||
elif any(k in message.msg for k in order_markers):
|
||||
@@ -2011,6 +2093,7 @@ class CustomerServiceAgent:
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
print(f"[Agent] AI 调用失败: {e},使用兜底回复")
|
||||
self._activity_log("agent_ai_error", customer_id=message.from_id, acc_id=message.acc_id, error=err_str)
|
||||
metrics_emit("ai_call_failed", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
if "AccountOverdueError" in err_str or "overdue" in err_str.lower():
|
||||
asyncio.create_task(_notify_wechat_overdue())
|
||||
@@ -2115,6 +2198,14 @@ class CustomerServiceAgent:
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
||||
else:
|
||||
print(f"{self.C_MUTED}[REPLY->CUSTOMER]{self.C_RESET} <静默/不发送>")
|
||||
self._activity_log(
|
||||
"agent_outbound_decision",
|
||||
customer_id=message.from_id,
|
||||
should_reply=should_reply,
|
||||
need_transfer=need_transfer,
|
||||
reply=reply_text or "",
|
||||
transfer_msg=transfer_msg,
|
||||
)
|
||||
|
||||
return AgentResponse(reply=reply_text or "", should_reply=should_reply, need_transfer=need_transfer, transfer_msg=transfer_msg)
|
||||
|
||||
@@ -3179,6 +3270,11 @@ class CustomerServiceAgent:
|
||||
prompt += f"\n客户说:{customer_text}\n"
|
||||
image_url = self._extract_image_url(customer_text)
|
||||
price_keywords = ["多少钱", "多少", "价格", "几块", "怎么收费", "报个价"]
|
||||
size_keywords = [
|
||||
"尺寸", "比例", "宽", "高", "米", "厘米", "mm", "cm",
|
||||
"横版", "竖版", "2米", "3米", "改成", "做成",
|
||||
]
|
||||
has_size_change = any(kw in customer_text.lower() for kw in [k.lower() for k in size_keywords])
|
||||
|
||||
# gemini_api 店铺:不触发找图流程,按 API 客服回复
|
||||
if shop_type == "gemini_api":
|
||||
@@ -3190,7 +3286,17 @@ class CustomerServiceAgent:
|
||||
if last_url:
|
||||
prompt += "\n客户在询问价格:若客户已确认发完,则给总报价;若还在发图,先引导发完后统一报价。"
|
||||
else:
|
||||
prompt += "\n客户在询问价格但未发图,回复「发图来我看看」。"
|
||||
prompt += "\n客户在询问价格但未发图:先简短承接(如“在看呢/收到”),不要机械连发;再自然引导对方发图。"
|
||||
if has_size_change:
|
||||
prompt += (
|
||||
"\n⚠️ 尺寸改动场景:优先判断图片主体是否会被拉伸变形,"
|
||||
"不是只看整张图宽高比。若会变形,要先提示“需要补图/扩边”,再给报价。"
|
||||
)
|
||||
elif has_size_change:
|
||||
prompt += (
|
||||
"\n客户在改尺寸/改比例:先按主体比例判断是否会变形,"
|
||||
"不是只看整图比例。若目标尺寸会拉伸主体,先明确说明要补图(如上下补图/扩边)再报价。"
|
||||
)
|
||||
elif any(kw in customer_text for kw in progress_keywords):
|
||||
# 客户问进度/催单,必须先核查付款状态
|
||||
if order_unpaid:
|
||||
|
||||
Reference in New Issue
Block a user