from __future__ import annotations import asyncio import json import logging import re from dataclasses import dataclass from typing import Any logger = logging.getLogger("cs_agent") @dataclass class BrainDecision: action: str # reply | quote | transfer | noop source: str reply: str = "" transfer_msg: str = "" should_reply: bool = False need_transfer: bool = False payload: dict[str, Any] | None = None def _extract_json_obj(text: str) -> dict[str, Any] | None: if not text: return None m = re.search(r"\{[\s\S]*\}", text) if not m: return None try: return json.loads(m.group(0)) except Exception: return None async def _ai_policy_brain_decide(client, data: dict, *, msg_text: str, shop_type: str) -> BrainDecision | None: if not client.enable_agent or not client.agent or not client.AgentDeps: return None acc_id = str(data.get("acc_id", "") or "") customer_id = str(data.get("from_id", "") or "") current_urls = client._extract_image_urls(msg_text) recent_urls = client._collect_recent_image_urls(customer_id, acc_id, max_count=6) key = client._customer_key(data) pending_urls = client._pending_images.get(key) or [] try: order_status = client._detect_order_status(msg_text) has_image_url = client._msg_has_image_url(msg_text) refers_images = client._msg_refers_images(msg_text) is_price = client._msg_is_price_inquiry(msg_text) is_req = client._msg_is_requirement(msg_text) ext_contact = client._msg_requests_external_contact(msg_text) except Exception: order_status, has_image_url, refers_images, is_price, is_req, ext_contact = "", False, False, False, False, False deps = client.AgentDeps( msg_id=str(data.get("msg_id", "") or "brain_policy"), acc_id=acc_id, from_id=customer_id, platform=str(data.get("acc_type", "") or "AliWorkbench"), ) prompt = ( "你是淘宝客服系统的主决策Brain,只做决策,不要解释。\n" "你必须根据历史规则和当前上下文,输出唯一动作。\n" "可选动作 action: reply / quote / transfer / noop。\n" "历史规则(完整继承):\n" "1) 客户发图/补图:先自然承接,再根据上下文决定继续收集或报价;\n" "2) 客户询价且有可用图片(当前或最近)时,优先 action=quote;\n" "3) 若有 pending 图片且客户催报价/补充需求,优先 quote_mode=flush_pending;\n" "4) 仅打招呼/短无意义文本:可 action=reply 简短承接,不要机械模板;\n" "5) 索要外部联系方式(微信/QQ/手机号)时,不外呼,站内引导;\n" "6) 订单已付款:可回执安排处理;未付款/待付款:提醒完成付款;\n" "7) 地图/政治/高风险内容:谨慎,必要时 transfer 或拒绝性 reply;\n" "8) 尺寸超限/不可做场景:给明确边界,不要胡乱承诺;\n" "9) 客户没发图却问价:先承接,再引导发图;\n" "10) 避免重复外发,避免同一句话反复说。\n" "\n" "quote_mode 可选: flush_pending / analyze_current_or_recent / collect_only\n" "只输出 JSON:\n" '{"action":"reply|quote|transfer|noop","reply":"","transfer_msg":"","quote_mode":"","reason":""}\n\n' f"店铺类型: {shop_type}\n" f"legacy_fast_quote_enabled: {str(bool(client._legacy_fast_quote_enabled)).lower()}\n" f"客户原话: {msg_text}\n" f"has_image_url: {has_image_url}\n" f"current_image_urls_count: {len(current_urls)}\n" f"recent_image_urls_count: {len(recent_urls)}\n" f"pending_image_urls_count: {len(pending_urls)}\n" f"refers_images: {refers_images}\n" f"is_price_inquiry: {is_price}\n" f"is_requirement: {is_req}\n" f"requests_external_contact: {ext_contact}\n" f"order_status: {order_status or 'none'}\n" ) try: result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[]) raw = str(getattr(result, "output", "") or "").strip() obj = _extract_json_obj(raw) if not obj: client._activity_log( "brain_policy_parse_error", acc_id=acc_id, customer_id=customer_id, raw=raw[:300], ) return None action = str(obj.get("action", "") or "").strip().lower() reply = str(obj.get("reply", "") or "").strip() transfer_msg = str(obj.get("transfer_msg", "") or "").strip() quote_mode = str(obj.get("quote_mode", "") or "").strip().lower() reason = str(obj.get("reason", "") or "").strip() payload: dict[str, Any] | None = None if action == "quote": mode = quote_mode or "analyze_current_or_recent" if mode == "flush_pending": payload = {"mode": "flush_pending", "key": key, "pre_reply": reply} elif mode == "collect_only": payload = {"mode": "collect_only", "pre_reply": reply} else: urls = current_urls or recent_urls payload = {"mode": "analyze_urls", "urls": urls, "pre_reply": reply} decision = BrainDecision( action=action if action in {"reply", "quote", "transfer", "noop"} else "noop", source="brain_ai_policy", reply=reply, transfer_msg=transfer_msg, should_reply=bool(reply), need_transfer=(action == "transfer"), payload=payload, ) client._activity_log( "brain_policy_raw", acc_id=acc_id, customer_id=customer_id, action=decision.action, quote_mode=quote_mode, reason=reason, ) return decision except Exception as e: client._activity_log( "brain_policy_error", acc_id=acc_id, customer_id=customer_id, error=str(e), ) return None async def decide_brain_action(client, data: dict, customer_msg, *, trace_id: str, msg_text: str, shop_type: str) -> BrainDecision: """统一主决策层:优先由 Brain AI 决策;失败时回退 Agent 默认决策。""" ai_decision = await _ai_policy_brain_decide(client, data, msg_text=msg_text, shop_type=shop_type) if ai_decision is not None: return ai_decision # 回退:保持可用性 logger.info("Agent 正在处理消息...") client._activity_log( "agent_process_start", trace_id=trace_id, acc_id=data.get("acc_id", ""), customer_id=data.get("from_id", ""), msg=msg_text, ) response = await client.agent.process_message(customer_msg) client._activity_log( "agent_process_done", trace_id=trace_id, acc_id=data.get("acc_id", ""), customer_id=data.get("from_id", ""), result="ok", should_reply=bool(response.should_reply), need_transfer=bool(response.need_transfer), ) if response.need_transfer: return BrainDecision( action="transfer", source="fallback_agent", reply=response.reply or "", transfer_msg=response.transfer_msg or "", should_reply=bool(response.should_reply), need_transfer=True, ) if response.should_reply and response.reply: return BrainDecision( action="reply", source="fallback_agent", reply=response.reply, should_reply=True, need_transfer=False, ) return BrainDecision(action="noop", source="fallback_agent", should_reply=False, need_transfer=False) async def execute_brain_action(client, data: dict, *, decision: BrainDecision, trace_id: str, msg_text: str): """统一执行层:只执行标准动作。""" customer_id = data.get("from_id", "") if customer_id: client._touch_customer_last_contact(customer_id) if decision.action == "transfer": logger.info("Agent 决定转接人工") client._activity_log( "agent_transfer", trace_id=trace_id, acc_id=data.get("acc_id", ""), customer_id=data.get("from_id", ""), transfer_msg=decision.transfer_msg, ) client._fire_and_forget( client._post_tianwang_callback( "message_processed", data, extra={ "should_reply": bool(decision.should_reply), "need_transfer": True, "agent_reply": decision.reply or "", "transfer_msg": decision.transfer_msg or "", }, ) ) await client.transfer_to_human(data, decision.transfer_msg) client._push_chat_to_wechat_safe( data=data, customer_msg=msg_text, reply_msg=decision.transfer_msg or "转接", tag="转人工", ) return if decision.action == "reply": text = (decision.reply or "").strip() if not text: return await asyncio.sleep(0.6) client._activity_log( "agent_reply", trace_id=trace_id, acc_id=data.get("acc_id", ""), customer_id=data.get("from_id", ""), reply=text, ) await client.send_reply(data, text) await client._maybe_schedule_auto_quote(data) client._fire_and_forget( client._post_tianwang_callback( "message_processed", data, extra={ "should_reply": True, "need_transfer": False, "agent_reply": text, }, ) ) client._push_chat_to_wechat_safe( data=data, customer_msg=msg_text, reply_msg=text, tag="正常AI回复", ) return if decision.action == "quote": payload = decision.payload or {} pre_reply = str(payload.get("pre_reply", "") or "").strip() if pre_reply: await client.send_reply(data, pre_reply) mode = str(payload.get("mode", "") or "") if mode == "flush_pending": key = str(payload.get("key", "") or "") if key: await client._flush_pending_images(key, data) elif mode == "analyze_urls": urls = payload.get("urls") or [] if isinstance(urls, list) and urls: if len(urls) == 1: asyncio.create_task(client._analyze_single_and_reply(data, urls[0])) else: asyncio.create_task(client._analyze_multi_and_reply(data, urls)) else: await client.send_reply(data, "你把要处理的图再发我一下,我马上给你看。") else: if not pre_reply: await client.send_reply(data, "收到,我先看一下哈,稍等哈。") return # noop client._activity_log( "agent_no_reply", trace_id=trace_id, acc_id=data.get("acc_id", ""), customer_id=data.get("from_id", ""), ) client._fire_and_forget( client._post_tianwang_callback( "message_processed", data, extra={ "should_reply": False, "need_transfer": False, "agent_reply": "", }, ) )