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:

View File

@@ -131,6 +131,19 @@ class QingjianAPIClient:
workflow.register_send_callback(self._workflow_send)
workflow.register_agent_notify_callback(self._workflow_agent_notify)
def _activity_log(self, event: str, **kwargs):
"""统一活动日志,便于按 event 检索完整链路。"""
safe = {}
for k, v in kwargs.items():
if isinstance(v, str):
safe[k] = v[:200]
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}")
async def connect(self):
"""连接WebSocket服务器"""
@@ -417,12 +430,19 @@ class QingjianAPIClient:
async def _debounce_agent_reply(self, data: dict):
"""
消息防抖:同一客户在 _DEBOUNCE_SECONDS 内的连续消息合并后再处理。
订单通知、图片URL、付款相关消息不走防抖,立即处理。
订单通知、付款相关消息不走防抖,立即处理。
"""
msg_body = data.get('msg', '')
# 以下情况跳过防抖,立即处理(后台执行,不阻塞接收循环)
immediate_keywords = ["买家已付款", "已付款", "[系统订单信息]"]
if any(kw in msg_body for kw in immediate_keywords) or self._msg_has_image_url(msg_body):
if any(kw in msg_body for kw in immediate_keywords):
self._activity_log(
"debounce_bypass_immediate",
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
reason="payment_or_order",
msg=msg_body,
)
self._fire_and_forget(self._agent_reply_serialized(data))
return
@@ -432,6 +452,12 @@ class QingjianAPIClient:
if key not in self._pending_msgs:
self._pending_msgs[key] = []
self._pending_msgs[key].append(msg_body)
self._activity_log(
"debounce_enqueue",
key=key,
queue_size=len(self._pending_msgs[key]),
msg=msg_body,
)
# 取消上一个等待任务(如果有)
old_task = self._debounce_tasks.get(key)
@@ -451,6 +477,12 @@ class QingjianAPIClient:
else:
merged_msg = "".join(m for m in msgs if m.strip())
print(f"[{self.get_time()}] 防抖合并 {len(msgs)} 条消息: {merged_msg[:60]}")
self._activity_log(
"debounce_flush",
key=capture_key,
merged_count=len(msgs),
merged_msg=merged_msg,
)
merged_data = dict(capture_data)
merged_data['msg'] = merged_msg
await self._agent_reply_serialized(merged_data)
@@ -513,7 +545,11 @@ class QingjianAPIClient:
if intent == "打招呼":
low, high = 1.0, min(3.0, base)
elif intent in ("询价", "砍价"):
low, high = 2.0, min(5.0, base)
# 询价先略等一会,给客户补发图片/需求的窗口,减少机械两连回
low, high = 4.0, min(7.0, max(base, 7.0))
elif intent == "image":
# 文本里直接贴图链接:短等合并上下文,避免和上一条询价并发
low, high = 2.2, 4.2
elif intent in ("修改", "批量"):
low, high = max(3.0, base * 0.65), min(18.0, base + 2.0)
elif intent == "转接":
@@ -825,6 +861,12 @@ class QingjianAPIClient:
return
logger.info("Agent 正在处理消息...")
self._activity_log(
"agent_process_start",
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
msg=msg_text,
)
# 调用 Agent
response = await self.agent.process_message(customer_msg)
@@ -832,6 +874,12 @@ class QingjianAPIClient:
# 检查是否需要转接人工
if response.need_transfer:
logger.info("Agent 决定转接人工")
self._activity_log(
"agent_transfer",
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
transfer_msg=response.transfer_msg,
)
await self.transfer_to_human(data, response.transfer_msg)
# 推送到企微:客户消息+转接回复成对
try:
@@ -881,6 +929,12 @@ class QingjianAPIClient:
# 模拟真人打字延迟,避免瞬间回复太机械
await asyncio.sleep(0.8)
logger.info(f"Agent 回复: {response.reply}")
self._activity_log(
"agent_reply",
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
reply=response.reply,
)
await self.send_reply(data, response.reply)
# 推送到企微:客户消息+AI回复成对
try:
@@ -897,9 +951,20 @@ class QingjianAPIClient:
pass
elif not response.need_transfer:
logger.info("Agent 决定不回复此消息")
self._activity_log(
"agent_no_reply",
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
)
except Exception as e:
logger.error(f"Agent 处理失败: {e}")
self._activity_log(
"agent_process_error",
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
error=str(e),
)
async def _analyze_multi_and_reply(self, data: dict, urls: list):
try:
@@ -1513,6 +1578,12 @@ class QingjianAPIClient:
"""
if not self.websocket:
print(f"[{self.get_time()}] 错误: 未连接到服务器")
self._activity_log(
"send_reply_skipped",
reason="websocket_not_connected",
acc_id=original_msg.get("acc_id", ""),
customer_id=original_msg.get("from_id", ""),
)
return
reply_content = self._colloquialize_outbound_reply(reply_content)
@@ -1531,6 +1602,12 @@ class QingjianAPIClient:
logger.info(
f"外发限流命中,跳过发送 | 客户:{ckey} | cooldown:{cooldown}s | msg:{str(reply_content)[:40]}"
)
self._activity_log(
"send_reply_throttled",
key=ckey,
cooldown_s=cooldown,
msg=str(reply_content),
)
return
self._last_reply_sent_at[ckey] = now_mono
@@ -1554,6 +1631,12 @@ class QingjianAPIClient:
"cy_name": customer_name
}
self._log_outbound_once(original_msg, str(reply_content))
self._activity_log(
"send_reply_attempt",
acc_id=shop_id,
customer_id=customer_id,
msg=str(reply_content),
)
await self.send_message(reply)
def _colloquialize_outbound_reply(self, text: Any) -> Any:
@@ -1652,10 +1735,29 @@ class QingjianAPIClient:
await self.websocket.send(msg_json)
pretty = json.dumps(message, ensure_ascii=False, indent=2)
print(f"[{self.get_time()}] 发送成功:\n{pretty}")
self._activity_log(
"send_message_success",
acc_id=message.get("acc_id", ""),
customer_id=message.get("from_id", ""),
msg_type=message.get("msg_type", 0),
msg=message.get("msg", ""),
)
except Exception as e:
print(f"[{self.get_time()}] 发送失败: {e}")
self._activity_log(
"send_message_error",
acc_id=message.get("acc_id", ""),
customer_id=message.get("from_id", ""),
error=str(e),
)
else:
print(f"[{self.get_time()}] 错误: 连接未打开")
self._activity_log(
"send_message_skipped",
reason="socket_not_open",
acc_id=message.get("acc_id", ""),
customer_id=message.get("from_id", ""),
)
async def auto_reply(self, data):
"""自动回复示例(已弃用,使用 agent_reply 替代)"""

View File

@@ -86,7 +86,7 @@ ANALYSIS_PROMPT = """你是一个电商图片处理评估专家,同时也是 G
high情况下可做改为partial备注写明风险话术谨慎接单
【敏感内容检测 - 必须严格判断!】
- yes含以下任一内容 → 色情/黄色/擦边/裸露/性暗示/大尺度/涉政/暴力/血腥/违禁品
- yes含以下任一内容 → 色情/黄色/擦边/裸露/性暗示/大尺度/涉政/暴力/血腥/违禁品/地图类
敏感内容=yes 时,可做必须填 no直接拒绝不接单
- no无上述敏感内容可以正常接单处理
@@ -257,6 +257,9 @@ class ImageAnalyzer:
"习近平", "毛泽东", "邓小平", "江泽民", "胡锦涛", "李克强", "周恩来",
"中国共产党", "共产党", "中共", "党代会", "两会", "人大", "政协",
"trump", "donald trump", "biden", "putin", "zelensky", "xi jinping",
# 地图类(业务规则:地图一律不接)
"地图", "地形图", "行政区划图", "世界地图", "中国地图", "卫星地图", "导航图", "航海图",
"map", "topographic map", "satellite map", "navigation map",
# 黄暴血腥
"黄色", "擦边", "裸露", "色情", "性暗示", "暴力", "凶杀", "打斗", "枪击", "血腥", "尸体", "虐待",
# 英文兜底
@@ -667,14 +670,14 @@ class ImageAnalyzer:
reason = (reason or "多人脸") + " | 多人脸场景不接单"
price_suggest = 0
# 硬规则3党政/涉黄/暴力/血腥内容不接单
# 硬规则3党政/涉黄/暴力/血腥/地图内容不接单
forbidden_scene = any(k in scene_text for k in self.FORBIDDEN_CONTENT_KEYWORDS)
sensitive_hit = str(sensitive or "").strip().lower() in ("yes", "true", "1", "")
if forbidden_scene or sensitive_hit:
feasibility = "no"
risk = "high"
note = "含政治/党政/涉黄/暴力/血腥等敏感内容,不接单"
reason = (reason or "敏感内容") + " | 敏感内容不接单(政治类一律拒单)"
note = "含政治/党政/涉黄/暴力/血腥/地图等敏感内容,不接单"
reason = (reason or "敏感内容") + " | 敏感内容不接单(政治/地图类一律拒单)"
price_suggest = 0
# 确保是 5 的倍数

