1073 lines
44 KiB
Python
Executable File
1073 lines
44 KiB
Python
Executable File
"""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, build_trace_id
|
||
from core.quote_state_machine import QuoteStateMachine
|
||
from services.risk_service import RiskService
|
||
from core.agent_pre_rules import AgentPreRuleService
|
||
from core.find_image_flow import handle_find_image_batch_flow
|
||
from core.order_flow import handle_order_notification
|
||
from core.ai_reply_flow import execute_ai_turn
|
||
from core.reply_finalize_flow import finalize_ai_reply
|
||
from core.prompt_flow import build_prompt_bundle
|
||
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
|
||
|
||
load_dotenv()
|
||
|
||
from services.service_tuhui_upload import upload_to_tuhui
|
||
from core.workflow_router import get_workflow_router
|
||
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:
|
||
print(f"[{tag}] 未配置 WECHAT_WEBHOOK,跳过推送")
|
||
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:
|
||
print(f"[{tag}] 企业微信推送成功 ✓")
|
||
else:
|
||
print(f"[{tag}] 企业微信推送失败: {data}")
|
||
except Exception as e:
|
||
print(f"[{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 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:
|
||
print(f"警告: 读取 {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] = {}
|
||
# 多轮对话历史(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):
|
||
"""统一的控制台分层日志输出。"""
|
||
print(f"{CustomerServiceAgent.C_PROMPT}[{title}]{CustomerServiceAgent.C_RESET}")
|
||
print(content)
|
||
print(f"{CustomerServiceAgent.C_MUTED}────────────────────{CustomerServiceAgent.C_RESET}")
|
||
|
||
@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 改写,失败时回退到固定模板。
|
||
"""
|
||
# 首张收图先承接“我看一下”,避免机械地立刻催“发完统一报价”。
|
||
if scene == "collect_ack" and len(state.pending_image_urls) == 1:
|
||
first_ack = [
|
||
"收到了,我先看一下哈,稍等哈",
|
||
"这张我收到了,我先看下,稍等我一下哈",
|
||
"收到这张了,我先过一眼,稍等哈",
|
||
"我先看这张哈,稍等我一下",
|
||
"图我收到了,我先看一眼,稍等我回你哈",
|
||
"这张先记上了,我先看下细节,稍等哈",
|
||
"收到哈,我先过一遍这张,稍等我会儿",
|
||
"我先看这张效果,稍等一下哈",
|
||
"图到了,我先看下清晰度,稍等哈",
|
||
"这张我先看着,稍等我一下就回你",
|
||
"收到这张了,我先核一下细节,稍等哈",
|
||
"我先把这张看完,稍等我一会儿哈",
|
||
]
|
||
return random.choice(first_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 "无"
|
||
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
|
||
|
||
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
|
||
|
||
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:
|
||
"""获取或创建对话状态,超时自动重置"""
|
||
now = datetime.now()
|
||
|
||
if customer_id in self.conversations:
|
||
state = self.conversations[customer_id]
|
||
# 超过 12 小时没有消息,重置阶段和压价次数
|
||
if state.last_update:
|
||
try:
|
||
last = datetime.fromisoformat(state.last_update)
|
||
hours = (now - last).total_seconds() / 3600
|
||
if hours > self.CONVERSATION_TIMEOUT_HOURS:
|
||
state.stage = "售前"
|
||
state.discount_count = 0
|
||
# 同时清理对话历史,避免发送过期上下文
|
||
self.message_histories.pop(customer_id, None)
|
||
except Exception:
|
||
pass
|
||
# 进程内状态为空时,尝试从持久化恢复
|
||
if not state.pending_image_urls and not state.pending_requirements:
|
||
self._restore_pending_quote_state(customer_id, state)
|
||
else:
|
||
self.conversations[customer_id] = ConversationState(
|
||
customer_id=customer_id,
|
||
last_update=now.isoformat()
|
||
)
|
||
self._restore_pending_quote_state(customer_id, self.conversations[customer_id])
|
||
|
||
# 定期清理长期不活跃客户(超过 7 天)
|
||
self._cleanup_inactive(now)
|
||
|
||
return self.conversations[customer_id]
|
||
|
||
def _cleanup_inactive(self, now: datetime):
|
||
"""清理超过 7 天没有消息的对话状态,释放内存"""
|
||
# 每 100 次调用清理一次,避免每次都遍历
|
||
if len(self.conversations) % 100 != 0:
|
||
return
|
||
expired = [
|
||
cid for cid, state in self.conversations.items()
|
||
if state.last_update and
|
||
(now - datetime.fromisoformat(state.last_update)).days > 7
|
||
]
|
||
for cid in expired:
|
||
self.conversations.pop(cid, None)
|
||
self.message_histories.pop(cid, None)
|
||
|
||
def _sync_pending_quote_state(self, customer_id: str, state: ConversationState):
|
||
"""把待报价队列同步到客户库,避免重启丢失。"""
|
||
try:
|
||
self._refresh_quote_phase(state)
|
||
from db.customer_db import db
|
||
db.update_pending_quote_state(
|
||
customer_id,
|
||
state.pending_image_urls,
|
||
state.pending_requirements,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _restore_pending_quote_state(self, customer_id: str, state: ConversationState):
|
||
"""从客户库恢复待报价队列。"""
|
||
try:
|
||
from db.customer_db import db
|
||
profile = db.get_customer(customer_id)
|
||
state.pending_image_urls = list(getattr(profile, "pending_quote_images", []) or [])
|
||
state.pending_requirements = list(getattr(profile, "pending_quote_requirements", []) or [])
|
||
state.image_count = len(state.pending_image_urls)
|
||
self._refresh_quote_phase(state)
|
||
except Exception:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _refresh_quote_phase(state: ConversationState, phase_hint: str = ""):
|
||
"""统一维护收图报价状态机。"""
|
||
QuoteStateMachine().refresh(state, phase_hint=phase_hint)
|
||
|
||
def _should_defer_batch_quote(self, state: ConversationState, mark_ready: bool = False) -> bool:
|
||
"""
|
||
批量报价延后控制:
|
||
- 首次进入 ready_to_quote 时按配置等待 N 轮
|
||
- 等待轮次归零后,本轮即可报价
|
||
"""
|
||
self.quote_state_machine.delay_turns = max(0, int(self.batch_quote_delay_turns))
|
||
return self.quote_state_machine.should_defer_batch_quote(state, mark_ready=mark_ready)
|
||
|
||
def _mark_quote_ready(self, state: ConversationState):
|
||
"""仅标记 ready 状态,不消费等待轮次。"""
|
||
self.quote_state_machine.delay_turns = max(0, int(self.batch_quote_delay_turns))
|
||
self.quote_state_machine.mark_ready(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)
|
||
_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
|
||
|
||
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())
|
||
|
||
print(f"[Agent] 订单金额核查:报价 {quoted}元 vs 实付 {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}元 — 请人工核查"
|
||
)
|
||
print(f"[Agent] {msg}")
|
||
await self._notify_wechat(msg)
|
||
except Exception as e:
|
||
print(f"[Agent] 订单金额核查失败: {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:
|
||
print(f"[Agent] Workflow 批量任务创建失败: {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:
|
||
print(f"[Agent] 找图自动处理失败,回退需求澄清: {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,
|
||
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)
|
||
print(f"回复内容: {response.reply}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import asyncio
|
||
asyncio.run(test_agent())
|