feat: upgrade客服多店铺分流、批量报价与稳定性防护
This commit is contained in:
@@ -8,13 +8,16 @@
|
||||
import os
|
||||
import glob
|
||||
import asyncio
|
||||
from typing import Optional, Dict
|
||||
import random
|
||||
import hashlib
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
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
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -87,6 +90,8 @@ class ConversationState(BaseModel):
|
||||
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) # 待统一报价需求
|
||||
last_update: str = ""
|
||||
last_reply_at: Optional[datetime] = None # 最后一次回复客户的时间
|
||||
|
||||
@@ -150,6 +155,12 @@ def load_skill_md(skills_dir: str = "skills") -> str:
|
||||
|
||||
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
|
||||
|
||||
def __init__(self, skills_dir: str = "skills"):
|
||||
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||
@@ -218,6 +229,23 @@ class CustomerServiceAgent:
|
||||
# 注册工具
|
||||
self._register_tools()
|
||||
|
||||
@staticmethod
|
||||
def _log_block(title: str, content: str):
|
||||
"""统一的控制台分层日志输出。"""
|
||||
print(f"{CustomerServiceAgent.C_PROMPT}[{title}]{CustomerServiceAgent.C_RESET}")
|
||||
print(content)
|
||||
print(f"{CustomerServiceAgent.C_MUTED}────────────────────{CustomerServiceAgent.C_RESET}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_reply_text(text: Optional[str]) -> str:
|
||||
"""清洗模型输出,避免把占位词直接发给客户。"""
|
||||
if text is None:
|
||||
return ""
|
||||
cleaned = str(text).strip()
|
||||
if cleaned.lower() in {"无", "none", "null", "n/a"}:
|
||||
return ""
|
||||
return cleaned
|
||||
|
||||
def _register_tools(self):
|
||||
"""注册所有 Tool,让 Agent 可以主动调用"""
|
||||
|
||||
@@ -802,11 +830,15 @@ class CustomerServiceAgent:
|
||||
self.message_histories.pop(customer_id, None)
|
||||
except Exception:
|
||||
pass
|
||||
# 进程内状态为空时,尝试从持久化恢复
|
||||
if not state.pending_image_urls and not state.pending_requirements:
|
||||
self._restore_pending_quote_state(customer_id, state)
|
||||
else:
|
||||
self.conversations[customer_id] = ConversationState(
|
||||
customer_id=customer_id,
|
||||
last_update=now.isoformat()
|
||||
)
|
||||
self._restore_pending_quote_state(customer_id, self.conversations[customer_id])
|
||||
|
||||
# 定期清理长期不活跃客户(超过 7 天)
|
||||
self._cleanup_inactive(now)
|
||||
@@ -827,6 +859,63 @@ class CustomerServiceAgent:
|
||||
self.conversations.pop(cid, None)
|
||||
self.message_histories.pop(cid, None)
|
||||
|
||||
def _sync_pending_quote_state(self, customer_id: str, state: ConversationState):
|
||||
"""把待报价队列同步到客户库,避免重启丢失。"""
|
||||
try:
|
||||
from db.customer_db import db
|
||||
db.update_pending_quote_state(
|
||||
customer_id,
|
||||
state.pending_image_urls,
|
||||
state.pending_requirements,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _restore_pending_quote_state(self, customer_id: str, state: ConversationState):
|
||||
"""从客户库恢复待报价队列。"""
|
||||
try:
|
||||
from db.customer_db import db
|
||||
profile = db.get_customer(customer_id)
|
||||
state.pending_image_urls = list(getattr(profile, "pending_quote_images", []) or [])
|
||||
state.pending_requirements = list(getattr(profile, "pending_quote_requirements", []) or [])
|
||||
state.image_count = len(state.pending_image_urls)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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:
|
||||
"""检测售前/售后"""
|
||||
# 系统订单通知不属于售后,单独处理
|
||||
@@ -845,7 +934,7 @@ class CustomerServiceAgent:
|
||||
核心原则:快、准、狠。**回复要像真人聊天,自然多变,禁止套模板、背台词。**
|
||||
|
||||
【你拥有的工具,按需调用】
|
||||
- analyze_image(url):收到图片必须调用,分析复杂度获取报价依据
|
||||
- analyze_image(url):客户确认“图片发完”后调用,分析复杂度用于统一报价
|
||||
- process_image_gemini(customer_id):客户付款或说「安排/处理」时调用,走完整流程
|
||||
- remove_background(image_url):只要去背景时单独调用
|
||||
- perspective_correct(image_url):只要透视矫正时调用(需白底图)
|
||||
@@ -864,9 +953,9 @@ class CustomerServiceAgent:
|
||||
【报价规则】
|
||||
- 价格必须为5的整数倍(10/15/20/25/30),禁止报12、17、23等
|
||||
- 客户只是文字询价,没发图 → 自然引导发图,不报价
|
||||
- 收到图片 → 立刻调用 analyze_image() → 工具返回结果后【必须】立刻回复客户报价
|
||||
- 收到图片先收集,不立刻报单张价;等客户明确“发完了/统一报价”后,再统一报价
|
||||
- 报价和推成交的话术要自然多变,跟着客户语气走,不要每次都一样
|
||||
- analyze_image 工具调用完成后,你的下一句话一定是报价,不能是内部说明
|
||||
- 客户确认发完后,分析完成的下一句话必须是明确报价
|
||||
- 报价后立刻推成交,不等客户反应
|
||||
|
||||
【文字加价规则】⚠️ 重要
|
||||
@@ -1215,6 +1304,7 @@ class CustomerServiceAgent:
|
||||
|
||||
async def process_message(self, message: CustomerMessage) -> AgentResponse:
|
||||
"""处理客户消息并生成回复"""
|
||||
metrics_emit("inbound_msg", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
# 获取或创建对话状态
|
||||
state = self._get_conversation_state(message.from_id)
|
||||
|
||||
@@ -1267,6 +1357,62 @@ class CustomerServiceAgent:
|
||||
print(f"[Agent] 订单通知静默({pay_status or order_status}),跳过回复")
|
||||
return AgentResponse(reply="", should_reply=False, need_transfer=False)
|
||||
|
||||
# 找图店:先收集图片和需求,等客户确认“发完”后统一报价
|
||||
customer_text, _ = self._split_customer_text(message.msg)
|
||||
shop_type = _get_shop_type(message.acc_id or "", message.goods_name or "")
|
||||
if shop_type == "find_image" and self._is_batch_quote_enabled(message.from_id, message.acc_id):
|
||||
incoming_urls = self._extract_image_urls(customer_text)
|
||||
text_without_urls = self._strip_urls_from_text(customer_text)
|
||||
|
||||
if incoming_urls:
|
||||
for u in incoming_urls:
|
||||
if u not in state.pending_image_urls:
|
||||
state.pending_image_urls.append(u)
|
||||
if text_without_urls:
|
||||
self._append_requirement(state, text_without_urls)
|
||||
state.image_count = len(state.pending_image_urls)
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
|
||||
if self._is_batch_finish_signal(customer_text):
|
||||
quote_res = await self._quote_pending_images(state, message)
|
||||
reply_text = quote_res.get("reply", "")
|
||||
need_transfer = bool(quote_res.get("need_transfer"))
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
||||
return AgentResponse(
|
||||
reply=reply_text,
|
||||
should_reply=not need_transfer,
|
||||
need_transfer=need_transfer,
|
||||
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
||||
)
|
||||
|
||||
ack = self._build_collect_ack(len(state.pending_image_urls))
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {ack}")
|
||||
return AgentResponse(reply=ack, should_reply=True, need_transfer=False)
|
||||
|
||||
if state.pending_image_urls:
|
||||
if text_without_urls:
|
||||
self._append_requirement(state, text_without_urls)
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
if self._is_batch_finish_signal(customer_text):
|
||||
quote_res = await self._quote_pending_images(state, message)
|
||||
reply_text = quote_res.get("reply", "")
|
||||
need_transfer = bool(quote_res.get("need_transfer"))
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
||||
return AgentResponse(
|
||||
reply=reply_text,
|
||||
should_reply=not need_transfer,
|
||||
need_transfer=need_transfer,
|
||||
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
||||
)
|
||||
|
||||
remind = self._build_collect_remind(len(state.pending_image_urls))
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {remind}")
|
||||
return AgentResponse(reply=remind, should_reply=True, need_transfer=False)
|
||||
|
||||
# 构建提示词(包含对话状态 + 客户画像)
|
||||
user_prompt = self._build_prompt(message, state)
|
||||
|
||||
@@ -1300,7 +1446,7 @@ class CustomerServiceAgent:
|
||||
# 取出该客户的历史对话,传给 AI 保持上下文
|
||||
history = self.message_histories.get(message.from_id, [])
|
||||
|
||||
print(f"[Agent] ── 发送给AI的提示词 ──\n{user_prompt}\n────────────────────")
|
||||
self._log_block("PROMPT->AI 前置提示词", user_prompt)
|
||||
|
||||
try:
|
||||
msg_lower = message.msg.lower()
|
||||
@@ -1323,7 +1469,7 @@ class CustomerServiceAgent:
|
||||
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
|
||||
reply_text = self._normalize_reply_text(result.output)
|
||||
# 拦截超低杀价:客户报价低于底线时,统一礼貌拒绝
|
||||
try:
|
||||
from config.config import MIN_PRICE_FLOOR
|
||||
@@ -1371,16 +1517,17 @@ class CustomerServiceAgent:
|
||||
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', '')})")
|
||||
print(f"{self.C_TOOL}[THINK/TOOL_CALL]{self.C_RESET} {getattr(part, 'tool_name', '')}({getattr(part, 'args', '')})")
|
||||
elif 'ToolReturn' in part_type:
|
||||
ret = str(getattr(part, 'content', ''))[:120]
|
||||
print(f"[Agent] 工具返回: {ret}")
|
||||
print(f"{self.C_TOOL}[THINK/TOOL_RETURN]{self.C_RESET} {ret}")
|
||||
|
||||
print(f"[Agent] AI原始输出: {repr(reply_text)}")
|
||||
print(f"{self.C_THINK}[THINK/RAW_OUTPUT]{self.C_RESET} {repr(reply_text)}")
|
||||
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
print(f"[Agent] AI 调用失败: {e},使用兜底回复")
|
||||
metrics_emit("ai_call_failed", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
if "AccountOverdueError" in err_str or "overdue" in err_str.lower():
|
||||
asyncio.create_task(_notify_wechat_overdue())
|
||||
else:
|
||||
@@ -1392,6 +1539,8 @@ class CustomerServiceAgent:
|
||||
tag="AI异常"
|
||||
))
|
||||
reply_text = None
|
||||
else:
|
||||
metrics_emit("ai_call_success", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
|
||||
# AI 失败兜底:给一个不出错的万能回复
|
||||
if not reply_text:
|
||||
@@ -1434,6 +1583,7 @@ class CustomerServiceAgent:
|
||||
if reply_text and any(kw in reply_text for kw in transfer_keywords):
|
||||
need_transfer = True
|
||||
transfer_msg = TRANSFER_MESSAGE
|
||||
metrics_emit("transfer_to_human", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
|
||||
# 未成交记录:客户表达放弃且已报价过(转人工不记录)
|
||||
customer_text, _ = self._split_customer_text(message.msg)
|
||||
@@ -1451,6 +1601,9 @@ class CustomerServiceAgent:
|
||||
# 记录本次回复时间,供冷却期判断
|
||||
if should_reply:
|
||||
state.last_reply_at = datetime.now()
|
||||
print(f"{self.C_REPLY}[REPLY->CUSTOMER]{self.C_RESET} {reply_text}")
|
||||
else:
|
||||
print(f"{self.C_MUTED}[REPLY->CUSTOMER]{self.C_RESET} <静默/不发送>")
|
||||
|
||||
return AgentResponse(reply=reply_text, should_reply=should_reply, need_transfer=need_transfer, transfer_msg=transfer_msg)
|
||||
|
||||
@@ -1461,6 +1614,7 @@ class CustomerServiceAgent:
|
||||
if numbers:
|
||||
price = round(int(numbers[0]) / 5) * 5 # 强制为5的整数倍
|
||||
state.last_price = price
|
||||
metrics_emit("quote_generated", customer_id=state.customer_id, price=price)
|
||||
# 持久化到客户数据库,重启后仍可读取
|
||||
try:
|
||||
from db.customer_db import db
|
||||
@@ -1698,24 +1852,323 @@ class CustomerServiceAgent:
|
||||
|
||||
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 ""
|
||||
# 处理 "有吗#*#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 ""
|
||||
text = re.sub(r'https?://\S+', ' ', msg)
|
||||
text = text.replace("#*#", " ").strip()
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
return text.strip(",,。.!!??;;:: ")
|
||||
|
||||
def _is_batch_finish_signal(self, text: str) -> bool:
|
||||
"""客户是否表达“图发完了,可以统一报价”。"""
|
||||
if not text:
|
||||
return False
|
||||
finish_keywords = [
|
||||
"发完了", "都发完了", "发齐了", "齐了", "先这些", "就这些", "全部", "一起报", "统一报价",
|
||||
"总共多少钱", "一共多少钱", "打包价", "总价", "报价吧", "报个总价", "给个总价",
|
||||
]
|
||||
return any(k in text for k in finish_keywords)
|
||||
|
||||
def _build_collect_ack(self, count: int) -> str:
|
||||
templates = [
|
||||
"收到,这边先记下了(已收{n}张)。你继续发,等你发完我再一起给你打包报价。",
|
||||
"好的,当前这批先收到了(第{n}张)。还有图就继续发,发齐我一次性给你总价。",
|
||||
"没问题,已记录到第{n}张。你把需求和图片都发完,我统一给你报更合适的价格。",
|
||||
]
|
||||
return random.choice(templates).format(n=count)
|
||||
|
||||
def _build_collect_remind(self, count: int) -> str:
|
||||
templates = [
|
||||
"需求我记下了(当前共{n}张图)。你继续发齐,发完回我“发完了”,我一次性给你总价。",
|
||||
"好的,这条需求也加上了(现在{n}张)。等你说发完,我立刻统一报价。",
|
||||
"收到,这个要求我也记住了(共{n}张)。你发完我就给你打包价。",
|
||||
]
|
||||
return random.choice(templates).format(n=count)
|
||||
|
||||
def _append_requirement(self, state: ConversationState, text: str):
|
||||
"""追加需求并做去重/截断,减少上下文噪音。"""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return
|
||||
t = t[:120]
|
||||
if state.pending_requirements and state.pending_requirements[-1] == t:
|
||||
return
|
||||
if t in state.pending_requirements[-5:]:
|
||||
return
|
||||
state.pending_requirements.append(t)
|
||||
if len(state.pending_requirements) > 20:
|
||||
state.pending_requirements = state.pending_requirements[-20:]
|
||||
|
||||
def _calc_requirement_surcharge(self, requirements: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
把客户补充需求做成结构化加价,避免纯靠模型自由发挥导致价格波动。
|
||||
返回:
|
||||
{"extra": int, "hits": List[str]}
|
||||
"""
|
||||
text = " ".join(requirements or [])
|
||||
rules = [
|
||||
(["分层", "psd", "源文件"], 30, "分层/源文件"),
|
||||
(["去背景", "抠图", "透明底", "白底"], 5, "去背景"),
|
||||
(["换背景", "换场景", "合成"], 10, "合成/换背景"),
|
||||
(["改字", "改文字", "替换文字", "排版"], 10, "改文字/排版"),
|
||||
(["调色", "改色", "换色", "配色"], 5, "调色"),
|
||||
(["多版本", "多个版本", "两版", "三版"], 10, "多版本"),
|
||||
(["加急", "今天要", "马上要", "尽快"], 10, "加急"),
|
||||
]
|
||||
total = 0
|
||||
hits: List[str] = []
|
||||
for keywords, fee, label in rules:
|
||||
if any(k in text for k in keywords):
|
||||
total += fee
|
||||
hits.append(f"{label}+{fee}")
|
||||
# 防止需求加价过高,做个上限保护
|
||||
total = min(total, 60)
|
||||
# 金额统一 5 的倍数
|
||||
total = round(total / 5) * 5
|
||||
return {"extra": total, "hits": hits}
|
||||
|
||||
def _build_batch_quote_reply(
|
||||
self,
|
||||
results: List[Tuple[str, Dict[str, Any]]],
|
||||
total_suggest: int,
|
||||
bundle_price: int,
|
||||
req_fee: Dict[str, Any],
|
||||
) -> str:
|
||||
"""构建分图明细 + 单条总报价可选项回复。"""
|
||||
complexity_map = {
|
||||
"simple": "简单",
|
||||
"normal": "常规",
|
||||
"complex": "复杂",
|
||||
"hard": "高难",
|
||||
}
|
||||
detail_lines: List[str] = []
|
||||
for i, (_, r) in enumerate(results, 1):
|
||||
p = int(r.get("price_suggest", 20) or 20)
|
||||
cx = complexity_map.get(str(r.get("complexity", "normal")), "常规")
|
||||
reason = str(r.get("reason", "常规处理")).replace("\n", " ").strip()
|
||||
if len(reason) > 18:
|
||||
reason = reason[:18] + "..."
|
||||
detail_lines.append(f"图{i}:{p}元({cx},{reason})")
|
||||
|
||||
extra = int(req_fee.get("extra", 0) or 0)
|
||||
single_total = round((total_suggest + extra) / 5) * 5
|
||||
req_hit = "、".join(req_fee.get("hits", [])) if req_fee.get("hits") else ""
|
||||
|
||||
lines = ["先给你分图报下:"]
|
||||
lines.extend(detail_lines)
|
||||
if req_hit:
|
||||
lines.append(f"需求加价:+{extra}元({req_hit})")
|
||||
option_line = f"可选:A按单张做共{single_total}元;B打包一起做{bundle_price}元(更划算)。"
|
||||
lines.append(option_line)
|
||||
lines.append("你定一个方案,我这边马上安排。")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _quote_pending_images(self, state: ConversationState, message: CustomerMessage) -> Dict[str, Any]:
|
||||
"""
|
||||
批量识别待处理图片并统一处理:
|
||||
- find_image 意图且可自动处理:直接 Gemini 处理 + 上传图绘 + 回链接
|
||||
- 高风险/不可做:转人工
|
||||
- 其他:统一报价
|
||||
"""
|
||||
from image.image_analyzer import image_analyzer
|
||||
|
||||
urls = list(state.pending_image_urls)
|
||||
if not urls:
|
||||
return {"reply": "你先把图片发我,我看完再给你统一报价。", "need_transfer": False}
|
||||
try:
|
||||
from config.config import BATCH_MAX_IMAGES, BATCH_ANALYZE_CONCURRENCY
|
||||
max_images = max(1, int(BATCH_MAX_IMAGES))
|
||||
analyze_concurrency = max(1, int(BATCH_ANALYZE_CONCURRENCY))
|
||||
except Exception:
|
||||
max_images = 12
|
||||
analyze_concurrency = 3
|
||||
if len(urls) > max_images:
|
||||
return {
|
||||
"reply": f"这次图片有点多({len(urls)}张),我先按前{max_images}张处理报价,剩下的下一批继续发我。",
|
||||
"need_transfer": False,
|
||||
}
|
||||
urls = urls[:max_images]
|
||||
|
||||
sem = asyncio.Semaphore(analyze_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
|
||||
|
||||
results = list(await asyncio.gather(*[_analyze_one(u) for u in urls]))
|
||||
for url, r in results:
|
||||
# 与单图流程一致:识别后写入 workflow 任务
|
||||
try:
|
||||
from core.workflow import workflow
|
||||
await workflow.image_analysis_result(
|
||||
customer_id=message.from_id,
|
||||
image_url=url,
|
||||
complexity=r.get("complexity", "normal"),
|
||||
acc_id=message.acc_id,
|
||||
acc_type=message.acc_type,
|
||||
gemini_prompt=r.get("gemini_prompt", ""),
|
||||
aspect_ratio=r.get("aspect_ratio", "1:1"),
|
||||
perspective=r.get("perspective", "no"),
|
||||
proc_type=r.get("proc_type", ""),
|
||||
subject=r.get("subject", ""),
|
||||
quality=r.get("quality", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Agent] Workflow 批量任务创建失败: {e}")
|
||||
|
||||
total_min = sum(int(r.get("price_min", 15) or 15) for _, r in results)
|
||||
total_max = sum(int(r.get("price_max", 25) or 25) for _, r in results)
|
||||
total_suggest = sum(int(r.get("price_suggest", 20) or 20) for _, r in results)
|
||||
req_fee = self._calc_requirement_surcharge(state.pending_requirements)
|
||||
|
||||
# 打包优惠:2 张减 5,3 张及以上按 9 折(四舍五入到 5 元)
|
||||
if len(results) == 2:
|
||||
bundle_price = max(10, total_suggest - 5)
|
||||
elif len(results) >= 3:
|
||||
bundle_price = max(10, round(total_suggest * 0.9 / 5) * 5)
|
||||
else:
|
||||
bundle_price = total_suggest
|
||||
bundle_price += int(req_fee.get("extra", 0) or 0)
|
||||
bundle_price = round(bundle_price / 5) * 5
|
||||
|
||||
# 先分流:高风险/不可做 -> 转人工
|
||||
unsafe = []
|
||||
dense_text_reject = []
|
||||
for i, (_, r) in enumerate(results, 1):
|
||||
if r.get("feasibility") == "no" or r.get("risk") == "high":
|
||||
unsafe.append(f"图{i}")
|
||||
note = str(r.get("note", "") or "")
|
||||
if "文字内容过于密集" in note or "密集文字" in note:
|
||||
dense_text_reject.append(f"图{i}")
|
||||
if unsafe:
|
||||
state.pending_image_urls.clear()
|
||||
state.pending_requirements.clear()
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
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,
|
||||
}
|
||||
|
||||
# 查找图片意图:直接自动处理并返回图绘链接
|
||||
intent_text = (message.msg or "") + " " + " ".join(state.pending_requirements[-5:])
|
||||
workflow_type, _ = self.workflow_router.detect_workflow(intent_text)
|
||||
if workflow_type == "find_image":
|
||||
links = []
|
||||
try:
|
||||
from image.image_processor import image_processor
|
||||
from utils.image_queue import run_with_queue
|
||||
for idx, (url, r) in enumerate(results, 1):
|
||||
req_parts = [f"complexity:{r.get('complexity', 'normal')}"]
|
||||
if r.get("gemini_prompt"):
|
||||
req_parts.append(f"prompt:{r.get('gemini_prompt')}")
|
||||
if r.get("aspect_ratio"):
|
||||
req_parts.append(f"ratio:{r.get('aspect_ratio')}")
|
||||
if r.get("perspective") and r.get("perspective") != "no":
|
||||
req_parts.append(f"perspective:{r.get('perspective')}")
|
||||
if r.get("proc_type"):
|
||||
req_parts.append(f"proc_type:{r.get('proc_type')}")
|
||||
if r.get("subject"):
|
||||
req_parts.append(f"subject:{r.get('subject')}")
|
||||
if r.get("quality"):
|
||||
req_parts.append(f"quality:{r.get('quality')}")
|
||||
|
||||
process_res = await run_with_queue(image_processor.process_image(
|
||||
url,
|
||||
"enhance",
|
||||
requirements="|".join(req_parts),
|
||||
gemini_prompt=r.get("gemini_prompt", ""),
|
||||
aspect_ratio=r.get("aspect_ratio", "1:1"),
|
||||
perspective=r.get("perspective", "no"),
|
||||
proc_type=r.get("proc_type", ""),
|
||||
subject=r.get("subject", ""),
|
||||
quality=r.get("quality", ""),
|
||||
))
|
||||
if not process_res.get("success"):
|
||||
raise RuntimeError(process_res.get("message", "图片处理失败"))
|
||||
|
||||
ok, link, _ = await upload_to_tuhui(
|
||||
process_res["result_path"],
|
||||
title=f"客户{message.from_id[-4:]}-图片{idx}",
|
||||
description="AI自动处理结果",
|
||||
price=max(10, int(r.get("price_suggest", 20) or 20) + int(req_fee.get("extra", 0) or 0) // max(1, len(results))),
|
||||
)
|
||||
if not ok:
|
||||
raise RuntimeError(str(link))
|
||||
links.append(link)
|
||||
except Exception as e:
|
||||
print(f"[Agent] 自动处理并上传失败,回退统一报价: {e}")
|
||||
else:
|
||||
lines = [f"这批我先给你处理好了,按打包 {bundle_price} 元。"]
|
||||
for i, link in enumerate(links, 1):
|
||||
lines.append(f"链接{i}:{link}")
|
||||
lines.append("你先看下效果,没问题我就按这个标准继续给你做。")
|
||||
state.last_price = bundle_price
|
||||
try:
|
||||
from db.customer_db import db
|
||||
db.update_last_price(message.from_id, bundle_price)
|
||||
except Exception:
|
||||
pass
|
||||
state.pending_image_urls.clear()
|
||||
state.pending_requirements.clear()
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
return {"reply": "\n".join(lines), "need_transfer": False}
|
||||
|
||||
reply_text = self._build_batch_quote_reply(
|
||||
results=results,
|
||||
total_suggest=total_suggest,
|
||||
bundle_price=bundle_price,
|
||||
req_fee=req_fee,
|
||||
)
|
||||
|
||||
state.last_price = bundle_price
|
||||
try:
|
||||
from db.customer_db import db
|
||||
db.update_last_price(message.from_id, bundle_price)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 清空待报价队列(本轮已统一报价)
|
||||
state.pending_image_urls.clear()
|
||||
state.pending_requirements.clear()
|
||||
self._sync_pending_quote_state(message.from_id, state)
|
||||
return {"reply": reply_text, "need_transfer": False}
|
||||
|
||||
def _split_customer_text(self, msg: str) -> tuple:
|
||||
"""
|
||||
@@ -1823,11 +2276,11 @@ class CustomerServiceAgent:
|
||||
if shop_type == "gemini_api":
|
||||
prompt += "\n【Gemini API 店铺】客户问账号/pro/续费/套餐等,按 API 客服自然回复,不要求发图。"
|
||||
elif image_url:
|
||||
prompt += f"\n客户发来图片(URL: {image_url})。必须:① 调用 analyze_image('{image_url}') ② 拿到结果后直接回复报价,话术自然多变。分析完必须回复,不能不回复。"
|
||||
prompt += "\n客户在继续发图阶段:先确认“已收图”,并引导客户把图和要求一次发完;等客户明确“发完了/统一报价”后再统一报价。"
|
||||
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}') 后直接回复报价,不能不回复。"
|
||||
prompt += "\n客户在询问价格:若客户已确认发完,则给总报价;若还在发图,先引导发完后统一报价。"
|
||||
else:
|
||||
prompt += "\n客户在询问价格但未发图,回复「发图来我看看」。"
|
||||
elif any(kw in customer_text for kw in progress_keywords):
|
||||
|
||||
Reference in New Issue
Block a user