View File

@@ -116,6 +116,8 @@ description: 找原图店客服 - 售前咨询、报价成交、售后处理
- 普通服务只发 **jpg**
- 要分层PSD单独 35 元
- 改尺寸:调用 resize_image常用 1920x1080 / 1080x1920 / 2000x2000
- 客户改尺寸时,先判断**图片主体比例**会不会被拉伸变形(不是只看整图宽高比)。
- 若主体会变形,先明确说明需要补图/扩边(如上下补图),再报价。
自然回复,不要固定句式。

View File

@@ -88,6 +88,8 @@ description: 找原图店客服 - 售前咨询、报价成交、售后处理
- 普通服务只发 **jpg**
- 要分层PSD单独 35 元
- 改尺寸:调用 resize_image常用 1920x1080 / 1080x1920 / 2000x2000
- 客户改尺寸时,先判断**图片主体比例**会不会被拉伸变形(不是只看整图宽高比)。
- 若主体会变形,先明确说明需要补图/扩边(如上下补图),再报价。
自然回复,不要固定句式。

View File

@@ -18,5 +18,6 @@ description: 售前接待与收图阶段技能,强调上下文承接、短句
- 「就这一个/没有了/先这些/就这些」=> 视为发图完成,进入报价。
- 「高清/重新发/发我/?」=> 按跟进催办承接。
- 若客户说「上一张/截图/局部/细节/补图」,按同一需求补充处理,不当新单。
- 客户提尺寸/比例需求时,优先看“主体是否会变形”,不是只看整图宽高比。
- 若目标尺寸会拉伸主体,先说明需要补图/扩边,再继续报价推进。
- 输出 1-2 句,口语化,不官腔。

