Files
tw/core/pydantic_ai_agent.py

1067 lines
43 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PydanticAI Agent 模块
架构:单 Agent + 多 Tool 模式
- Agent 负责对话逻辑和决策
- Tool 负责具体能力:看图/查客户/转接
- AI 自主决定何时调用哪个工具,时序自然,不需要外部协调
"""
import os
import glob
import asyncio
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, model_validator
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
from dotenv import load_dotenv
from utils.metrics_tracker import emit as metrics_emit
from utils.observability import emit_activity
from core.quote_state_machine import QuoteStateMachine
from services.risk_service import RiskService
from core.agent_pre_rules import AgentPreRuleService
from core.order_helpers import parse_order_info, order_instruction as build_order_instruction
from core.collection_intent_helpers import (
append_requirement,
build_collect_ack,
build_collect_progress_reply,
build_collect_remind,
build_find_image_clarify_reply,
build_not_understood_reply,
classify_short_customer_text,
is_batch_finish_intent,
is_batch_finish_signal,
is_cross_image_composite_intent,
is_find_image_not_edit_conflict,
is_related_image_followup_intent,
is_result_followup_query,
needs_clarification_in_collecting,
)
from core.agent_prompts import (
build_after_sale_prompt,
build_natural_reply_prompt,
build_order_prompt,
build_pricing_prompt,
build_processing_prompt,
build_risk_prompt,
build_similar_prompt,
build_system_prompt,
)
from core.risk_text_helpers import is_map_inquiry, is_political_inquiry
from core.context_helpers import (
calc_avg_complexity,
get_conversation_context,
get_customer_profile_context,
get_intent_emotion_hint,
get_refusal_context_hint,
)
from core.batch_quote_helpers import (
assess_batch_risk,
build_batch_pricing_plan,
build_batch_quote_reply,
calc_requirement_surcharge,
prepare_batch_intake,
)
from core.prompt_builder import build_prompt as build_agent_prompt, split_customer_text
from core.image_workflow_router import handle_image_workflow as route_image_workflow
from core.message_orchestrator import process_incoming_message
from core.conversation_state_store import (
get_conversation_state as load_conversation_state,
mark_quote_ready as state_mark_quote_ready,
refresh_quote_phase as state_refresh_quote_phase,
should_defer_batch_quote as state_should_defer_batch_quote,
sync_pending_quote_state as state_sync_pending_quote_state,
restore_pending_quote_state as state_restore_pending_quote_state,
cleanup_inactive as state_cleanup_inactive,
)
load_dotenv()
from services.service_tuhui_upload import upload_to_tuhui
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 = "通知"):
"""发送企业微信 markdown 通知,任何异常都发"""
if not _WECHAT_WEBHOOK:
logger.info("[%s] 未配置 WECHAT_WEBHOOK跳过推送", tag)
return
try:
import httpx
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(_WECHAT_WEBHOOK, json={
"msgtype": "markdown",
"markdown": {"content": content}
})
data = resp.json()
if data.get("errcode") == 0:
logger.info("[%s] 企业微信推送成功", tag)
else:
logger.warning("[%s] 企业微信推送失败: %s", tag, data)
except Exception as e:
logger.exception("[%s] 企业微信发送异常: %s", tag, e)
async def _notify_wechat_overdue():
"""API 欠费时发企业微信通知"""
await _notify_wechat(
"⚠️ **火山引擎 API 欠费**客服AI已停止响应请立即充值\n"
"地址https://console.volcengine.com/ark"
)
# ========== 转接常量 ==========
TRANSFER_MESSAGE = "话术|[转移会话],分组20252916034,无原因"
CASE_LIBRARY_LINK = "https://www.yuque.com/zuowei-dfvpq/kge0in/mynala0g35b8cec5"
TAOBAO_REPLY_TAILS = ("", "", "好的", "嗯咯", "嗯啦")
def _is_ack_like_customer_text(text: str) -> bool:
"""客户是否为确认型短句(好的/嗯/收到/ok 等)。"""
s = (text or "").strip().lower()
if not s:
return False
s = s.rstrip("。.!~")
ack_set = {
"", "好的", "", "嗯嗯", "收到", "知道了", "明白了",
"ok", "okay", "", "可以", "好嘞", "好的呢",
}
return s in ack_set
def _is_meaningless_short_text(text: str) -> bool:
"""识别无意义短句:仅需简短承接,不进入复杂流程。"""
s = (text or "").strip().lower().rstrip("。.!~")
if not s:
return False
meaningless = {
"", "好的", "", "嗯嗯", "", "哦哦", "收到", "知道了", "明白了",
"ok", "okay", "", "可以", "好嘞", "好的呢", "在吗", "有人吗", "在不在",
}
return s in meaningless
# ========== 数据模型 ==========
class CustomerMessage(BaseModel):
"""客户消息模型"""
msg_id: str
acc_id: str
msg: str
from_id: str
from_name: str
cy_id: str
acc_type: str
msg_type: int
cy_name: str
goods_name: Optional[str] = None
goods_order: Optional[str] = None
class ConversationState(BaseModel):
"""对话状态"""
customer_id: str
stage: str = "售前" # 售前/售后
last_price: Optional[int] = None # 最后报价
last_min_price: Optional[int] = None # 最近图片的最低价
last_order_id: Optional[str] = None # 订单号
order_status: Optional[str] = None # 订单状态
discount_count: int = 0 # 让价次数
image_count: int = 0 # 图片数量
pending_image_urls: 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_reply_at: Optional[datetime] = None # 最后一次回复客户的时间
class AgentDeps(BaseModel):
"""Agent 依赖项 - 用于传递上下文"""
msg_id: str
acc_id: str
from_id: str
platform: str
class AgentResponse(BaseModel):
"""Agent 回复模型"""
reply: str
should_reply: bool = True
need_transfer: bool = False # 是否需要转人工
transfer_msg: str = "" # 转接消息
@model_validator(mode="after")
def _ensure_reply_tail(self):
# 统一在 process_message 中按客户输入决定是否补口语尾词
return self
def _get_shop_type(acc_id: str = "", goods_name: str = "") -> str:
"""根据 acc_id 或 goods_name 判断店铺类型,返回 gemini_api / find_image / default"""
try:
from config.config import CONFIG_DIR
import json
cfg_path = CONFIG_DIR / "shop_prompts.json"
if not cfg_path.exists():
return "find_image"
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
shops = cfg.get("shops", {})
goods_kw = cfg.get("goods_keywords", {})
type_hints = cfg.get("type_hints", {})
# 优先按 acc_id
if acc_id and acc_id in shops:
return shops[acc_id].get("type", "find_image")
# 按商品名关键词
goods_lower = (goods_name or "").lower()
for kw, stype in goods_kw.items():
if kw in goods_lower:
return stype
except Exception:
pass
return "find_image"
def _get_shop_persona(acc_id: str = "", goods_name: str = "") -> str:
"""按店铺返回人设描述优先级shops.persona > type_personas > default_persona。"""
default_persona = "淘宝老店主,说话自然,像真人微信聊天,不官腔、不背模板。"
try:
from config.config import CONFIG_DIR
import json
cfg_path = CONFIG_DIR / "shop_prompts.json"
if not cfg_path.exists():
return default_persona
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
shops = cfg.get("shops", {})
if acc_id and acc_id in shops:
persona = str(shops[acc_id].get("persona", "")).strip()
if persona:
return persona
shop_type = _get_shop_type(acc_id, goods_name)
type_personas = cfg.get("type_personas", {})
persona = str(type_personas.get(shop_type, "")).strip()
if persona:
return persona
cfg_default = str(cfg.get("default_persona", "")).strip()
return cfg_default or default_persona
except Exception:
return default_persona
def load_skill_map(skills_dir: str = "skills") -> Dict[str, str]:
"""按技能目录名加载 SKILL.md返回 {skill_name: content}。"""
skill_map: Dict[str, str] = {}
skill_files = glob.glob(os.path.join(skills_dir, "**/SKILL.md"), recursive=True)
for skill_file in skill_files:
try:
content = Path(skill_file).read_text(encoding="utf-8")
skill_name = Path(skill_file).parent.name.strip().lower()
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:
logger.warning("读取技能文件失败: %s | err=%s", skill_file, e)
return skill_map
class CustomerServiceAgent:
"""客服 Agent - 支持 SKILL.md + 工作流"""
C_RESET = "\033[0m"
C_PROMPT = "\033[96m" # cyan
C_THINK = "\033[95m" # magenta
C_TOOL = "\033[93m" # yellow
C_REPLY = "\033[92m" # green
C_MUTED = "\033[90m" # gray
_DEFAULT_EVOLUTION_CANDIDATE = Path("config") / "evolution_candidate.json"
@staticmethod
def _activity_log(event: str, **kwargs):
emit_activity(
logger,
event=event,
trace_id=str(kwargs.pop("trace_id", "")),
customer_id=str(kwargs.pop("customer_id", "")),
result=str(kwargs.pop("result", "ok")),
**kwargs,
)
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")
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
self.quote_state_machine = QuoteStateMachine(delay_turns=self.batch_quote_delay_turns)
self.risk_service = RiskService()
self.pre_rule_service = AgentPreRuleService(self, self.risk_service)
if not self.api_key:
raise ValueError("请设置 OPENAI_API_KEY 环境变量")
# 对话状态管理
self.conversations: Dict[str, ConversationState] = {}
self.ConversationStateClass = ConversationState
# 多轮对话历史PydanticAI ModelMessage 列表按客户ID存储
self.message_histories: Dict[str, list] = {}
self.evolution_candidate = self._load_evolution_candidate()
# 加载技能并按角色拆分,避免所有 Agent 吃同一份大杂烩提示词
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 模型
model = OpenAIChatModel(
model_name=self.model_name,
provider=OpenAIProvider(
api_key=self.api_key,
base_url=self.base_url
)
)
try:
from config.config import MIN_PRICE_FLOOR
min_price_floor = MIN_PRICE_FLOOR
except Exception:
min_price_floor = 15
self.agent = Agent(
model=model,
deps_type=AgentDeps,
system_prompt=build_system_prompt(self.reply_persona, self.skill_pre_sales, self.skill_style),
)
self.agent_after_sale = Agent(
model=model,
deps_type=AgentDeps,
system_prompt=build_after_sale_prompt(self.skill_after_sale, self.skill_style),
)
self.agent_pricing = Agent(
model=model,
deps_type=AgentDeps,
system_prompt=build_pricing_prompt(
min_price_floor=min_price_floor,
case_library_link=CASE_LIBRARY_LINK,
skill_pricing=self.skill_pricing,
skill_style=self.skill_style,
),
)
self.agent_processing = Agent(
model=model,
deps_type=AgentDeps,
system_prompt=build_processing_prompt(self.skill_after_sale, self.skill_style),
)
self.agent_similar = Agent(
model=model,
deps_type=AgentDeps,
system_prompt=build_similar_prompt(self.skill_pre_sales, self.skill_style),
)
self.agent_natural_reply = Agent(
model=model,
deps_type=AgentDeps,
system_prompt=build_natural_reply_prompt(self.reply_persona, self.skill_style),
)
# 工作流程路由器
self.workflow_router = get_workflow_router()
self.agent_order = Agent(
model=model,
deps_type=AgentDeps,
system_prompt=build_order_prompt(self.skill_after_sale, self.skill_style),
)
self.agent_risk = Agent(
model=model,
deps_type=AgentDeps,
system_prompt=build_risk_prompt(self.skill_risk, self.skill_style),
)
# 注册工具
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)
def _load_evolution_candidate(self) -> Dict[str, Any]:
"""读取自我进化候选配置(灰度策略),读取失败时返回空。"""
try:
path = Path(os.getenv("EVOLUTION_CANDIDATE_PATH", str(self._DEFAULT_EVOLUTION_CANDIDATE)))
if not path.exists():
return {}
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return {}
return data
except Exception:
return {}
def _evolution_gray_percent(self) -> int:
"""灰度比例,默认 5%"""
try:
env_pct = os.getenv("EVOLUTION_GRAY_PERCENT", "").strip()
if env_pct:
pct = int(float(env_pct))
else:
pct = int(((self.evolution_candidate or {}).get("gray_percent", 5)))
return max(0, min(100, pct))
except Exception:
return 5
def _evolution_enabled_for_customer(self, customer_id: str) -> bool:
"""按客户哈希稳定灰度命中,命中后启用候选策略。"""
cand = self.evolution_candidate or {}
if str(cand.get("status", "")).strip() != "ready_for_gray_5_percent":
return False
if not customer_id:
return False
pct = self._evolution_gray_percent()
if pct <= 0:
return False
digest = hashlib.md5(customer_id.encode("utf-8")).hexdigest()
bucket = int(digest[:8], 16) % 100
hit = bucket < pct
if hit:
metrics_emit("evolution_gray_hit", customer_id=customer_id, percent=pct, version=str(cand.get("version", "")))
return hit
def _evolution_has_proposal(self, proposal_id: str) -> bool:
cand = self.evolution_candidate or {}
for p in cand.get("proposals", []) or []:
if str((p or {}).get("id", "")).strip() == proposal_id:
return True
return False
@staticmethod
def _is_service_risk_inquiry(text: str) -> bool:
"""识别退款/投诉等服务风险场景。"""
s = (text or "").strip().lower()
if not s:
return False
kw = ("退款", "退货", "投诉", "差评", "举报", "欺骗", "骗人", "起诉", "法院", "生气", "不满意")
return any(k in s for k in kw)
@staticmethod
def _log_block(title: str, content: str):
"""统一的控制台分层日志输出。"""
logger.info("[%s]\n%s\n--------------------", title, content)
@staticmethod
def _normalize_reply_text(text: Optional[str]) -> str:
"""清洗模型输出,避免把占位词直接发给客户。"""
if text is None:
return ""
cleaned = str(text).strip()
if cleaned.lower() in {"", "none", "null", "n/a"}:
return ""
return cleaned
@staticmethod
def _colloquialize_reply(text: str) -> str:
"""把常见机械表达柔化为更口语的客服话术。"""
t = (text or "").strip()
if not t:
return t
repl = {
"确认我就安排": "你点头我就开做",
"可以的话我马上安排": "可以我就马上给你做",
"我这边马上安排": "我马上安排",
"立刻统一报价": "马上给你报价",
"统一报价": "一起给你报价",
"": "",
"请您": "",
"可选A": "可选:",
"流程完成": "已经安排好了",
}
for k, v in repl.items():
t = t.replace(k, v)
return t
async def _render_collection_reply_with_ai(
self,
*,
message: CustomerMessage,
state: ConversationState,
scene: str,
intent_hint: str,
fallback: str,
) -> str:
"""
收图阶段回复默认走 AI 改写,失败时回退到固定模板。
"""
first_image_ack = "收到,我先看一下哈,稍等哈。"
if scene == "collect_ack" and len(state.pending_image_urls) == 1:
fallback = first_image_ack
if not self.dynamic_collection_replies:
return fallback
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 ""
persona = _get_shop_persona(message.acc_id or "", message.goods_name or "")
user_prompt = (
"请按下面意图生成给客户的自然回复。\n"
f"场景: {scene}\n"
f"店铺人设: {persona}\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
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 ""
persona = _get_shop_persona(message.acc_id or "", message.goods_name or "")
prompt = (
"请把下面这句客服回复润色成更自然的微信聊天口吻,语义必须保持一致。\n"
f"场景: {scene}\n"
f"店铺人设: {persona}\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
def _register_tools(self):
"""注册所有 Tool让 Agent 可以主动调用"""
from core.agent_tools import register_tools
register_tools(self)
# 对话状态超过多少小时后重置(避免昨天的售后状态影响今天)
CONVERSATION_TIMEOUT_HOURS = 12
def _get_conversation_state(self, customer_id: str) -> ConversationState:
return load_conversation_state(self, customer_id)
def _cleanup_inactive(self, now: datetime):
state_cleanup_inactive(self.conversations, self.message_histories, now)
def _sync_pending_quote_state(self, customer_id: str, state: ConversationState):
state_sync_pending_quote_state(self, customer_id, state)
def _restore_pending_quote_state(self, customer_id: str, state: ConversationState):
state_restore_pending_quote_state(customer_id, state)
@staticmethod
def _refresh_quote_phase(state: ConversationState, phase_hint: str = ""):
state_refresh_quote_phase(state, phase_hint=phase_hint)
def _should_defer_batch_quote(self, state: ConversationState, mark_ready: bool = False) -> bool:
return state_should_defer_batch_quote(self, state, mark_ready=mark_ready)
def _mark_quote_ready(self, state: ConversationState):
state_mark_quote_ready(self, state)
def _build_reject_message(self, reason: str = "") -> str:
templates = [
"这类图文字内容太密了,我们这边不接这单哈,建议精简后再发我看看。",
"这种密集文字/宣传栏类图片暂时做不了,抱歉啦,换一版简化内容我可以继续帮你看。",
"这张文字信息太多,处理风险高,我们先不接,您可以先筛重点文字再发我。",
]
msg = random.choice(templates)
if reason:
msg += f"{reason}"
return msg
def _is_batch_quote_enabled(self, customer_id: str, acc_id: str) -> bool:
"""灰度开关:按店铺白名单 + 客户哈希百分比控制新策略是否生效。"""
try:
from config.config import (
FEATURE_BATCH_QUOTE_ENABLED,
FEATURE_BATCH_QUOTE_PERCENT,
FEATURE_BATCH_QUOTE_SHOPS,
)
if not FEATURE_BATCH_QUOTE_ENABLED:
return False
pct = max(0, min(100, int(FEATURE_BATCH_QUOTE_PERCENT)))
if pct == 0:
return False
shops = [s.strip() for s in (FEATURE_BATCH_QUOTE_SHOPS or "").split(",") if s.strip()]
if shops and (acc_id or "") not in shops:
return False
if pct >= 100:
return True
h = int(hashlib.md5((customer_id or "").encode("utf-8")).hexdigest()[:8], 16) % 100
return h < pct
except Exception:
return True
def _detect_stage(self, message: str) -> str:
"""检测售前/售后"""
# 系统订单通知不属于售后,单独处理
if "系统订单信息" in message:
return "订单通知"
after_sale_keywords = ["已下单", "已付款", "催一下", "发文件", "要修改", "不满意", "退款", "退货"]
for keyword in after_sale_keywords:
if keyword in message:
return "售后"
return "售前"
_is_political_inquiry = staticmethod(is_political_inquiry)
_is_map_inquiry = staticmethod(is_map_inquiry)
_get_shop_type = staticmethod(_get_shop_type)
_get_shop_persona = staticmethod(_get_shop_persona)
_notify_wechat = staticmethod(_notify_wechat)
_notify_wechat_overdue = staticmethod(_notify_wechat_overdue)
_calc_avg_complexity = staticmethod(calc_avg_complexity)
_get_conversation_context = staticmethod(get_conversation_context)
_get_intent_emotion_hint = staticmethod(get_intent_emotion_hint)
def _get_customer_profile_context(self, customer_id: str) -> str:
return get_customer_profile_context(self, customer_id)
def _get_refusal_context_hint(self, customer_id: str, current_msg: str, profile_context: str) -> str:
return get_refusal_context_hint(self, customer_id, current_msg, profile_context)
# 简单打招呼类消息(在近期已回复后无需再回)
_COOLDOWN_PATTERNS = [
"你好", "您好", "在吗", "在么", "在不在", "有人吗",
"", "嗯嗯", "", "好的", "好哒", "ok", "OK", "okay",
"谢谢", "谢谢你", "感谢", "收到", "知道了", "明白了",
]
_COOLDOWN_SECONDS = 5 * 60 # 5 分钟内不重复回复纯打招呼
def _in_cooldown(self, state: ConversationState, msg: str) -> bool:
"""最近刚回复过 + 消息是纯打招呼 → True 静默"""
if not state.last_reply_at:
return False
elapsed = (datetime.now() - state.last_reply_at).total_seconds()
if elapsed > self._COOLDOWN_SECONDS:
return False
clean = msg.strip().rstrip("!?。.~")
return clean in self._COOLDOWN_PATTERNS
def _should_handle_as_meaningless_short_text(self, state: ConversationState, msg: str) -> bool:
"""
无意义短句仅在“非业务处理中”生效,避免误拦截真实推进消息。
例如:已在收图/待报价阶段时,客户发“好的/在吗”不应直接 ping。
"""
customer_text, _ = self._split_customer_text(msg or "")
text = (customer_text or "").strip()
if not _is_meaningless_short_text(text):
return False
if self._extract_image_urls(text):
return False
if (getattr(state, "pending_image_urls", None) or []):
return False
if getattr(state, "quote_phase", "idle") in {"collecting", "ready_to_quote", "waiting_result"}:
return False
return True
async def build_auto_quote_reply(self, state: ConversationState, message: CustomerMessage) -> AgentResponse:
"""
自动报价内部入口:不走 process_message避免伪造客户语句污染上下文。
"""
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()
return AgentResponse(
reply=reply_text,
should_reply=not need_transfer,
need_transfer=need_transfer,
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
)
async def process_message(self, message: CustomerMessage) -> AgentResponse:
"""处理客户消息并生成回复。"""
return await process_incoming_message(self, message)
async def _check_order_amount(self, customer_id: str, order: dict, acc_id: str):
"""核查订单实付金额是否与报价一致,异常时企业微信预警"""
try:
import re
from db.customer_db import db
profile = db.get_customer(customer_id)
quoted = profile.last_price # 上次报价(元)
if not quoted:
return
# 从订单解析实付金额
raw_amount = order.get("amount", "")
m = re.search(r'[\d.]+', str(raw_amount))
if not m:
return
paid = float(m.group())
logger.info("[Agent] 订单金额核查:报价 %s元 vs 实付 %s元(客户 %s", quoted, paid, customer_id)
# 实付金额明显低于报价(低于报价的 60%)才预警
if paid < quoted * 0.6:
msg = (
f"⚠️ **订单金额异常**\n"
f"店铺:{acc_id}\n"
f"客户:{customer_id}{profile.name or ''}\n"
f"报价:{quoted}\n"
f"实付:{paid}\n"
f"差额:{quoted - paid:.1f}元 — 请人工核查"
)
logger.warning("[Agent] %s", msg)
await self._notify_wechat(msg)
except Exception as e:
logger.exception("[Agent] 订单金额核查失败: %s", e)
def _extract_image_url(self, msg: str) -> str:
"""从消息中提取图片URL兼容纯URL和 text#*#url 两种格式"""
urls = self._extract_image_urls(msg)
return urls[0] if urls else ""
def _extract_image_urls(self, msg: str) -> List[str]:
"""提取消息中的所有图片URL去重保序"""
import re
if not msg:
return []
image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com", "suning.com")
candidates = re.findall(r'https?://[^\s#]+', msg)
urls: List[str] = []
for u in candidates:
low = u.lower()
if any(ext in low for ext in image_exts) or any(h in low for h in image_hosts):
if u not in urls:
urls.append(u)
return urls
def _strip_urls_from_text(self, msg: str) -> str:
"""去掉 URL 后的纯文本,用于提取额外需求。"""
import re
if not msg:
return ""
text = re.sub(r'https?://\S+', ' ', msg)
text = text.replace("#*#", " ").strip()
text = re.sub(r'\s+', ' ', text)
return text.strip(",。.!?;: ")
_is_batch_finish_signal = staticmethod(is_batch_finish_signal)
_is_batch_finish_intent = staticmethod(is_batch_finish_intent)
_is_cross_image_composite_intent = staticmethod(is_cross_image_composite_intent)
_is_related_image_followup_intent = staticmethod(is_related_image_followup_intent)
_is_result_followup_query = staticmethod(is_result_followup_query)
_classify_short_customer_text = staticmethod(classify_short_customer_text)
_build_collect_ack = staticmethod(build_collect_ack)
_build_collect_progress_reply = staticmethod(build_collect_progress_reply)
_build_collect_remind = staticmethod(build_collect_remind)
_is_find_image_not_edit_conflict = staticmethod(is_find_image_not_edit_conflict)
_needs_clarification_in_collecting = staticmethod(needs_clarification_in_collecting)
_build_find_image_clarify_reply = staticmethod(build_find_image_clarify_reply)
_build_not_understood_reply = staticmethod(build_not_understood_reply)
_append_requirement = staticmethod(append_requirement)
async def _run_batch_feasibility(self, urls: List[str], concurrency: int) -> List[Tuple[str, Dict[str, Any]]]:
"""Stage 2: 可做性分析(逐图)。"""
from image.image_analyzer import image_analyzer
sem = asyncio.Semaphore(max(1, concurrency))
async def _analyze_one(url: str):
async with sem:
try:
r = await image_analyzer.analyze(url)
except Exception:
r = {
"complexity": "normal",
"reason": "识别异常,按常规估价",
"price_min": 15,
"price_max": 25,
"price_suggest": 20,
"success": False,
}
return url, r
return list(await asyncio.gather(*[_analyze_one(u) for u in urls]))
async def _sync_batch_analysis_to_workflow(self, results: List[Tuple[str, Dict[str, Any]]], message: CustomerMessage) -> None:
for url, r in results:
try:
from core.workflow import workflow
await workflow.image_analysis_result(
customer_id=message.from_id,
image_url=url,
complexity=r.get("complexity", "normal"),
acc_id=message.acc_id,
acc_type=message.acc_type,
gemini_prompt=r.get("gemini_prompt", ""),
aspect_ratio=r.get("aspect_ratio", "1:1"),
perspective=r.get("perspective", "no"),
proc_type=r.get("proc_type", ""),
subject=r.get("subject", ""),
quality=r.get("quality", ""),
)
except Exception as e:
logger.exception("[Agent] Workflow 批量任务创建失败: %s", e)
_calc_requirement_surcharge = staticmethod(calc_requirement_surcharge)
_build_batch_quote_reply = staticmethod(build_batch_quote_reply)
_prepare_batch_intake = staticmethod(prepare_batch_intake)
_assess_batch_risk = staticmethod(assess_batch_risk)
_build_batch_pricing_plan = staticmethod(build_batch_pricing_plan)
async def _try_batch_auto_process(
self,
results: List[Tuple[str, Dict[str, Any]]],
message: CustomerMessage,
req_fee: Dict[str, Any],
) -> Dict[str, Any]:
"""Stage 4-A: 自动处理+图绘链接。失败时回退到需求澄清。"""
links = []
try:
from image.image_processor import image_processor
from utils.image_queue import run_with_queue
for idx, (url, r) in enumerate(results, 1):
req_parts = [f"complexity:{r.get('complexity', 'normal')}"]
if r.get("gemini_prompt"):
req_parts.append(f"prompt:{r.get('gemini_prompt')}")
if r.get("aspect_ratio"):
req_parts.append(f"ratio:{r.get('aspect_ratio')}")
if r.get("perspective") and r.get("perspective") != "no":
req_parts.append(f"perspective:{r.get('perspective')}")
if r.get("proc_type"):
req_parts.append(f"proc_type:{r.get('proc_type')}")
if r.get("subject"):
req_parts.append(f"subject:{r.get('subject')}")
if r.get("quality"):
req_parts.append(f"quality:{r.get('quality')}")
process_res = await run_with_queue(image_processor.process_image(
url,
"enhance",
requirements="|".join(req_parts),
gemini_prompt=r.get("gemini_prompt", ""),
aspect_ratio=r.get("aspect_ratio", "1:1"),
perspective=r.get("perspective", "no"),
proc_type=r.get("proc_type", ""),
subject=r.get("subject", ""),
quality=r.get("quality", ""),
))
if not process_res.get("success"):
raise RuntimeError(process_res.get("message", "图片处理失败"))
ok, link, _ = await upload_to_tuhui(
process_res["result_path"],
title=f"客户{message.from_id[-4:]}-图片{idx}",
description="AI自动处理结果",
price=max(10, int(r.get("price_suggest", 20) or 20) + int(req_fee.get("extra", 0) or 0) // max(1, len(results))),
)
if not ok:
raise RuntimeError(str(link))
links.append(link)
except Exception as e:
logger.exception("[Agent] 找图自动处理失败,回退需求澄清: %s", e)
return {
"reply": "这种可以做类似款。你先说下具体需求:要几张、是否改字、尺寸比例、交付格式(单图/打包链接),我按需求给你直接做。",
"need_transfer": False,
}
lines = ["找到了,链接如下:"]
for i, link in enumerate(links, 1):
lines.append(f"链接{i}{link}")
return {"reply": "\n".join(lines), "need_transfer": False}
def _finalize_batch_state(self, state: ConversationState, customer_id: str, final_price: int = 0):
if final_price > 0:
state.last_price = final_price
try:
from db.customer_db import db
db.update_last_price(customer_id, final_price)
except Exception:
pass
state.pending_image_urls.clear()
state.pending_requirements.clear()
self._refresh_quote_phase(state, "idle")
self._sync_pending_quote_state(customer_id, state)
async def _quote_pending_images(self, state: ConversationState, message: CustomerMessage) -> Dict[str, Any]:
"""
统一报价主流程(分层):
1) Intake 收集
2) Feasibility 可做性
3) Pricing 报价
4) Router 自动处理/报价/转人工
"""
intake = self._prepare_batch_intake(state)
if not intake.get("ok", False):
return {"reply": intake.get("reply", ""), "need_transfer": bool(intake.get("need_transfer", False))}
urls = intake["urls"]
requirements = intake["requirements"]
analyze_concurrency = int(intake["analyze_concurrency"])
results = await self._run_batch_feasibility(urls=urls, concurrency=analyze_concurrency)
await self._sync_batch_analysis_to_workflow(results=results, message=message)
risk = self._assess_batch_risk(results)
unsafe = risk["unsafe"]
dense_text_reject = risk["dense_text_reject"]
if unsafe:
self._finalize_batch_state(state, message.from_id, final_price=0)
if dense_text_reject and len(dense_text_reject) == len(unsafe):
return {"reply": self._build_reject_message("文字密集类图片暂不接单"), "need_transfer": False}
return {
"reply": f"这批里{''.join(unsafe)}处理风险较高,我先帮你转人工设计师跟进会更稳妥。",
"need_transfer": True,
}
pricing = self._build_batch_pricing_plan(results=results, requirements=requirements)
total_suggest = int(pricing["total_suggest"])
bundle_price = int(pricing["bundle_price"])
req_fee = pricing["req_fee"]
intent_text = (message.msg or "") + " " + " ".join(requirements[-5:])
workflow_type, _ = self.workflow_router.detect_workflow(intent_text)
if workflow_type == "find_image":
route_res = await self._try_batch_auto_process(
results=results,
message=message,
req_fee=req_fee,
)
self._finalize_batch_state(state, message.from_id, final_price=bundle_price)
return route_res
reply_text = self._build_batch_quote_reply(
results=results,
total_suggest=total_suggest,
bundle_price=bundle_price,
req_fee=req_fee,
)
self._finalize_batch_state(state, message.from_id, final_price=bundle_price)
return {"reply": reply_text, "need_transfer": False}
_split_customer_text = staticmethod(split_customer_text)
def _build_prompt(self, message: CustomerMessage, state: ConversationState) -> str:
return build_agent_prompt(
message=message,
state=state,
extract_image_url=self._extract_image_url,
shop_type_resolver=_get_shop_type,
shop_persona_resolver=_get_shop_persona,
parse_order_info=parse_order_info,
build_order_instruction=build_order_instruction,
)
async def _handle_image_workflow(self, message: str, data: dict, image_urls: list) -> bool:
return await route_image_workflow(
workflow_router=self.workflow_router,
message=message,
data=data,
image_urls=image_urls,
)
async def test_agent():
"""测试 Agent"""
agent = CustomerServiceAgent(skills_dir="skills")
test_msg = CustomerMessage(
msg_id="123",
acc_id="test_account",
msg="这张图可以做吗?",
from_id="customer001",
from_name="张三",
cy_id="customer001",
acc_type="AliWorkbench",
msg_type=0,
cy_name="张三",
goods_name="专业找图代找高清图片",
goods_order=""
)
response = await agent.process_message(test_msg)
logger.info("回复内容: %s", response.reply)
if __name__ == "__main__":
import asyncio
asyncio.run(test_agent())