feat: upgrade客服多店铺分流、批量报价与稳定性防护

This commit is contained in:
2026-02-28 18:52:31 +08:00
parent c39840fe15
commit 46143be86c
16 changed files with 1329 additions and 37 deletions

View File

@@ -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 张减 53 张及以上按 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):