View File

@@ -12,7 +12,7 @@ description: 风控拒绝技能,覆盖敏感内容拦截、拒绝边界和安
## 执行规则
- 政治/色情/暴力/明显违规内容:直接拒绝,不报价。
- 地图类需求(地图/地形图/行政区划图/卫星地图等):直接拒绝,不报价。
- 拒绝后若客户追问「能做吗/有吗」,保持一致,不反复改口。
- 不输出技术解释,不展开争论。
- 句子短、边界清晰、语气克制。

View File

@@ -364,6 +364,27 @@ class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(reply, "图片收到了,你继续发就行。")
async def test_map_inquiry_is_rejected(self):
agent = CustomerServiceAgent()
msg = CustomerMessage(
msg_id="m-map-reject",
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("地图", resp.reply)
self.assertIn("不做", resp.reply)
self.assertTrue(resp.reply.endswith(("", "", "好的", "嗯咯", "嗯啦")))
def tearDown(self):
db.clear_pending_quote_state(self.customer_id)

View File

@@ -34,6 +34,10 @@ _SENSITIVE_PATTERNS = {
"擦边": [
r"擦边", r"大尺度", r"性感图", r"露点", r"半裸",
],
"地图": [
r"地图", r"地形图", r"行政区划图", r"世界地图", r"中国地图",
r"卫星地图", r"导航图", r"航海图", r"作战地图", r"军事地图",
],
}
_COMPILED: dict = {}