"""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())