"""PydanticAI Agent 模块 架构:单 Agent + 多 Tool 模式 - Agent 负责对话逻辑和决策 - Tool 负责具体能力:看图/查客户/转接 - AI 自主决定何时调用哪个工具,时序自然,不需要外部协调 """ import os import glob import asyncio from typing import Optional, Dict from datetime import datetime from pydantic import BaseModel 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 load_dotenv() # ========== 企业微信通知 ========== _WECHAT_WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=cc88bdef-a13f-4d7e-bdb6-ee51b68b8205" 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,无原因" # ========== 数据模型 ========== 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 # 图片数量 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 = "" # 转接消息 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_md(skills_dir: str = "skills") -> str: """加载 skills 目录下的所有 SKILL.md 文件内容""" skill_contents = [] skill_files = glob.glob(os.path.join(skills_dir, "**/SKILL.md"), recursive=True) for skill_file in skill_files: try: with open(skill_file, 'r', encoding='utf-8') as f: content = f.read() skill_contents.append(content) except Exception as e: print(f"警告: 读取 {skill_file} 失败: {e}") return "\n\n".join(skill_contents) class CustomerServiceAgent: """客服 Agent - 支持 SKILL.md + 工作流""" 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") if not self.api_key: raise ValueError("请设置 OPENAI_API_KEY 环境变量") # 对话状态管理 self.conversations: Dict[str, ConversationState] = {} # 多轮对话历史(PydanticAI ModelMessage 列表,按客户ID存储) self.message_histories: Dict[str, list] = {} # 加载 skills 内容 self.skills_content = load_skill_md(skills_dir) # 创建 OpenAI 模型 model = OpenAIChatModel( model_name=self.model_name, provider=OpenAIProvider( api_key=self.api_key, base_url=self.base_url ) ) self.agent = Agent( model=model, deps_type=AgentDeps, system_prompt=self._get_system_prompt() ) self.agent_after_sale = Agent( model=model, deps_type=AgentDeps, system_prompt=self._get_after_sale_prompt() ) self.agent_pricing = Agent( model=model, deps_type=AgentDeps, system_prompt=self._get_pricing_prompt() ) self.agent_processing = Agent( model=model, deps_type=AgentDeps, system_prompt=self._get_processing_prompt() ) self.agent_similar = Agent( model=model, deps_type=AgentDeps, system_prompt=self._get_similar_prompt() ) self.agent_order = Agent( model=model, deps_type=AgentDeps, system_prompt=self._get_order_prompt() ) self.agent_risk = Agent( model=model, deps_type=AgentDeps, system_prompt=self._get_risk_prompt() ) # 注册工具 self._register_tools() def _register_tools(self): """注册所有 Tool,让 Agent 可以主动调用""" @self.agent.tool async def analyze_image(ctx: RunContext[AgentDeps], image_url: str) -> str: """ 分析客户发来的图片复杂度,用于报价。 收到图片URL时调用此工具,返回复杂度和建议报价。 """ try: from image.image_analyzer import image_analyzer result = await image_analyzer.analyze(image_url) complexity_label = { "simple": "简单(画面干净)", "normal": "一般复杂度", "complex": "细节偏多", "hard": "非常复杂", }.get(result["complexity"], result["complexity"]) # 持久化图片URL和复杂度,重启后仍能记住这张图 try: from db.customer_db import db db.update_last_image( ctx.deps.from_id, image_url, complexity=result["complexity"], gemini_prompt=result.get("gemini_prompt", ""), aspect_ratio=result.get("aspect_ratio", "1:1"), perspective=result.get("perspective", "no"), ) except Exception: pass # 存图片类型到客户画像 try: from db.customer_db import db as _db if result.get("subject"): _db.add_image_type(ctx.deps.from_id, result["subject"]) except Exception: pass # 在 workflow 里创建待处理任务(付款后自动触发 Gemini) try: from core.workflow import workflow await workflow.image_analysis_result( customer_id=ctx.deps.from_id, image_url=image_url, complexity=result["complexity"], acc_id=ctx.deps.acc_id, acc_type=ctx.deps.platform, gemini_prompt=result.get("gemini_prompt", ""), aspect_ratio=result.get("aspect_ratio", "1:1"), perspective=result.get("perspective", "no"), proc_type=result.get("proc_type", ""), subject=result.get("subject", ""), quality=result.get("quality", ""), ) print(f"[Agent] Workflow 任务已创建 | 客户: {ctx.deps.from_id} | 比例: {result.get('aspect_ratio')} | 透视: {result.get('perspective')} | 图片: {image_url[:60]}...") except Exception as e: print(f"[Agent] Workflow 任务创建失败: {e}") # 组装给 AI 的分析报告 risk = result.get("risk", "none") has_face = result.get("has_face", "no") feasibility = result.get("feasibility", "yes") note = result.get("note", "") lines = [ f"图片主体:{result['subject'] or '未识别'}", f"处理类型:{result['proc_type'] or '高清修复'}", f"原图质量:{result['quality'] or '未知'}", f"图片类型:{result.get('category', '') or '通用'}", f"图片尺寸:{(result.get('width') or 0)}x{(result.get('height') or 0)}({result.get('megapixels', 0.0)}MP)", f"含人脸:{'是' if has_face == 'yes' else '否'}", f"复杂度:{complexity_label}", f"原因:{result['reason']}", ] if result.get("size_surcharge"): lines.append(f"尺寸加价:+{result['size_surcharge']}元") if result.get("size_note"): lines.append(f"尺寸提示:{result['size_note']}") try: st = self._get_conversation_state(ctx.deps.from_id) if isinstance(result.get("price_min"), (int, float)): st.last_min_price = int(result.get("price_min") or 0) try: from db.customer_db import db as _db _db.update_last_min_price(ctx.deps.from_id, st.last_min_price) except Exception: pass except Exception: pass # 根据可做性和风险等级给 AI 不同的行动指引 if feasibility == "no": if "敏感" in (note or ""): lines.append("【拒绝】图片含敏感/黄色/擦边内容,不接单。") lines.append("→ 直接拒绝,不说「发图来看看」,自然回复如:这类不做/不接。") else: lines.append("【无法处理】此图无法处理(纯黑/纯白/完全损坏/要找原始RAW文件)。") lines.append("→ 告知客户无法处理,建议换图或说明原因,不要报价。") elif risk == "high": lines.append(f"【高风险】此图处理风险高:{note or 'AI修复后效果不能保证与原图一致'}") lines.append(f"建议报价:{result['price_suggest']}元") lines.append("→ 先自然说明风险(人脸/效果可能不完美),再报价,满意再拍。话术自然。") elif risk == "low": lines.append(f"【低风险-含人脸】修复后人脸相似度约70-90%,效果不稳定。") lines.append(f"建议报价:{result['price_suggest']}元") lines.append(f"→ 报价时自然加一句风险提示(人脸可能有轻微变化、满意再付等)") else: # 无风险,正常报价 lines.append(f"建议报价:{result['price_suggest']}元(区间 {result['price_min']}-{result['price_max']}元)") if feasibility == "partial": lines.append(f"⚠️ 此图有一定难度:{note or '效果可能不完美'},回复时可加「效果不满意退款」") if note and note not in ("无", ""): lines.append(f"提示:{note}") lines.append(f"【立刻回复客户报价 {result['price_suggest']} 元,话术自然多变】") return "\n".join(lines) except Exception as e: return f"图片分析失败: {e},请根据经验判断报价" @self.agent.tool async def get_customer_info(ctx: RunContext[AgentDeps], customer_id: str) -> str: """ 查询客户历史信息:消费记录、性格标签、报价历史等。 对话开始时或需要了解客户背景时调用。 """ try: from db.customer_db import db return db.get_profile_text(customer_id) except Exception as e: return f"查询失败: {e}" @self.agent.tool async def transfer_to_human(ctx: RunContext[AgentDeps]) -> str: """ 转接人工客服。 遇到退款/投诉/情绪激动/复杂售后时调用。 """ return "TRANSFER_REQUESTED" @self.agent.tool async def save_customer_note( ctx: RunContext[AgentDeps], customer_id: str, note: str ) -> str: """ 记录客户关键信息到画像(邮箱/微信/特殊需求等)。 客户提供联系方式或重要信息时调用。 """ try: from db.customer_db import db db.add_note(customer_id, note) return "已记录" except Exception as e: return f"记录失败: {e}" @self.agent.tool async def update_contact_info( ctx: RunContext[AgentDeps], customer_id: str, contact_type: str, value: str ) -> str: """ 更新客户联系方式。 当客户说出邮箱/手机/微信时调用,比正则提取更准确。 contact_type 枚举值: email - 邮箱 phone - 手机号 wechat - 微信号 """ try: from db.customer_db import db if contact_type == "email": db.update_email(customer_id, value) elif contact_type == "phone": db.update_phone(customer_id, value) elif contact_type == "wechat": db.update_wechat(customer_id, value) else: return f"未知联系方式类型: {contact_type}" return f"已保存 {contact_type}: {value}" except Exception as e: return f"保存失败: {e}" @self.agent.tool async def record_quote( ctx: RunContext[AgentDeps], customer_id: str, price: int, description: str = "" ) -> str: """ 记录本次报价到客户画像,用于后续对话保持价格一致。 每次给客户报价后调用。 Args: customer_id: 客户ID price: 报价金额(元) description: 报价描述,如"单图处理"/"三图打包" """ try: from db.customer_db import db db.update_last_price(customer_id, price) if description: db.add_note(customer_id, f"报价 {price}元({description})") # 同步到内存状态 state = self.conversations.get(customer_id) if state: state.last_price = price return f"已记录报价 {price}元" except Exception as e: return f"记录失败: {e}" @self.agent.tool async def process_image_gemini(ctx: RunContext[AgentDeps], customer_id: str = "") -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不自动作图" except Exception: return "现在处理模块暂时暂停,先不自动作图" """ 触发 Gemini 作图处理。客户付款后或说「安排一下」「处理一下」时调用。 会从客户档案读取上次发图的 URL 和处理参数(提示词、比例、透视),启动 Gemini 流程。 处理完成后会自动发图给客户。 """ cid = customer_id or ctx.deps.from_id try: from core.workflow import workflow ok = await workflow.trigger_processing_on_payment( customer_id=cid, acc_id=ctx.deps.acc_id, acc_type=ctx.deps.platform, ) if ok: return "已安排,稍后发你" return "该客户暂无待处理图片,请先发图" except Exception as e: return f"触发作图失败: {e},请稍后重试或转人工" @self.agent_pricing.tool async def analyze_image_pricing(ctx: RunContext[AgentDeps], image_url: str) -> str: try: from image.image_analyzer import image_analyzer result = await image_analyzer.analyze(image_url) p = result.get("price_suggest", 20) try: st = self._get_conversation_state(ctx.deps.from_id) if isinstance(result.get("price_min"), (int, float)): st.last_min_price = int(result.get("price_min") or 0) try: from db.customer_db import db as _db _db.update_last_min_price(ctx.deps.from_id, st.last_min_price) except Exception: pass except Exception: pass return f"建议报价:{p}元" except Exception as e: return f"图片分析失败: {e}" @self.agent_pricing.tool async def record_quote_pricing( ctx: RunContext[AgentDeps], customer_id: str, price: int, description: str = "" ) -> str: try: from db.customer_db import db db.update_last_price(customer_id, price) return "ok" except Exception as e: return f"记录失败: {e}" @self.agent_processing.tool async def process_image_gemini_run(ctx: RunContext[AgentDeps], customer_id: str = "") -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停" except Exception: return "现在处理模块暂时暂停" cid = customer_id or ctx.deps.from_id try: from core.workflow import workflow ok = await workflow.trigger_processing_on_payment( customer_id=cid, acc_id=ctx.deps.acc_id, acc_type=ctx.deps.platform, ) if ok: return "已安排" return "暂无待处理图片" except Exception as e: return f"触发作图失败: {e}" @self.agent_similar.tool async def recommend_similar(ctx: RunContext[AgentDeps], hint: str = "") -> str: try: return "有类似款,拍下我发你参考图。" except Exception as e: return f"推荐失败: {e}" @self.agent_order.tool async def handle_order(ctx: RunContext[AgentDeps], raw_msg: str = "") -> str: try: info = self._parse_order_info(raw_msg or "") paid_kw = ["等待发货", "已付款", "付款成功", "买家已付款"] if any(k in (info.get("pay_status", "") or "") for k in paid_kw) or any(k in (info.get("order_status", "") or "") for k in paid_kw): return "已安排,稍后发你" return "" except Exception: return "" @self.agent_risk.tool async def risk_filter(ctx: RunContext[AgentDeps], text: str = "") -> str: return "这类不接哦,抱歉哈。" @self.agent.tool async def remove_background(ctx: RunContext[AgentDeps], image_url: str) -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不处理图片" except Exception: return "现在处理模块暂时暂停,先不处理图片" """【独立工具】去背景,输出白底图。客户只要去背景时调用。""" try: from image.image_tools import remove_background as _rb r = await _rb(image_url) if r["success"]: return f"去背景完成,已保存。自然回复客户好了发你" return f"去背景失败:{r['message']}" except Exception as e: return f"去背景失败:{e}" @self.agent.tool async def perspective_correct(ctx: RunContext[AgentDeps], image_url: str) -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不处理图片" except Exception: return "现在处理模块暂时暂停,先不处理图片" """【独立工具】透视矫正。输入需白底图,输出展平图。""" try: from image.image_tools import perspective_correct as _pc r = await _pc(image_url) if r["success"]: return f"透视矫正完成。自然回复客户好了" return f"透视矫正失败:{r['message']}" except Exception as e: return f"透视矫正失败:{e}" @self.agent.tool async def extract_pattern_tool( ctx: RunContext[AgentDeps], image_url: str, prompt: str = "", aspect_ratio: str = "1:1" ) -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不处理图片" except Exception: return "现在处理模块暂时暂停,先不处理图片" """【独立工具】印花提取/主处理。按提示词和比例处理。""" try: from image.image_tools import extract_pattern r = await extract_pattern(image_url, prompt=prompt, aspect_ratio=aspect_ratio) if r["success"]: return f"提取完成。自然回复客户好了发你" return f"提取失败:{r['message']}" except Exception as e: return f"提取失败:{e}" @self.agent.tool async def enhance_image_tool(ctx: RunContext[AgentDeps], image_url: str) -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不处理图片" except Exception: return "现在处理模块暂时暂停,先不处理图片" """【独立工具】高清增强。客户只要清晰化时调用。""" try: from image.image_tools import enhance_image r = await enhance_image(image_url) if r["success"]: return f"高清增强完成。自然回复客户好了" return f"增强失败:{r['message']}" except Exception as e: return f"增强失败:{e}" @self.agent.tool async def color_match_tool( ctx: RunContext[AgentDeps], orig_url: str, result_url: str, strength: float = 0.75 ) -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不处理图片" except Exception: return "现在处理模块暂时暂停,先不处理图片" """【独立工具】颜色匹配。将 result 色调匹配到 orig。""" try: from image.image_tools import color_match_images r = await color_match_images(orig_url, result_url, strength=strength) if r["success"]: return f"颜色匹配完成" return f"颜色匹配失败:{r['message']}" except Exception as e: return f"颜色匹配失败:{e}" @self.agent.tool async def trim_border_tool(ctx: RunContext[AgentDeps], image_url: str) -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不处理图片" except Exception: return "现在处理模块暂时暂停,先不处理图片" """【独立工具】裁切四周背景边(白/黄/米等)。""" try: from image.image_tools import trim_border r = await trim_border(image_url) if r["success"]: return f"裁边完成" return f"裁边失败:{r['message']}" except Exception as e: return f"裁边失败:{e}" @self.agent.tool async def vectorize_to_eps_tool(ctx: RunContext[AgentDeps], image_url: str) -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不处理图片" except Exception: return "现在处理模块暂时暂停,先不处理图片" """【独立工具】矢量化 - 将图片转为 EPS 矢量文件。客户要做矢量图、转 EPS、转 AI 格式时调用。""" try: from image.image_tools import vectorize_to_eps r = await vectorize_to_eps(image_url) if r["success"]: return f"矢量化完成,已生成 EPS 文件。自然回复客户好了发你" return f"矢量化失败:{r['message']}" except Exception as e: return f"矢量化失败:{e}" @self.agent.tool async def meitu_enhance_tool( ctx: RunContext[AgentDeps], image_url: str, mode: str = "standard" ) -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不处理图片" except Exception: return "现在处理模块暂时暂停,先不处理图片" """ 【独立工具】美图画质增强。客户要画质增强、清晰化、美图处理时调用。 Args: image_url: 图片 URL 或本地路径 mode: 处理模式。crystal(极速重绘) standard(标准) enhance(增强) hdr(HDR) portrait(人像优化) """ try: from image.image_tools import meitu_enhance r = await meitu_enhance(image_url, mode=mode) if r["success"]: return f"画质增强完成。自然回复客户好了发你" return f"画质增强失败:{r['message']}" except Exception as e: return f"画质增强失败:{e}" @self.agent.tool async def resize_image( ctx: RunContext[AgentDeps], image_url: str, width: int, height: int = 0 ) -> str: try: from config.config import IMAGE_MODULE_ENABLED if not IMAGE_MODULE_ENABLED: return "现在处理模块暂时暂停,先不处理图片" except Exception: return "现在处理模块暂时暂停,先不处理图片" """ 改图片尺寸。客户说「改成1920x1080」「弄成横图」「改下尺寸」时调用。 Args: image_url: 图片URL(客户刚发的图,或从对话中获取) width: 目标宽度(像素),如 1920 height: 目标高度(0=按宽度等比缩放),如 1080 常用尺寸:1920x1080(横屏) 1080x1920(竖屏) 2000x2000(方图) """ try: from image.image_processor import image_processor result = await image_processor.resize(image_url, width, height) if result["success"]: return f"改尺寸完成:{width}x{height},已保存。自然回复客户改好了" else: return f"改尺寸失败:{result['message']},告知客户稍后重试" except Exception as e: return f"改尺寸失败:{e}" @self.agent.tool async def calculate_bulk_price( ctx: RunContext[AgentDeps], image_count: int, complexities: str = "" ) -> str: """ 计算多图打包价格。 客户要做多张图时调用,返回建议总价。 Args: image_count: 图片数量 complexities: 各图复杂度,逗号分隔,如 "normal,complex,simple" 没有识别结果时留空,按平均价格估算 """ if image_count <= 0: return "图片数量无效" # 各复杂度单价(必须为5的整数倍) unit_price = {"simple": 15, "normal": 20, "complex": 25, "hard": 30} default_unit = 20 # 没有识别结果时的默认单价 if complexities: levels = [c.strip() for c in complexities.split(",")] total = sum(unit_price.get(lv, default_unit) for lv in levels) else: total = image_count * default_unit # 打包优惠:3张以上9折,5张以上8折,价格必须为5的整数倍 if image_count >= 5: discounted = round(total * 0.8 / 5) * 5 tip = f"({image_count}张8折优惠)" elif image_count >= 3: discounted = round(total * 0.9 / 5) * 5 tip = f"({image_count}张9折优惠)" else: discounted = round(total / 5) * 5 tip = "" return f"建议打包报价:{discounted}元{tip}(原价{total}元)" # 对话状态超过多少小时后重置(避免昨天的售后状态影响今天) 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 else: self.conversations[customer_id] = ConversationState( customer_id=customer_id, last_update=now.isoformat() ) # 定期清理长期不活跃客户(超过 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 _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 "售前" def _get_system_prompt(self) -> str: base_prompt = """你是淘宝找原图店的客服,对外说帮客户找原图/高清图,实际是帮客户把模糊图处理成高清版,擅长快速成交。 核心原则:快、准、狠。**回复要像真人聊天,自然多变,禁止套模板、背台词。** 【你拥有的工具,按需调用】 - analyze_image(url):收到图片必须调用,分析复杂度获取报价依据 - process_image_gemini(customer_id):客户付款或说「安排/处理」时调用,走完整流程 - remove_background(image_url):只要去背景时单独调用 - perspective_correct(image_url):只要透视矫正时调用(需白底图) - extract_pattern_tool(image_url, prompt, aspect_ratio):只要印花提取时调用 - enhance_image_tool(image_url):只要高清增强时调用 - color_match_tool(orig_url, result_url, strength):颜色匹配 - trim_border_tool(image_url):裁切四周背景边 - resize_image(image_url, width, height):改尺寸,height=0则等比缩放 - get_customer_info(customer_id):老客户来时调用,了解历史消费和性格 - transfer_to_human():退款/投诉/情绪激动时调用 - update_contact_info(customer_id, type, value):客户说出邮箱/手机/微信时调用,type填"email"/"phone"/"wechat" - record_quote(customer_id, price, description):每次报价后调用,记录报价保持一致 - calculate_bulk_price(count, complexities):客户要做多张图时调用,获取打包价 - save_customer_note(customer_id, note):记录其他重要信息 【报价规则】 - 价格必须为5的整数倍(10/15/20/25/30),禁止报12、17、23等 - 客户只是文字询价,没发图 → 自然引导发图,不报价 - 收到图片 → 立刻调用 analyze_image() → 工具返回结果后【必须】立刻回复客户报价 - 报价和推成交的话术要自然多变,跟着客户语气走,不要每次都一样 - analyze_image 工具调用完成后,你的下一句话一定是报价,不能是内部说明 - 报价后立刻推成交,不等客户反应 【压价规则】 - 客户说「贵」「有点贵」「算了」「便宜点」→ 直接让价一次,禁止追问「什么问题」「说清楚点」 - 只让价一次,话术自然变化 - 第二次压价:表达最低了即可,换着说 【转接规则】 - 退款/退货/投诉/情绪激动/test → 调用 transfer_to_human() - 调用后只回复"转接",不加其他内容 【售后规则】 - 催进度:自然回复在做了/快了/马上好之类 - 要修改:自然问哪里要改 【禁忌】 - 没看到图不报价 - 不说"不行/不可以" - 不解释技术细节 - 不给价格区间 - 回复不超过2句话 - 绝对禁止输出任何内部独白或状态说明,包括但不限于:"无需回复""已完成""已经完成""不需要回复""流程结束""操作完成""任务完成""记录完成""报价已记录"等 - 每次必须输出真实的、发给客户看的回复文字,哪怕只有一句话""" if self.skills_content: base_prompt += f"\n\n=== 技能文档 ===\n{self.skills_content}" return base_prompt def _get_after_sale_prompt(self) -> str: return """你是淘宝客服的售后助手,负责售后阶段的自然沟通与处理进度反馈。 核心:简洁、自然、不解释技术细节、尽量不调用报价相关工具。 规则: - 已付款客户优先:确认安排、说明进度、承诺时间点 - 修改需求:礼貌询问具体改哪里,尽量一句话 - 催进度:自然回复在做了/快了/马上好,给预计时间 - 投诉/情绪激动/退款:转人工 - 输出不超过2句话,不说内部状态""" def _get_pricing_prompt(self) -> str: try: from config.config import MIN_PRICE_FLOOR floor = MIN_PRICE_FLOOR except Exception: floor = 15 return f"""你是淘宝客服的报价助手,负责在客户明确提到价格/询价时快速给出自然报价并推动成交。 规则: - 收到图片或历史有图片依据时尽量结合复杂度给出单价,价格为5的整数倍 - 没有图片时引导发图,不给价格区间 - 报价后紧跟一句推动成交,话术自然不重复 - 最低价不低于{floor}元,客户出价低于底线时礼貌拒绝(不好意思) - 输出不超过2句话""" def _get_processing_prompt(self) -> str: return """你是淘宝客服的处理助手,负责在客户说安排/处理/开始做或已付款的场景下进行处理安排与进度反馈。 规则: - 已付款或明确要求开始时,确认安排并给预计时间点 - 可调用处理流程工具 - 投诉/退款时转人工 - 输出不超过2句话""" def _get_similar_prompt(self) -> str: return """你是淘宝客服的相似图助手,客户问“有一样的吗/类似的吗/同款吗”时,给出自然回复与参考建议。 规则: - 先确认可以找类似款,建议拍后我发参考图 - 如已知图案/类型,简要说明“同类型都有”,推动成交 - 输出不超过2句话""" def _get_order_prompt(self) -> str: return """你是淘宝客服的订单助手,负责系统订单通知的处理。 规则: - 已付款时自然确认安排;其他状态静默(输出空字符串) - 输出不超过1句话""" def _get_risk_prompt(self) -> str: return """你是淘宝客服的风控助手,负责敏感/违规内容的前置拦截与替代话术。 规则: - 黄色/擦边/涉政等不接单,礼貌拒绝 - 输出不超过1句话""" def _get_customer_profile_context(self, customer_id: str) -> str: """从数据库读取客户画像,注入给 AI。含个性化语气、报价策略、主动预测。""" try: from db.customer_db import db profile = db.get_customer(customer_id) if profile.blacklist: return f"【⚠️黑名单客户】原因:{profile.blacklist_reason or '已标记'},请转接人工处理,不要自动回复" parts = [] if profile.email: parts.append(f"邮箱:{profile.email}") if profile.phone: parts.append(f"电话:{profile.phone}") if profile.wechat: parts.append(f"微信:{profile.wechat}") if profile.personality: parts.append(f"性格:{'/'.join(profile.personality)}") if profile.total_orders > 0: parts.append(f"历史下单:{profile.total_orders}次") if profile.vip_custom_price: parts.append(f"VIP专属价:{profile.vip_custom_price}元(直接用这个价报)") elif profile.last_price: parts.append(f"上次报价:{profile.last_price}元") if profile.lowest_price_accepted: parts.append(f"最低接受价:{profile.lowest_price_accepted}元") if profile.discount_given_count: parts.append(f"历史让价:{profile.discount_given_count}次") if getattr(profile, "last_quote_no_convert", False): parts.append("【报价策略】上次报价未成交,本次可适当降低5-10元促成交") if profile.price_sensitivity: parts.append(f"价格敏感:{profile.price_sensitivity}") if profile.decision_speed: parts.append(f"决策速度:{profile.decision_speed}") if profile.last_image_url: parts.append(f"上次发图:{profile.last_image_url}") if profile.processing_status: parts.append(f"当前任务:{profile.processing_status}") if profile.preferred_format: parts.append(f"格式偏好:{profile.preferred_format}") if profile.bulk_potential == "有": parts.append("批量潜力:有(可主动推打包价)") if profile.upsell_opportunity: parts.append(f"加购机会:{'/'.join(profile.upsell_opportunity)}") if profile.last_conversation_summary: time_str = "" if profile.last_conversation_time: try: t = datetime.fromisoformat(profile.last_conversation_time) diff = datetime.now() - t if diff.days > 0: time_str = f"({diff.days}天前)" else: h = diff.seconds // 3600 time_str = f"({h}小时前)" if h > 0 else "(刚刚)" except Exception: pass parts.append(f"上次对话{time_str}:{profile.last_conversation_summary}") # 个性化:语气与报价策略 hints = [] if profile.personality: if "爽快" in profile.personality: hints.append("回复简洁直接,少废话") if "砍价" in profile.personality or "砍价狂" in profile.personality: hints.append("报价时强调性价比,只让价一次") if "纠结" in profile.personality or "墨迹" in profile.personality: hints.append("耐心一点,可多给一点说明") if profile.price_sensitivity == "高": hints.append("报价时顺带提「满意再拍」降低顾虑") if profile.decision_speed == "快": hints.append("重点推快速成交,少铺垫") if hints: parts.append(f"【回复风格】{'; '.join(hints)}") # 主动预测:批量/加急 proactive = [] if profile.bulk_potential == "有" or (profile.total_images_sent or 0) >= 2: proactive.append("可主动问「要做多张吗,多张有优惠」") if profile.total_orders > 0 and profile.decision_speed == "快": proactive.append("老客爽快,直接推成交") if proactive: parts.append(f"【主动推荐】{'; '.join(proactive)}") if parts: return "【该客户历史信息】" + " | ".join(parts) except Exception: pass return "" def _get_refusal_context_hint(self, customer_id: str, current_msg: str, profile_context: str) -> str: """ 检测「刚拒绝某张图 + 客户问能找到吗」场景,注入显式提示,避免前后矛盾。 原因:last_conversation_summary 异步更新可能滞后,message_histories 模型可能忽略。 """ ask_keywords = ["能找到吗", "可以吗", "有吗", "能做吗", "可以找吗", "可以弄吗"] if not any(kw in current_msg for kw in ask_keywords): return "" refusal_keywords = ["不做", "不接", "拒绝", "不做这类", "这类不做"] # 检查 profile 摘要(可能因异步更新而滞后) if any(kw in profile_context for kw in refusal_keywords): return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。" # 检查内存历史中最近几条消息(ModelResponse 含客服回复) history = self.message_histories.get(customer_id, []) for msg in reversed(history[-6:]): msg_str = str(msg) if any(kw in msg_str for kw in refusal_keywords): return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。" return "" def _get_conversation_context(self, customer_id: str, acc_id: str = "", limit: int = 12, max_len: int = 80) -> str: """ 每一次对话都从数据库加载近期对话,压缩后注入 prompt。 确保模型看到上下文,同时控制 token 消耗。 """ try: try: from config.config import CHAT_CONTEXT_LIMIT, CHAT_CONTEXT_TRUNCATE_LEN limit = CHAT_CONTEXT_LIMIT max_len = CHAT_CONTEXT_TRUNCATE_LEN except Exception: pass from db.chat_log_db import get_recent_conversation msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=limit) if not msgs: return "" lines = [] for m in msgs: role = "客" if m.get("direction") == "in" else "服" msg_text = (m.get("message") or "").strip().replace("\n", " ")[:max_len] if not msg_text: continue lines.append(f"{role}:{msg_text}") if not lines: return "" return "【近期】\n" + "\n".join(lines) + "\n\n" except Exception: return "" def _get_intent_emotion_hint(self, msg: str) -> str: """语义匹配:意图/情绪识别,注入提示。EMBEDDING_MODEL 未配置时用关键词。""" try: from utils.intent_analyzer import detect_intent_embedding, detect_intent_keywords, detect_emotion_embedding intent = detect_intent_embedding(msg) if not intent: intent = detect_intent_keywords(msg) emotion = detect_emotion_embedding(msg) if os.getenv("EMBEDDING_MODEL") else None parts = [] if intent: parts.append(f"意图:{intent}") if emotion: parts.append(f"情绪:{emotion}") if parts: return f"【当前消息】{', '.join(parts)}" except Exception: pass return "" # 简单打招呼类消息(在近期已回复后无需再回) _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: """处理客户消息并生成回复""" # 获取或创建对话状态 state = self._get_conversation_state(message.from_id) # 冷却期检测:近期已回复 + 纯打招呼 → 静默 if self._in_cooldown(state, message.msg): elapsed = int((datetime.now() - state.last_reply_at).total_seconds()) print(f"[Agent] 冷却期静默(距上次回复 {elapsed}s):{message.msg!r}") return AgentResponse(reply="", should_reply=False, need_transfer=False) # 检测售前/售后 new_stage = self._detect_stage(message.msg) if new_stage != state.stage: state.stage = new_stage state.last_update = datetime.now().isoformat() # 订单通知前置处理 if "系统订单信息" in message.msg or "订单状态" in message.msg: _, order_block = self._split_customer_text(message.msg) customer_text, _ = self._split_customer_text(message.msg) order = self._parse_order_info(order_block or message.msg) pay_status = order.get("pay_status", "") order_status = order.get("order_status", "") paid_keywords = ["等待发货", "已付款", "付款成功", "买家已付款"] is_paid = any(kw in pay_status or kw in order_status for kw in paid_keywords) if is_paid: # 订单金额核查:对比报价和实付金额 asyncio.create_task(self._check_order_amount( message.from_id, order, message.acc_id )) # 成交记录:写入数据库供日报分析 asyncio.create_task(self._record_deal_success( message.from_id, message.from_name, message.acc_id, message.acc_type, order, state )) # 已付款:触发 Gemini 作图 try: from core.workflow import workflow asyncio.create_task(workflow.trigger_processing_on_payment( customer_id=message.from_id, acc_id=message.acc_id, acc_type=message.acc_type, )) except Exception as e: print(f"[Agent] 触发作图失败: {e}") elif not customer_text: # 非付款 + 没有客户文字 → 直接静默,不调用 AI print(f"[Agent] 订单通知静默({pay_status or order_status}),跳过回复") return AgentResponse(reply="", should_reply=False, need_transfer=False) # 构建提示词(包含对话状态 + 客户画像) user_prompt = self._build_prompt(message, state) # 注入客户历史画像(个性化语气、报价策略、主动预测) profile_context = self._get_customer_profile_context(message.from_id) if profile_context: user_prompt = profile_context + "\n\n" + user_prompt # 前后一致提示:刚拒绝某张图后,客户问「能找到吗」等时,必须区分能做/不能做 refusal_hint = self._get_refusal_context_hint(message.from_id, message.msg, profile_context or "") if refusal_hint: user_prompt = refusal_hint + "\n\n" + user_prompt # 每一次对话都注入近期上下文,确保模型看到完整对话 conv_context = self._get_conversation_context(message.from_id, acc_id=message.acc_id or "") if conv_context: user_prompt = conv_context + user_prompt # 语义匹配:意图/情绪识别(配置 EMBEDDING_MODEL 时用 embedding,否则关键词) intent_hint = self._get_intent_emotion_hint(message.msg) if intent_hint: user_prompt = intent_hint + "\n\n" + user_prompt deps = AgentDeps( msg_id=message.msg_id, acc_id=message.acc_id, from_id=message.from_id, platform=message.acc_type ) # 取出该客户的历史对话,传给 AI 保持上下文 history = self.message_histories.get(message.from_id, []) print(f"[Agent] ── 发送给AI的提示词 ──\n{user_prompt}\n────────────────────") try: msg_lower = message.msg.lower() pricing_kw = ["多少钱", "多少一张", "报价", "给个价", "几块", "价位", "能便宜点吗"] processing_kw = ["安排", "处理一下", "开始做", "做一下", "尽快", "加急", "付款了", "已付款"] similar_kw = ["有一样的", "有一样吗", "一样的吗", "类似的", "类似的吗", "同款", "相似", "类似吗"] order_markers = ["[系统订单信息]", "订单状态", "买家已付款"] risk_kw = ["黄色", "擦边", "色情", "涉黄", "涉政", "政治", "裸", "不雅"] target_agent = self.agent_after_sale if state.stage == "售后" else self.agent if any(k in msg_lower for k in risk_kw): target_agent = self.agent_risk elif any(k in message.msg for k in order_markers): target_agent = self.agent_order if any(k in msg_lower for k in processing_kw): target_agent = self.agent_processing elif any(k in msg_lower for k in pricing_kw): target_agent = self.agent_pricing elif any(k in msg_lower for k in similar_kw): target_agent = self.agent_similar result = await target_agent.run(user_prompt, deps=deps, message_history=history) # 更新历史,最多保留最近 30 条消息防止 token 超限 self.message_histories[message.from_id] = result.all_messages()[-30:] reply_text = result.output # 拦截超低杀价:客户报价低于底线时,统一礼貌拒绝 try: from config.config import MIN_PRICE_FLOOR import re offer = None m = re.search(r'(\d{1,4})\s*(?:元|块|块钱|元钱)\b', message.msg) if m: offer = int(m.group(1)) else: m2 = re.search(r'(?:能|可以|可否|能否)\s*(\d{1,4})\b', message.msg) offer = int(m2.group(1)) if m2 else None st = self._get_conversation_state(message.from_id) floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR if offer is not None and offer < floor: reply_text = "不好意思" except Exception: pass # 降限:若AI在回复中给出小于底线的报价,提升到>=底线且为5的倍数 try: from config.config import MIN_PRICE_FLOOR st = self._get_conversation_state(message.from_id) floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR def _adjust(text: str) -> str: import re def _repl(m): num = int(m.group(1)) adj = max(floor, round(num / 5) * 5) return m.group(0).replace(str(num), str(adj)) patterns = [ r'按(\d{1,4})元', r'报价[::]\s*(\d{1,4})\s*元', r'(\d{1,4})\s*元一张', r'打包(\d{1,4})\s*元', ] t = text for p in patterns: t = re.sub(p, _repl, t) return t reply_text = _adjust(reply_text or "") except Exception: pass # 打印工具调用记录 for msg in result.new_messages(): for part in getattr(msg, 'parts', []): part_type = type(part).__name__ if 'ToolCall' in part_type: print(f"[Agent] 工具调用: {getattr(part, 'tool_name', '')}({getattr(part, 'args', '')})") elif 'ToolReturn' in part_type: ret = str(getattr(part, 'content', ''))[:120] print(f"[Agent] 工具返回: {ret}") print(f"[Agent] AI原始输出: {repr(reply_text)}") except Exception as e: err_str = str(e) print(f"[Agent] AI 调用失败: {e},使用兜底回复") if "AccountOverdueError" in err_str or "overdue" in err_str.lower(): asyncio.create_task(_notify_wechat_overdue()) else: asyncio.create_task(_notify_wechat( f"⚠️ **AI调用异常**\n" f"客户:{message.from_id}\n" f"店铺:{message.acc_id}\n" f"错误:{err_str[:200]}", tag="AI异常" )) reply_text = None # AI 失败兜底:给一个不出错的万能回复 if not reply_text: return AgentResponse( reply="好的稍等,我看一下", should_reply=True, need_transfer=False ) # 敏感词过滤:党政/暴力/血腥/黄色 try: from utils.content_filter import should_block_reply blocked, fallback = should_block_reply(reply_text) if blocked: print(f"[Agent] 敏感词拦截,使用兜底回复") reply_text = fallback or "好的,您稍等,我帮您确认一下" except Exception: pass # 成本统计(可选) try: from utils.api_cost_tracker import record record("openai_chat", count=1) except Exception: pass # 检测是否报价 self._detect_price(reply_text, state) # 检测压价 self._detect_discount(message.msg, state) # 自动打标签(异步,不阻塞) asyncio.create_task(self._auto_tag(message, reply_text, state)) # 检测是否需要转接(文字触发 或 AI 调用了 transfer_to_human 工具) need_transfer = False transfer_msg = "" transfer_keywords = ["TRANSFER_REQUESTED", "[转移会话]", "转移会话", "转人工", "转接"] if reply_text and any(kw in reply_text for kw in transfer_keywords): need_transfer = True transfer_msg = TRANSFER_MESSAGE # 未成交记录:客户表达放弃且已报价过(转人工不记录) customer_text, _ = self._split_customer_text(message.msg) no_convert_keywords = ["算了", "不要了", "不做了", "下次再说", "先不弄了"] if customer_text and state.last_price and state.last_price > 0: if any(kw in customer_text for kw in no_convert_keywords): reason = "嫌贵放弃" if any(k in customer_text for k in ["贵", "贵了", "便宜"]) else "放弃" asyncio.create_task(self._record_deal_fail( message.from_id, message.from_name, message.acc_id, message.acc_type, reason )) # 需要转接时不把原始回复发给客户 should_reply = bool(reply_text and reply_text.strip()) and not need_transfer # 记录本次回复时间,供冷却期判断 if should_reply: state.last_reply_at = datetime.now() return AgentResponse(reply=reply_text, should_reply=should_reply, need_transfer=need_transfer, transfer_msg=transfer_msg) def _detect_price(self, reply: str, state: ConversationState): """从回复中提取价格,同步写入客户数据库(价格必须为5的整数倍)""" import re numbers = re.findall(r'(\d+)[元]', reply) if numbers: price = round(int(numbers[0]) / 5) * 5 # 强制为5的整数倍 state.last_price = price # 持久化到客户数据库,重启后仍可读取 try: from db.customer_db import db db.update_last_price(state.customer_id, price) except Exception: pass 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 _notify_wechat(msg) except Exception as e: print(f"[Agent] 订单金额核查失败: {e}") async def _record_deal_success( self, customer_id: str, customer_name: str, acc_id: str, platform: str, order: dict, state: "ConversationState", ): """成交时写入数据库,供日报与数据分析""" try: import re from db.deal_outcome_db import record_deal order_id = order.get("order_id", "") raw_amount = order.get("amount", "") m = re.search(r"[\d.]+", str(raw_amount)) amount = float(m.group()) if m else 0 reason = "让价后成交" if (state.discount_count or 0) > 0 else "直接成交" record_deal( customer_id=customer_id, outcome="成交", reason=reason, customer_name=customer_name or "", acc_id=acc_id or "", platform=platform or "", order_id=order_id, amount=amount, discount_given=(state.discount_count or 0) > 0, ) # 同步到客户库 try: from db.customer_db import db if order_id: db.add_order(customer_id, order_id, amount) db.clear_quote_no_convert(customer_id) except Exception: pass print(f"[Agent] 成交记录: {customer_id} {reason} {amount}元") except Exception as e: print(f"[Agent] 成交记录失败: {e}") async def _record_deal_fail( self, customer_id: str, customer_name: str, acc_id: str, platform: str, reason: str, ): """未成交时写入数据库,供日报与数据分析;标记报价未成交,下次可适当降低""" try: from db.deal_outcome_db import record_deal from db.customer_db import db record_deal( customer_id=customer_id, outcome="未成交", reason=reason, customer_name=customer_name or "", acc_id=acc_id or "", platform=platform or "", ) db.mark_quote_no_convert(customer_id) print(f"[Agent] 未成交记录: {customer_id} {reason}") except Exception as e: print(f"[Agent] 未成交记录失败: {e}") async def _auto_tag(self, message: CustomerMessage, reply: str, state: ConversationState): """自动识别并写入各类标签""" try: from db.customer_db import db cid = message.from_id msg = message.msg.lower() # 批量潜力 if any(kw in msg for kw in ["还有", "多张", "好几张", "一批", "下次还"]): db.set_bulk_potential(cid, "有") db.add_upsell_opportunity(cid, "批量打包") # 加购机会:问过PSD/分层 if any(kw in msg for kw in ["psd", "分层", "源文件"]): db.add_upsell_opportunity(cid, "分层PSD") db.update_preferred_format(cid, "psd") # 格式偏好 if "jpg" in msg or "jpeg" in msg: db.update_preferred_format(cid, "jpg") if "png" in msg: db.update_preferred_format(cid, "png") # 尺寸/分辨率偏好 if any(kw in msg for kw in ["分辨率", "dpi", "尺寸", "大图", "印刷"]): db.update_preferred_size(cid, message.msg[:30]) # 决策速度:收到报价后立刻说拍了 → 快 if any(kw in msg for kw in ["拍了", "下单了", "好的", "行"]) and state.last_price: db.update_decision_speed(cid, "快") # 图片类型识别(简单关键词匹配) type_keywords = { "印花": ["印花", "花纹", "图案", "面料", "布料", "纺织"], "logo": ["logo", "标志", "品牌", "商标"], "人物": ["人物", "人像", "照片", "脸", "头像"], "产品": ["产品", "商品", "包装", "实物"], "老照片": ["老照片", "旧照片", "发黄", "修复"], } for img_type, keywords in type_keywords.items(): if any(kw in message.msg for kw in keywords): db.add_image_type(cid, img_type) break # 定期自动计算衍生标签 db.auto_compute_tags(cid) except Exception: pass def _detect_discount(self, message: str, state: ConversationState): """检测压价,并持久化让价记录""" if any(kw in message for kw in ["贵", "便宜", "太贵", "有点贵"]): state.discount_count += 1 if state.last_price: try: from db.customer_db import db db.record_discount(state.customer_id, state.last_price) except Exception: pass # 客户明确给价(如“10元/10块/能10吗”) import re m = re.search(r'(\d+)\s*元|\b(\d+)\s*块', message) offer = None if m: offer = int(m.group(1) or m.group(2)) if offer: try: from config.config import MIN_PRICE_FLOOR if offer < MIN_PRICE_FLOOR: # 标记本次为超低出价(用于后续拒绝话术提示) state.last_price = state.last_price or 0 except Exception: pass def _parse_order_info(self, msg: str) -> dict: """从系统订单消息中提取所有字段""" import re info = {} m = re.search(r'订单号[::]\s*(\d+)', msg) if m: info['order_id'] = m.group(1) # 订单大状态(新订单/交易成功/交易关闭等) m = re.search(r'订单状态[::]\s*([^\s\[]+)', msg) if m: info['order_status'] = m.group(1).strip() # 支付细状态(等待买家付款/等待发货/交易完成等) m = re.search(r'\[状态[::]\s*([^\]]+)\]', msg) if m: info['pay_status'] = m.group(1).strip() # 金额 m = re.search(r'金额[::]\s*([\d.]+)元', msg) if m: info['amount'] = m.group(1) # 数量 m = re.search(r'数量[::]\s*(\d+)', msg) if m: info['quantity'] = m.group(1) # 时间(格式:2026-2-24 19:52:52) m = re.search(r'(\d{4}-\d{1,2}-\d{1,2}\s+\d{1,2}:\d{2}:\d{2})', msg) if m: info['order_time'] = m.group(1).strip() # 买家备注 m = re.search(r'买家备注[::]\s*([^\n]+)', msg) if m and m.group(1).strip(): info['buyer_note'] = m.group(1).strip() return info def _get_order_instruction(self, pay_status: str, order_status: str) -> str: """ 根据订单状态生成 AI 指令。 只有「已付款」才需要回复客户,其他状态一律静默。 """ paid_keywords = ["等待发货", "已付款", "付款成功", "买家已付款"] if any(kw in pay_status or kw in order_status for kw in paid_keywords): return "【已付款-必须回复】客户已付款,立刻自然回复确认收款并告知马上安排。" else: # 所有其他状态(待付款、交易完成、关闭等)静默处理 return "【仅系统通知-无需回复客户】这是系统订单通知,不需要回复客户任何内容,直接跳过。" def _extract_image_url(self, msg: str) -> str: """从消息中提取图片URL,兼容纯URL和 text#*#url 两种格式""" import re if not msg: return "" # 处理 "有吗#*#https://..." 格式 if "#*#" in msg: parts = msg.split("#*#", 1) candidate = parts[1].strip() if candidate.startswith(("http://", "https://")): return candidate # 纯URL或URL在任意位置 m = re.search(r'https?://\S+', msg) if m: url = m.group() image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com", "suning.com") if any(ext in url.lower() for ext in image_exts) or any(h in url.lower() for h in image_hosts): return url return "" def _split_customer_text(self, msg: str) -> tuple: """ 把混合消息拆分为(客户真实文字, 系统订单块)。 平台有时把客户文字和系统订单通知拼在同一条消息里。 """ import re # 找到系统订单块的起始位置 order_marker = re.search(r'\[系统订单信息\]|\[系统通知\]', msg) if order_marker: customer_text = msg[:order_marker.start()].strip() order_block = msg[order_marker.start():].strip() else: customer_text = msg.strip() order_block = "" return customer_text, order_block def _build_prompt(self, message: CustomerMessage, state: ConversationState) -> str: """构建提示词""" msg_content = message.msg stage_info = f"【当前阶段】{state.stage}" # 拆分:客户文字 vs 系统订单块 customer_text, order_block = self._split_customer_text(msg_content) has_order = bool(order_block) if has_order: order = self._parse_order_info(order_block) if order.get('order_id'): state.last_order_id = order['order_id'] stage_info += f"\n【订单号】{order['order_id']}" if order.get('order_status'): state.order_status = order['order_status'] stage_info += f"\n【订单状态】{order['order_status']}" if order.get('pay_status'): stage_info += f"\n【支付状态】{order['pay_status']}" if order.get('amount'): stage_info += f"\n【订单金额】{order['amount']}元" if order.get('quantity'): stage_info += f"\n【数量】{order['quantity']}件" if order.get('order_time'): stage_info += f"\n【下单时间】{order['order_time']}" if order.get('buyer_note'): stage_info += f"\n【买家备注】{order['buyer_note']}" if state.discount_count > 0: stage_info += f"\n【客户压价次数】{state.discount_count}" # 店铺类型:不同店铺不同回复策略 shop_type = _get_shop_type(message.acc_id or "", message.goods_name or "") shop_hint = "" try: from config.config import CONFIG_DIR import json cfg_path = CONFIG_DIR / "shop_prompts.json" if cfg_path.exists(): with open(cfg_path, "r", encoding="utf-8") as f: cfg = json.load(f) hints = cfg.get("type_hints", {}) shop_hint = hints.get(shop_type, "") if not shop_hint and message.acc_id: sh = cfg.get("shops", {}).get(message.acc_id, {}) shop_hint = sh.get("hint", "") except Exception: pass prompt = f"""收到新消息: {stage_info} 发送者: {message.from_name} ({message.from_id}) """ if message.goods_name: prompt += f"商品名称: {message.goods_name}\n" if shop_hint: prompt += f"\n{shop_hint}\n" # ── 优先处理客户真实问题 ── # ── 判断订单付款状态(供后续逻辑使用)── order_paid = False order_unpaid = False if has_order: order = self._parse_order_info(order_block) paid_kws = ["等待发货", "已付款", "付款成功", "买家已付款"] unpaid_kws = ["等待买家付款", "待付款", "未付款"] ps = order.get('pay_status', '') os_ = order.get('order_status', '') if any(kw in ps or kw in os_ for kw in paid_kws): order_paid = True elif any(kw in ps or kw in os_ for kw in unpaid_kws): order_unpaid = True # ── 催单/进度询问关键词 ── progress_keywords = [ "安排了吗", "安排好了吗", "好了吗", "做了吗", "做好了吗", "弄好了吗", "好了没", "做了没", "什么时候好", "多久好", "进度", "催一下", "快点", "什么时候能好", "做完了吗", ] if customer_text: prompt += f"\n客户说:{customer_text}\n" image_url = self._extract_image_url(customer_text) price_keywords = ["多少钱", "多少", "价格", "几块", "怎么收费", "报个价"] # gemini_api 店铺:不触发找图流程,按 API 客服回复 if shop_type == "gemini_api": prompt += "\n【Gemini API 店铺】客户问账号/pro/续费/套餐等,按 API 客服自然回复,不要求发图。" elif image_url: prompt += f"\n客户发来图片(URL: {image_url})。必须:① 调用 analyze_image('{image_url}') ② 拿到结果后直接回复报价,话术自然多变。分析完必须回复,不能不回复。" elif any(kw in customer_text for kw in price_keywords): last_url = self._extract_image_url(msg_content) if last_url: prompt += f"\n客户在询问上面那张图的价格,图片URL是 {last_url}。调用 analyze_image('{last_url}') 后直接回复报价,不能不回复。" else: prompt += "\n客户在询问价格但未发图,回复「发图来我看看」。" elif any(kw in customer_text for kw in progress_keywords): # 客户问进度/催单,必须先核查付款状态 if order_unpaid: prompt += "\n⚠️【订单未付款】客户问安排进度,但订单还未付款。自然告知拍下付款后马上安排即可。" elif order_paid: prompt += "\n客户催单,订单已付款,自然回复在做了/快了之类。" else: prompt += "\n客户催单,查询当前处理状态后自然回复。" elif any(kw in customer_text for kw in ["贵", "有点贵", "太贵", "算了", "便宜点", "少点", "打折", "贵哦"]): # 客户嫌贵/要放弃 → 直接让价一次,不问「什么问题」 prompt += "\n⚠️【客户嫌贵】客户已表达价格顾虑或要放弃。禁止追问「什么问题」「说清楚点」。直接让价一次(如原价20→15)。话术自然,像真人聊天,只让一次。" elif any(kw in customer_text for kw in ["擦边", "黄色", "色情", "大尺度", "性感图", "露点", "半裸"]): # 客户问擦边/黄色内容 → 直接拒绝,不说「发图来看看」 prompt += "\n⚠️【拒绝】客户询问擦边/黄色/敏感内容。直接拒绝,不接单,不说「发图来看看」。自然回复如:这类不做/不接/做不了。" else: prompt += "\n根据客户说的内容自然回应,像真人聊天,不要套模板。" # ── 附加订单信息(不覆盖客户问题的优先级)── if has_order: order = self._parse_order_info(order_block) order_instruction = self._get_order_instruction( order.get('pay_status', ''), order.get('order_status', '') ) if customer_text: if not order_unpaid: # 未付款情况已在上面明确处理,不重复添加背景 prompt += f"\n\n【背景参考-订单通知】{order_instruction}" else: # 纯系统通知,没有客户文字 prompt += f"\n\n{order_instruction}" if not customer_text and not has_order: prompt += f"\n消息内容: {msg_content}\n请按工作流规则回复。" return prompt 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())