feat: enforce activity logs and tighten sizing/map reply policies

This commit is contained in:
2026-03-01 13:01:10 +08:00
parent 0f769607c4
commit 1c1b870d2b
9 changed files with 260 additions and 19 deletions

View File

@@ -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: