import os import re import hashlib import logging from typing import List, Optional, Any, Dict from pydantic_ai import Agent, RunContext from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider from core.schema import StandardMessage, StandardResponse from core.agent_tools import register_agent_tools logger = logging.getLogger("cs_agent") from core.skill_manager import skill_manager def _clip(text: str, limit: int = 1200) -> str: if text is None: return "" text = str(text) if len(text) <= limit: return text return f"{text[:limit]}...(截断, 共{len(text)}字)" def _fmt_time(ts: Any) -> str: s = str(ts or "").strip() if not s: return "--:--:--" if " " in s: return s.split(" ", 1)[1] return s class CustomerServiceBrain: """ 重构后的单一 Agent 大脑: 【全能终极版】统一称呼为“设计师”,支持下线安抚。 """ def __init__(self, model_name: str = None): self.api_key = os.getenv("OPENAI_API_KEY") self.base_url = os.getenv("OPENAI_BASE_URL") self.model_name = model_name or os.getenv("OPENAI_MODEL", "gpt-4o-mini") model = OpenAIChatModel( model_name=self.model_name, provider=OpenAIProvider(api_key=self.api_key, base_url=self.base_url) ) exclude_names = os.getenv("SKILL_EXCLUDE_FROM_PROMPT", "pricing-skill") excluded_skills = [s.strip().lower() for s in exclude_names.split(",") if s.strip()] all_skills = skill_manager.get_all_skills_text(exclude=excluded_skills) logger.info(f"[SkillManager] 已从提示词排除技能: {excluded_skills}") # --- 统一口径后的 System Prompt --- system_prompt = ( "你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话自然、专业。\n\n" "【统一称呼规范 - 第一人称原则】\n" "1. 你就是店主本人,未转接设计师之前,所有回复必须用第一人称:'我'、'我这边'。\n" "2. 例如:客户问进度 → '我在看哈,稍等';客户催 → '我帮你催下哈'。\n" "3. 只有在需要转接时才提'设计师':'我叫设计师来看下哈'。\n" "4. 严禁使用'师傅'、'客服'、'专员'等词汇。\n\n" "【★★★ 历史记录查询 - 最高优先级 ★★★】\n" "你有一个 lookup_chat_history_tool 工具,可以查询客户的完整历史聊天记录。\n" "以下情况你【必须】先调用此工具查历史,再回复:\n" "1. 客户说'之前聊过'、'上次'、'你看聊天记录'、'我发过了'、'前面发了'等\n" "2. 客户追问进度:'做好了吗'、'多久能好'、'怎么样了'\n" "3. 客户表达不满或困惑:'?'、'你瞎么'、'搞笑'、'说过了'\n" "4. 【近期对话回顾】中显示客户之前已发过图或说过需求\n" "查到历史后,根据历史内容回复,绝对不要再重复问客户已经回答过的问题!\n\n" "【核心逻辑】\n" "1. 业务:只聊高清修复和找原图。核心链路:引导发图 -> 问需求 -> 找设计师。\n" "2. **主动引导**:只有当客户【从未发过图】且没有历史图片记录时,才引导发图。\n" "3. **非业务问题**:如果客户问招聘、合作、闲聊等与做图无关的话题,礼貌拒绝。\n" "4. **客户说没有参考图**:直接转人工:'好的,我这就叫设计师帮您找哈'。\n" "5. **客户问尺寸/能否打印/退款**:直接转人工:'这个设计师帮您看下哈'。\n" "6. 转接时机:收到图片并明确需求后,立即调用转人工工具。\n" "7. **下线安抚**:只有工具返回ERROR时才能提设计师不在。根据错误码区分:\n" " - ERROR_DESIGNER_NOT_STARTED → 说'还没上班,记下了上班马上处理'(严禁说下班)\n" " - ERROR_DESIGNER_OFFLINE → 说'下班了,需求记下明天回'\n" " - ERROR_DESIGNER_BUSY → 说'稍等,我帮你联系下'(严禁说下班)\n" "8. 正在转接中:如果系统提示已在转接,回:'已经在帮你催了哈,稍等下!'。\n" "9. **每次转接必须调用工具**:不要猜测,每次都重新调用。\n\n" "【情绪识别与应急转人工】\n" "当客户出现以下信号时,立即调用转人工工具,不要继续机械回复:\n" "- 愤怒/辱骂:'滚'、'垃圾'、'投诉'、'差评'、'骗子'\n" "- 反复质疑:'你是机器人吗'、'搞笑'、'你瞎么'、'说了多少遍'\n" "- 连续不满:客户连续2条以上表达不满(如'?'、'...'、质问语气)\n" "转人工话术:'亲亲抱歉,我马上叫设计师亲自来处理哈'\n\n" "【确认短句收尾规则 - 千牛要求最后一句必须是客服说的】\n" "客户说'嗯'、'好'、'好的'、'行'、'ok'、'哦'、'知道了'等确认短句时,\n" "必须回一句自然的收尾,但严禁复读'嗯咯'!根据上下文选择合适的收尾:\n" "- 如果刚谈完需求/报价 → '有问题随时找我哈'\n" "- 如果刚说了等设计师 → '好的,有消息马上告诉你'\n" "- 如果是闲聊结束 → '好嘞~'\n" "每次收尾话术不能重复,要自然变化。\n\n" "【必杀令 - 严格遵守】\n" "1. 每句回复严禁超过15个字!语气淘宝亲切风,多用'哈'、'呢'。\n" "2. 严禁报价,严禁复读图片已收到的情况。\n" "3. 必须原样输出工具返回的'正在为您转接|'指令。\n" "4. **严禁**说'在呢铁子'!只能说'在呢'或'在呢亲'。\n" "5. **严禁**连续两次回复相同或相似内容!回顾你最近说过的话,换一种说法。\n" "6. **严禁**输出任何代码、标记、括号等乱码!只输出自然语言。\n" "7. **严禁**自己臆造'下班'!只有工具返回ERROR才能说下班。\n" "8. **严禁**在客户已发过图的情况下还说'先发图来看看'!先查历史确认。\n\n" f"业务参考:\n{all_skills}" ) self.agent = Agent(model=model, system_prompt=system_prompt) register_agent_tools(self.agent) async def think_and_reply(self, msg: StandardMessage, history: Optional[List[dict]] = None) -> StandardResponse: if history is None: history = [] try: user_content = msg.content or "" # 客户已发图:在上下文中明确告知 AI,避免再回"先发图" if msg.image_urls: user_content = ( f"【系统通知:客户已发送 {len(msg.image_urls)} 张图片,不要再让客户发图!" f"请直接问客户需求(找原图还是修复),然后转接设计师】\n{user_content}" ) recent_context = "" if history: lines = [] for h in history[-6:]: role = "客户" if h.get("role") == "user" else "我" content = h.get("content", "") lines.append(f"[{_fmt_time(h.get('timestamp'))}] {role}:{content}") recent_context = "【近期对话回顾】\n" + "\n".join(lines) + "\n----------------\n" full_input = f"【当前客户ID:{msg.user_id}】\n{recent_context}现在的对话:{user_content}" logger.info( f"[PROMPT->AI] user={msg.user_id} acc={msg.acc_id} images={len(msg.image_urls)}\n" f"{_clip(full_input)}" ) result = await self.agent.run(full_input, message_history=history) # --- 转接指令:直接从工具返回截获,不经过 AI 二次加工 --- transfer_cmd = "" for m in result.all_messages(): if hasattr(m, 'parts'): for part in m.parts: if getattr(part, 'part_kind', '') == 'tool-return': content = str(getattr(part, 'content', '')) if "[转移会话]" in content: transfer_cmd = content if transfer_cmd: logger.info(f"[Brain] 工具返回转接指令,直接发送(跳过AI加工): {transfer_cmd[:60]}") return StandardResponse( reply_content=transfer_cmd, need_transfer=True, metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type} ) # --- 非转接场景:取 AI 的正常回复 --- reply_text = "" raw_output = getattr(result, 'output', None) or getattr(result, 'data', None) if isinstance(raw_output, str): reply_text = raw_output # 清理模型泄露的内部标记/乱码(覆盖所有已知格式) reply_text = re.sub(r'\[\]<\|[^|]+\|>', '', reply_text) reply_text = re.sub(r'<\|[^|]*\|>', '', reply_text) reply_text = re.sub(r'\[Function[^\]]*\]', '', reply_text) reply_text = re.sub(r'\[/?Tool[^\]]*\]', '', reply_text) reply_text = re.sub(r']*>', '', reply_text, flags=re.IGNORECASE) reply_text = re.sub(r']*>.*?]*>', '', reply_text, flags=re.DOTALL) reply_text = re.sub(r']*>.*', '', reply_text, flags=re.DOTALL) reply_text = re.sub(r']*>', '', reply_text) reply_text = re.sub(r'```[^`]*```', '', reply_text) reply_text = re.sub(r'\{["\'][^}]+\}', '', reply_text) reply_text = re.sub(r'AgentRunResult\([^)]*\)', '', reply_text) reply_text = re.sub(r'\[/?[A-Z][a-zA-Z]*(?:Call|End|Start|Result|Return)[^\]]*\]', '', reply_text) reply_text = re.sub(r'[\[\]]{2,}', '', reply_text) reply_text = reply_text.strip() # 过滤"在呢铁子" if "在呢铁子" in reply_text: reply_text = reply_text.replace("在呢铁子", "在呢亲") if not reply_text: reply_text = "稍等我看看。" logger.info(f"[THINK/RAW_OUTPUT] user={msg.user_id}\n{_clip(reply_text)}") need_transfer = "[转移会话]" in reply_text return StandardResponse( reply_content=reply_text, need_transfer=need_transfer, metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type} ) except Exception as e: logger.error(f"[Brain Error]: {e}") return StandardResponse(reply_content="好哒,我在看图,稍等回你哈。", metadata={"acc_id": msg.acc_id})