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

@@ -275,6 +275,32 @@ def health_check():
})
@app.route('/api/metrics', methods=['GET'])
def metrics_dashboard():
"""
运行与业务看板
Query:
- hours: 统计窗口小时数默认24
"""
try:
hours = int(request.args.get('hours', 24))
if hours <= 0:
hours = 24
from utils.metrics_tracker import get_dashboard
data = get_dashboard(hours=hours)
return jsonify({
'code': 200,
'message': 'OK',
'data': data,
})
except Exception as e:
logger.error(f"指标看板异常:{e}")
return jsonify({
'code': 500,
'message': f'服务器错误:{str(e)}'
}), 500
def start_http_server(host='0.0.0.0', port=6060, debug=False):
"""启动 HTTP 服务器"""
global task_manager, task_scheduler

View File

@@ -62,3 +62,31 @@ python scripts/init_designer_roster.py list # 查看当前数据
### 对接方要求(外部 AI 服务)
对端需实现:企微群消息 → 解析「上线」/「下线」→ 存库 → 提供 GET `/online` 接口,按上述格式返回在线名单。
---
## system_inquiry_rules.json
按店铺识别“系统客服询单”消息(非普通买家咨询),并配置处理策略。
```json
{
"enabled": true,
"default_action": "silent",
"default_reply": "您好,这边已收到询单消息,稍后由人工客服跟进处理。",
"sender_keywords": ["系统客服", "官方客服", "平台客服", "机器人客服"],
"message_keywords": ["询单", "代客咨询", "平台代问", "系统代发"],
"shops": {
"test_shop": {
"enabled": true,
"action": "reply",
"reply": "收到,已登记,稍后人工给您回。",
"sender_keywords": ["系统客服"],
"message_keywords": ["询单"]
}
}
}
```
- `action` 支持: `silent` / `reply` / `transfer`
- `shops.<acc_id>` 可覆盖全局规则(店铺维度)

View File

@@ -40,6 +40,7 @@ IMAGE_MODULE_ENABLED = os.getenv("IMAGE_MODULE_ENABLED", "false").lower() in ("1
# ========== 防抖配置 ==========
MESSAGE_DEBOUNCE_SECONDS = int(os.getenv("MESSAGE_DEBOUNCE_SECONDS", "8"))
OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS = int(os.getenv("OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS", "5"))
# ========== AI 上下文加载 ==========
CHAT_CONTEXT_LIMIT = int(os.getenv("CHAT_CONTEXT_LIMIT", "30"))
@@ -47,3 +48,27 @@ CHAT_CONTEXT_TRUNCATE_LEN = int(os.getenv("CHAT_CONTEXT_TRUNCATE_LEN", "160"))
# ========== 报价底线 ==========
MIN_PRICE_FLOOR = int(os.getenv("MIN_PRICE_FLOOR", "15"))
MAX_SERVICE_SIZE_LONGEST_METERS = float(os.getenv("MAX_SERVICE_SIZE_LONGEST_METERS", "10"))
MAX_SERVICE_SIZE_AREA_SQM = float(os.getenv("MAX_SERVICE_SIZE_AREA_SQM", "20"))
# ========== 批量报价/自动处理 ==========
BATCH_MAX_IMAGES = int(os.getenv("BATCH_MAX_IMAGES", "12")) # 单次最多处理图片数
BATCH_ANALYZE_CONCURRENCY = int(os.getenv("BATCH_ANALYZE_CONCURRENCY", "3")) # 批量识别并发数
# ========== 灰度开关 ==========
FEATURE_BATCH_QUOTE_ENABLED = os.getenv("FEATURE_BATCH_QUOTE_ENABLED", "true").lower() in ("1", "true", "yes")
FEATURE_BATCH_QUOTE_PERCENT = int(os.getenv("FEATURE_BATCH_QUOTE_PERCENT", "100")) # 0-100
FEATURE_BATCH_QUOTE_SHOPS = os.getenv("FEATURE_BATCH_QUOTE_SHOPS", "") # 逗号分隔 acc_id 白名单,空=全量
# ========== 系统客服询单识别 ==========
SYSTEM_INQUIRY_ENABLED = os.getenv("SYSTEM_INQUIRY_ENABLED", "true").lower() in ("1", "true", "yes")
SYSTEM_INQUIRY_DEFAULT_ACTION = os.getenv("SYSTEM_INQUIRY_DEFAULT_ACTION", "silent").strip().lower()
SYSTEM_INQUIRY_DEFAULT_REPLY = os.getenv(
"SYSTEM_INQUIRY_DEFAULT_REPLY",
"您好,这边已收到询单消息,稍后由人工客服跟进处理。",
)
SYSTEM_INQUIRY_SHOPS = os.getenv("SYSTEM_INQUIRY_SHOPS", "") # 逗号分隔店铺白名单,空=全店铺
SYSTEM_INQUIRY_RULES_FILE = os.getenv(
"SYSTEM_INQUIRY_RULES_FILE",
str(CONFIG_DIR / "system_inquiry_rules.json"),
)

View File

@@ -0,0 +1,20 @@
{
"enabled": true,
"default_action": "silent",
"default_reply": "您好,这边已收到询单消息,稍后由人工客服跟进处理。",
"sender_keywords": [
"系统客服",
"官方客服",
"平台客服",
"机器人客服",
"商家客服系统"
],
"message_keywords": [
"系统询单",
"代客咨询",
"平台代问",
"系统代发",
"客服询单"
],
"shops": {}
}

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):

View File

@@ -3,10 +3,12 @@ import websockets
import json
import re
import logging
import random
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Optional
from typing import Optional, Dict, Any, List
# ========== 转接分组映射 ==========
def _get_transfer_group(acc_id: str) -> str:
@@ -49,6 +51,7 @@ import os
logger = setup_logger()
from db.chat_log_db import log_message as _chat_log
from utils.metrics_tracker import emit as metrics_emit
# 导入 Agent 模块
try:
@@ -94,6 +97,8 @@ class QingjianAPIClient:
self._agent_semaphore = asyncio.Semaphore(8)
self._pending_images: dict = {}
self._pending_image_tasks: dict = {}
self._system_inquiry_rules = self._load_system_inquiry_rules()
self._last_reply_sent_at: dict = {} # customer_key -> monotonic ts
# 延迟加载任务模块(避免循环导入)
self.task_scheduler = None
@@ -289,7 +294,7 @@ class QingjianAPIClient:
if self._is_transfer_msg(data):
# 会话转交 → 主动打招呼
print(f"[{self.get_time()}] 收到转交消息,发送问候")
greeting = "在呢,发图来我看看"
greeting = self._pick_transfer_greeting()
await self.send_reply(data, greeting)
try:
from utils.wechat_chat_log import push_chat_to_wechat
@@ -324,6 +329,8 @@ class QingjianAPIClient:
))
except Exception:
pass
elif await self._handle_system_inquiry(data):
print(f"[{self.get_time()}] 系统客服询单消息,已按规则处理")
elif self._should_ignore(data):
print(f"[{self.get_time()}] 系统通知,跳过回复")
else:
@@ -529,6 +536,12 @@ class QingjianAPIClient:
except Exception:
pass
# 超大尺寸(米制)直接拒单,避免进入报价/处理流程
oversize_reply = self._oversize_reply_if_needed(msg_text)
if oversize_reply:
await self.send_reply(data, oversize_reply)
return
# 消息含图片URL累积到待处理列表先询问要求
if self._msg_has_image_url(msg_text):
urls = self._extract_image_urls(msg_text)
@@ -543,6 +556,7 @@ class QingjianAPIClient:
await self._flush_pending_images(capture_key, capture_data)
task = asyncio.create_task(_delay_flush(key, data))
self._pending_image_tasks[key] = task
return
elif self._msg_refers_images(msg_text):
urls = self._collect_recent_image_urls(_cid, data.get('acc_id', ''), max_count=6)
if urls:
@@ -550,20 +564,24 @@ class QingjianAPIClient:
self._add_pending_images(key, urls)
await self.send_reply(data, "稍等,我找找刚才那几张")
await self._flush_pending_images(key, data)
return
else:
status = self._detect_order_status(msg_text)
if status == "paid":
ack = "收到付款,我马上安排处理,有需要第一时间联系您"
await self.send_reply(data, ack)
return
elif status in ("waiting", "order"):
ack = "订单我看到了哈,方便的话请完成付款,我好安排处理"
await self.send_reply(data, ack)
return
else:
urls = self._extract_image_urls(msg_text)
if len(urls) == 1:
key = self._customer_key(data)
self._add_pending_images(key, urls)
await self.send_reply(data, "收到,我看看哈")
return
else:
if self._msg_requests_external_contact(msg_text):
reply = "这里沟通就可以哦,其他联系方式不方便"
@@ -603,9 +621,11 @@ class QingjianAPIClient:
if status == "paid":
ack = "收到付款,我马上安排处理,有需要第一时间联系您"
await self.send_reply(data, ack)
return
elif status in ("waiting", "order"):
ack = "订单我看到了哈,方便的话请完成付款,我好安排处理"
await self.send_reply(data, ack)
return
# 构建 CustomerMessage
customer_msg = CustomerMessage(
@@ -813,11 +833,68 @@ class QingjianAPIClient:
lower = msg.lower()
kws = ("加qq", "qq号", "vx", "微信", "加v", "联系方式", "私聊", "加一下", "加个", "手机号", "电话", "加群", "q q", "v 信")
return any(k in lower for k in kws)
@staticmethod
def _extract_size_pairs_m(msg: str) -> list[tuple[float, float]]:
"""提取消息中的米制尺寸对,如 15*6.4米 / 15米*6.4 / 15x6.4m。"""
if not msg:
return []
s = (msg or "").lower().replace("×", "*").replace("x", "*")
pairs = []
patterns = [
r'(\d+(?:\.\d+)?)\s*\*\s*(\d+(?:\.\d+)?)\s*(?:米|m)\b',
r'(\d+(?:\.\d+)?)\s*(?:米|m)\s*\*\s*(\d+(?:\.\d+)?)\b',
]
for p in patterns:
for m in re.findall(p, s):
try:
a = float(m[0])
b = float(m[1])
if a > 0 and b > 0:
pairs.append((a, b))
except Exception:
continue
return pairs
def _oversize_reply_if_needed(self, msg: str) -> str:
"""
检测超大尺寸需求并返回拒绝话术;未命中返回空字符串。
规则:最长边 > 阈值 或 面积 > 阈值。
"""
try:
from config.config import MAX_SERVICE_SIZE_LONGEST_METERS, MAX_SERVICE_SIZE_AREA_SQM
longest_limit = float(MAX_SERVICE_SIZE_LONGEST_METERS)
area_limit = float(MAX_SERVICE_SIZE_AREA_SQM)
except Exception:
longest_limit = 10.0
area_limit = 20.0
pairs = self._extract_size_pairs_m(msg)
for w, h in pairs:
longest = max(w, h)
area = w * h
if longest > longest_limit or area > area_limit:
return (
f"{w:g}米*{h:g}米这个尺寸太大了,我们这边做不了。"
"如果要做可以拆成几段小尺寸,我再给你按段评估。"
)
return ""
def _is_transfer_msg(self, data: dict) -> bool:
"""判断是否是会话转交消息(需要主动打招呼)"""
msg = self.to_chinese(data.get('msg', ''))
return '转交给' in msg or '转接给' in msg
def _pick_transfer_greeting(self) -> str:
"""转接后问候话术:简短自然,随机避免机械感。"""
choices = [
"在的亲,发图我看下",
"在呢亲,有需求直接说",
"我在的,您把要求发我",
"在的哈,你说我这边看着处理",
"在呢,图和需求发来我看看",
]
return random.choice(choices)
def _is_shop_card(self, data: dict) -> bool:
"""判断是否是进店卡片消息"""
msg = self.to_chinese(data.get('msg', ''))
@@ -838,6 +915,130 @@ class QingjianAPIClient:
except Exception:
return False
def _load_system_inquiry_rules(self) -> Dict[str, Any]:
"""加载系统客服询单规则(全局 + 店铺覆盖)。"""
from config.config import (
SYSTEM_INQUIRY_ENABLED,
SYSTEM_INQUIRY_DEFAULT_ACTION,
SYSTEM_INQUIRY_DEFAULT_REPLY,
SYSTEM_INQUIRY_RULES_FILE,
)
enabled_env = os.getenv("SYSTEM_INQUIRY_ENABLED")
enabled = (
enabled_env.lower() in ("1", "true", "yes")
if isinstance(enabled_env, str)
else bool(SYSTEM_INQUIRY_ENABLED)
)
action = (os.getenv("SYSTEM_INQUIRY_DEFAULT_ACTION") or SYSTEM_INQUIRY_DEFAULT_ACTION or "silent").strip().lower()
reply = os.getenv("SYSTEM_INQUIRY_DEFAULT_REPLY") or SYSTEM_INQUIRY_DEFAULT_REPLY or ""
rules_file = os.getenv("SYSTEM_INQUIRY_RULES_FILE") or str(SYSTEM_INQUIRY_RULES_FILE)
defaults: Dict[str, Any] = {
"enabled": bool(enabled),
"default_action": action,
"default_reply": reply,
"sender_keywords": ["系统客服", "官方客服", "平台客服", "机器人客服", "商家客服系统"],
"message_keywords": ["系统询单", "代客咨询", "平台代问", "系统代发", "客服询单"],
"shops": {},
}
try:
p = Path(rules_file)
if p.exists():
with p.open("r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
defaults.update(loaded)
except Exception as e:
logger.warning(f"系统询单规则加载失败,使用默认规则: {e}")
return defaults
@staticmethod
def _normalize_kw_list(v: Any) -> List[str]:
if not isinstance(v, list):
return []
return [str(x).strip().lower() for x in v if str(x).strip()]
def _resolve_system_inquiry_policy(self, acc_id: str) -> Dict[str, Any]:
"""根据店铺合并系统询单策略。"""
from config.config import SYSTEM_INQUIRY_SHOPS
rules = self._system_inquiry_rules or {}
if not bool(rules.get("enabled", True)):
return {"enabled": False}
shops_env = os.getenv("SYSTEM_INQUIRY_SHOPS", SYSTEM_INQUIRY_SHOPS or "")
shop_whitelist = [s.strip() for s in shops_env.split(",") if s.strip()]
if shop_whitelist and (acc_id or "") not in shop_whitelist:
return {"enabled": False}
policy: Dict[str, Any] = {
"enabled": True,
"action": str(rules.get("default_action", "silent")).strip().lower(),
"reply": str(rules.get("default_reply", "")).strip(),
"sender_keywords": self._normalize_kw_list(rules.get("sender_keywords")),
"message_keywords": self._normalize_kw_list(rules.get("message_keywords")),
}
shop_cfg = (rules.get("shops") or {}).get(acc_id or "", {})
if isinstance(shop_cfg, dict):
if "enabled" in shop_cfg and not bool(shop_cfg.get("enabled", True)):
return {"enabled": False}
if shop_cfg.get("action"):
policy["action"] = str(shop_cfg.get("action")).strip().lower()
if shop_cfg.get("reply"):
policy["reply"] = str(shop_cfg.get("reply")).strip()
if isinstance(shop_cfg.get("sender_keywords"), list):
policy["sender_keywords"] = self._normalize_kw_list(shop_cfg.get("sender_keywords"))
if isinstance(shop_cfg.get("message_keywords"), list):
policy["message_keywords"] = self._normalize_kw_list(shop_cfg.get("message_keywords"))
if policy["action"] not in ("silent", "reply", "transfer"):
policy["action"] = "silent"
return policy
def _match_system_inquiry(self, data: dict, policy: Dict[str, Any]) -> bool:
"""识别是否为系统客服询单消息。"""
if not policy.get("enabled", False):
return False
from_name = self.to_chinese(data.get("from_name", "") or "").lower()
from_id = str(data.get("from_id", "") or "").lower()
msg = self.to_chinese(data.get("msg", "") or "").lower()
sender_hits = 0
for kw in policy.get("sender_keywords", []):
if kw and (kw in from_name or kw in from_id):
sender_hits += 1
message_hits = 0
for kw in policy.get("message_keywords", []):
if kw and kw in msg:
message_hits += 1
# 优先看发送者特征;纯文本命中时至少要求两个关键词,降低误判风险
return sender_hits > 0 or message_hits >= 2
async def _handle_system_inquiry(self, data: dict) -> bool:
"""命中系统询单后按策略处理。"""
acc_id = data.get("acc_id", "")
policy = self._resolve_system_inquiry_policy(acc_id)
if not self._match_system_inquiry(data, policy):
return False
customer_id = data.get("from_id", "")
metrics_emit("system_inquiry_detected", customer_id=customer_id, acc_id=acc_id)
action = policy.get("action", "silent")
logger.info(f"系统询单命中 | 店铺:{acc_id} | 客户:{customer_id} | action:{action}")
if action == "reply":
reply = policy.get("reply") or "您好,这边已收到询单消息,稍后由人工客服跟进处理。"
await self.send_reply(data, reply)
metrics_emit("system_inquiry_auto_reply", customer_id=customer_id, acc_id=acc_id)
return True
if action == "transfer":
await self.transfer_to_human(data, "系统询单转人工")
metrics_emit("system_inquiry_transfer", customer_id=customer_id, acc_id=acc_id)
return True
metrics_emit("system_inquiry_ignored", customer_id=customer_id, acc_id=acc_id)
return True
def _should_ignore(self, data: dict) -> bool:
"""判断是否应该忽略该消息(不回复)"""
msg = self.to_chinese(data.get('msg', ''))
@@ -1108,6 +1309,23 @@ class QingjianAPIClient:
print(f"[{self.get_time()}] 错误: 未连接到服务器")
return
# 同一客户外发限流N 秒内最多 1 条
try:
from config.config import OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS
cooldown = max(0, int(OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS))
except Exception:
cooldown = 5
if cooldown > 0:
ckey = f"{original_msg.get('acc_id', '')}:{original_msg.get('from_id', '')}"
now_mono = time.monotonic()
last = self._last_reply_sent_at.get(ckey, 0.0)
if (now_mono - last) < cooldown:
logger.info(
f"外发限流命中,跳过发送 | 客户:{ckey} | cooldown:{cooldown}s | msg:{str(reply_content)[:40]}"
)
return
self._last_reply_sent_at[ckey] = now_mono
shop_id = original_msg.get("acc_id", "")
# 根据轻简API文档

View File

@@ -65,6 +65,8 @@ class CustomerProfile:
last_gemini_prompt: str = "" # 最近一次图片的 Gemini 处理提示词
last_aspect_ratio: str = "1:1" # 最近一次图片的建议输出比例
last_perspective: str = "no" # 最近一次图片的透视状态
pending_quote_images: List[str] = None # 待统一报价图片队列(持久化)
pending_quote_requirements: List[str] = None # 待统一报价需求队列(持久化)
# 当前任务状态
processing_status: str = "" # 待处理/处理中/等待确认/已完成
@@ -148,6 +150,10 @@ class CustomerProfile:
self.image_type_history = []
if self.upsell_opportunity is None:
self.upsell_opportunity = []
if self.pending_quote_images is None:
self.pending_quote_images = []
if self.pending_quote_requirements is None:
self.pending_quote_requirements = []
class CustomerDatabase:
@@ -423,6 +429,24 @@ class CustomerDatabase:
profile.last_perspective = perspective
self.save_customer(profile)
def update_pending_quote_state(
self,
customer_id: str,
images: List[str],
requirements: List[str]
):
"""持久化收图阶段状态,防止服务重启丢失。"""
profile = self.get_customer(customer_id)
profile.pending_quote_images = list(images or [])
profile.pending_quote_requirements = list(requirements or [])
self.save_customer(profile)
def clear_pending_quote_state(self, customer_id: str):
profile = self.get_customer(customer_id)
profile.pending_quote_images = []
profile.pending_quote_requirements = []
self.save_customer(profile)
def update_processing_status(self, customer_id: str, status: str, image_url: str = "", expected_done_at: str = ""):
"""更新当前任务处理状态"""
profile = self.get_customer(customer_id)

View File

@@ -239,6 +239,11 @@ class ImageAnalyzer:
# 最短等待时间即使AI极快返回也等这么久看起来像真人在找
MIN_WAIT_SECONDS = 4
DENSE_TEXT_SUBJECT_KEYWORDS = (
"宣传栏", "公告栏", "展板", "海报墙", "通知栏", "知识栏", "制度牌", "公示栏", "墙报", "密密麻麻",
"word wall", "poster wall", "bulletin board",
)
async def analyze(self, image_path: str) -> dict:
"""
异步分析图片复杂度(使用火山引擎 /responses 接口)。
@@ -519,6 +524,8 @@ class ImageAnalyzer:
sensitive = p(content, "敏感内容:", "敏感内容:").lower().strip()
flatness = p(content, "平整度:", "平整度:").lower().strip() # flat|mild|rough
has_text = p(content, "含文字:", "含文字:").lower().strip()
text_amount = p(content, "文字数量:", "文字数量:").strip()
text_layer_need = p(content, "文字分层需求:", "文字分层需求:").lower().strip()
has_face = p(content, "含人脸:", "含人脸:").lower().strip()
has_shadow = p(content, "阴影:", "阴影:").lower().strip()
reason = p(content, "原因:", "原因:")
@@ -534,11 +541,22 @@ class ImageAnalyzer:
if has_face not in ("yes", "no"):
has_face = "no"
valid_text_amounts = {"none", "少量 (1-10 字)", "中量 (11-50 字)", "大量 (51-200 字)", "极多 (200 字以上)"}
if text_amount not in valid_text_amounts:
text_amount = "none"
if text_layer_need not in ("yes", "no"):
text_layer_need = "no"
if risk not in ("none", "low", "high"):
risk = "none"
if perspective not in ("no", "mild", "strong"):
perspective = "no"
# 识别“密集文字场景”关键词(中文 + 英文兜底)
dense_text_scene = any(
kw in ((subject or "") + " " + (proc_type or "") + " " + (reason or "")).lower()
for kw in self.DENSE_TEXT_SUBJECT_KEYWORDS
)
# 校验比例合法性
valid_ratios = {"1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5"}
if aspect_ratio not in valid_ratios:
@@ -556,11 +574,11 @@ class ImageAnalyzer:
if has_text == "yes":
if complexity == "simple":
# 简单但含文字 → 提升到 normal 价格
price_min, price_max = self.PRICE_MAP["normal"]
price_min, price_max, _ = self.PRICE_MAP["normal"]
reason = "含文字,需精细处理"
elif complexity == "normal":
# normal 含文字 → 提升到 complex 价格
price_min, price_max = self.PRICE_MAP["complex"]
price_min, price_max, _ = self.PRICE_MAP["complex"]
reason = "含文字,需精细处理"
# complex/hard 保持原价,已经够高
# 建议报价complex/hard 取固定值simple/normal 取中间且必须为5的整数倍
@@ -605,6 +623,14 @@ class ImageAnalyzer:
price_suggest = 80
reason += " | 大量文字分层"
# 硬规则:密集文字类图片不接单(知识宣传栏/公告栏等)
if text_amount in ["大量 (51-200 字)", "极多 (200 字以上)"] or dense_text_scene:
feasibility = "no"
risk = "high"
note = "文字内容过于密集(如宣传栏/公告栏),暂不接单处理"
reason = (reason or "文字密集") + " | 密集文字场景不接单"
price_suggest = 0
# 确保是 5 的倍数
price_suggest = round(price_suggest / 5) * 5
@@ -649,6 +675,10 @@ class ImageAnalyzer:
def _fallback(self, reason: str) -> dict:
"""识别失败时的默认结果(返回 normal让人工判断"""
print(f"[ImageAnalyzer] 识别失败,使用默认值: {reason}")
text_amount = "none"
text_layer_need = "no"
text_surcharge = 0
layer_surcharge = 0
return {
"complexity": "normal",
"reason": reason,

View File

@@ -14,11 +14,19 @@ import time
from datetime import datetime
from pathlib import Path
import logging
from dotenv import load_dotenv
from utils.metrics_tracker import emit as metrics_emit
from utils.service_base import BaseService
logger = logging.getLogger(__name__)
load_dotenv()
GEMINI_IMAGE_MODEL = os.getenv("GEMINI_IMAGE_MODEL", "gemini-3.1-flash-image-preview")
GEMINI_IMAGE_FALLBACK_MODEL = os.getenv("GEMINI_IMAGE_FALLBACK_MODEL", "gemini-2.5-flash-image")
GEMINI_IMAGE_SIZE = os.getenv("GEMINI_IMAGE_SIZE", "1K")
GEMINI_THINKING_LEVEL = os.getenv("GEMINI_THINKING_LEVEL", "MINIMAL")
GEMINI_PERSON_GENERATION = os.getenv("GEMINI_PERSON_GENERATION", "")
class GeminiExtractV2Service(BaseService):
@@ -46,17 +54,26 @@ class GeminiExtractV2Service(BaseService):
"name": "西风接口$0.014",
"api_key": "sk-uRuvzLfIHsc3BiHZ2cyebk0cYsZ8NR9rLL326QqXCKIy9EpK",
"api_url": "https://api.apiqik.online/v1beta/models",
"api_model": "gemini-2.5-flash-image", # 更稳定的模型
"max_retries": 2, # 贵接口少重试
"api_model": GEMINI_IMAGE_MODEL,
"max_retries": 2,
"cost": "",
"use_gemini_format": True # 使用Gemini原生API格式
},
{
"name": "西风接口Fallback",
"api_key": "sk-uRuvzLfIHsc3BiHZ2cyebk0cYsZ8NR9rLL326QqXCKIy9EpK",
"api_url": "https://api.apiqik.online/v1beta/models",
"api_model": GEMINI_IMAGE_FALLBACK_MODEL,
"max_retries": 1,
"cost": "",
"use_gemini_format": True
},
{
"name": "最贵的",
"api_key": "sk-8i7uYE0RtnQwDImV8a5f7014DcAb46F6BcEb72Df92218aC8",
"api_url": "https://api.laozhang.ai/v1/chat/completions",
"api_model": "gemini-2.5-flash-image-preview",
"api_model": GEMINI_IMAGE_MODEL,
"max_retries": 1,
"cost": ""
}
@@ -90,6 +107,9 @@ class GeminiExtractV2Service(BaseService):
output_path: str,
custom_prompt: str = None,
aspect_ratio: str = "1:1",
image_size: str = "",
person_generation: str = "",
thinking_level: str = "",
) -> tuple[bool, str, dict]:
"""
使用多API配置进行印花图案提取
@@ -113,6 +133,7 @@ class GeminiExtractV2Service(BaseService):
# 按优先级逐个尝试API配置
for config_index, config in enumerate(self.API_CONFIGS):
logger.info(f"尝试使用API: {config['name']} (成本: {config['cost']})")
metrics_emit("gemini_request", model=config.get("api_model", ""), provider=config.get("name", ""))
# 对每个API配置进行重试
for attempt in range(config['max_retries']):
@@ -129,9 +150,22 @@ class GeminiExtractV2Service(BaseService):
# 有效比例列表Auto 不传 aspectRatio
valid_ratios = {"1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5"}
valid_sizes = {"1K", "2K", "4K"}
valid_thinking = {"MINIMAL", "LOW", "MEDIUM", "HIGH"}
image_config = {}
if aspect_ratio in valid_ratios:
image_config["aspectRatio"] = aspect_ratio
size_val = (image_size or GEMINI_IMAGE_SIZE or "").upper().strip()
if size_val in valid_sizes:
image_config["imageSize"] = size_val
person_val = (person_generation or GEMINI_PERSON_GENERATION or "").strip()
if person_val:
# 中转接口若支持该字段会生效;不设置时不发送,保证兼容
image_config["personGeneration"] = person_val
thinking_val = (thinking_level or GEMINI_THINKING_LEVEL or "").upper().strip()
thinking_config = {}
if thinking_val in valid_thinking:
thinking_config["thinkingLevel"] = thinking_val
data = {
"contents": [
@@ -153,9 +187,13 @@ class GeminiExtractV2Service(BaseService):
"generationConfig": {
"responseModalities": ["IMAGE"],
**({"imageConfig": image_config} if image_config else {}),
**({"thinkingConfig": thinking_config} if thinking_config else {}),
}
}
logger.info(f"Gemini 生成配置: 比例={aspect_ratio} 格式=JPEG")
logger.info(
f"Gemini 生成配置: 比例={aspect_ratio} 尺寸={image_config.get('imageSize', '默认')} "
f"person={image_config.get('personGeneration', '默认')} thinking={thinking_config.get('thinkingLevel', '默认')}"
)
else:
# OpenAI兼容格式
api_url = config['api_url']
@@ -212,6 +250,25 @@ class GeminiExtractV2Service(BaseService):
continue
result = await response.json()
# Gemini 偶发只返回文本不返回图片NO_IMAGE 时快速重试/降级
if config.get('use_gemini_format', False):
finish_reason = ""
try:
finish_reason = (
(result.get("candidates") or [{}])[0].get("finishReason", "")
)
except Exception:
finish_reason = ""
if finish_reason == "NO_IMAGE":
logger.warning(
f"{config['name']} 返回 NO_IMAGE (模型={config.get('api_model')}),第{attempt + 1}"
)
metrics_emit("gemini_no_image", model=config.get("api_model", ""), provider=config.get("name", ""))
if attempt == config['max_retries'] - 1:
logger.warning(f"{config['name']} NO_IMAGE 重试已用完,切换下一个配置")
break
await asyncio.sleep(1 + attempt)
continue
except (aiohttp.ClientError, asyncio.TimeoutError, AssertionError) as e:
logger.error(f"{config['name']} 网络连接错误 (第{attempt + 1}次): {str(e)}")
@@ -235,6 +292,7 @@ class GeminiExtractV2Service(BaseService):
if success:
logger.info(f"使用 {config['name']} 成功完成印花提取")
metrics_emit("gemini_success", model=config.get("api_model", ""), provider=config.get("name", ""))
try:
from utils.api_cost_tracker import record
record("gemini_extract", count=1)

View File

@@ -0,0 +1,66 @@
import unittest
from unittest.mock import AsyncMock, patch
from core.pydantic_ai_agent import CustomerMessage, CustomerServiceAgent
class BatchQuoteReplyFormatTest(unittest.IsolatedAsyncioTestCase):
async def test_batch_reply_contains_per_image_and_options(self):
agent = CustomerServiceAgent()
cid = "__batch_quote_case__"
st = agent._get_conversation_state(cid)
st.pending_image_urls = ["https://img.alicdn.com/a.jpg", "https://img.alicdn.com/b.jpg"]
st.pending_requirements = ["去背景", "加急"]
msg = CustomerMessage(
msg_id="m-batch-1",
acc_id="test_shop",
msg="发完了,统一报价",
from_id=cid,
from_name="t",
cy_id=cid,
acc_type="AliWorkbench",
msg_type=0,
cy_name="t",
goods_name="专业找图",
goods_order="",
)
fake_r1 = {
"complexity": "normal",
"reason": "常规处理",
"price_min": 15,
"price_max": 25,
"price_suggest": 20,
"feasibility": "yes",
"risk": "low",
"aspect_ratio": "1:1",
"perspective": "no",
}
fake_r2 = {
"complexity": "complex",
"reason": "细节较多",
"price_min": 20,
"price_max": 30,
"price_suggest": 25,
"feasibility": "yes",
"risk": "low",
"aspect_ratio": "1:1",
"perspective": "no",
}
with patch("image.image_analyzer.image_analyzer.analyze", new=AsyncMock(side_effect=[fake_r1, fake_r2])):
with patch("core.workflow.workflow.image_analysis_result", new=AsyncMock(return_value=None)):
res = await agent._quote_pending_images(st, msg)
self.assertFalse(res.get("need_transfer", False))
reply = res.get("reply", "")
self.assertIn("图1", reply)
self.assertIn("图2", reply)
self.assertIn("可选", reply)
self.assertIn("打包", reply)
self.assertIn("", reply)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,40 @@
import os
import unittest
from websockets.protocol import State
from core.websocket_client import QingjianAPIClient
class _DummyWS:
def __init__(self):
self.state = State.OPEN
self.sent = []
async def send(self, msg_json: str):
self.sent.append(msg_json)
class OutboundCooldownTest(unittest.IsolatedAsyncioTestCase):
def setUp(self):
os.environ["OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS"] = "5"
async def test_skip_second_reply_within_cooldown(self):
c = QingjianAPIClient(enable_agent=False)
c.websocket = _DummyWS()
msg = {
"acc_id": "shop_a",
"from_id": "u001",
"from_name": "u001",
"acc_type": "AliWorkbench",
}
await c.send_reply(msg, "第一条")
await c.send_reply(msg, "第二条")
self.assertEqual(len(c.websocket.sent), 1)
def tearDown(self):
os.environ.pop("OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS", None)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,34 @@
import os
import unittest
from core.websocket_client import QingjianAPIClient
class OversizeGuardTest(unittest.TestCase):
def setUp(self):
os.environ["MAX_SERVICE_SIZE_LONGEST_METERS"] = "10"
os.environ["MAX_SERVICE_SIZE_AREA_SQM"] = "20"
def test_extract_size_pairs(self):
c = QingjianAPIClient(enable_agent=False)
pairs = c._extract_size_pairs_m("15*6.4米 高度")
self.assertTrue(len(pairs) >= 1)
self.assertEqual(pairs[0], (15.0, 6.4))
def test_oversize_hits(self):
c = QingjianAPIClient(enable_agent=False)
r = c._oversize_reply_if_needed("15*6.4米")
self.assertIn("做不了", r)
def test_normal_size_not_hit(self):
c = QingjianAPIClient(enable_agent=False)
r = c._oversize_reply_if_needed("2.4*1.2米")
self.assertEqual(r, "")
def tearDown(self):
os.environ.pop("MAX_SERVICE_SIZE_LONGEST_METERS", None)
os.environ.pop("MAX_SERVICE_SIZE_AREA_SQM", None)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,76 @@
import os
import unittest
from unittest.mock import AsyncMock
from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage
from db.customer_db import db
class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.customer_id = "__regression_test_customer__"
db.clear_pending_quote_state(self.customer_id)
os.environ["FEATURE_BATCH_QUOTE_ENABLED"] = "true"
os.environ["FEATURE_BATCH_QUOTE_PERCENT"] = "100"
os.environ["FEATURE_BATCH_QUOTE_SHOPS"] = ""
async def test_collect_images_then_ack(self):
agent = CustomerServiceAgent()
msg = CustomerMessage(
msg_id="m1",
acc_id="test_shop",
msg="https://img.alicdn.com/a.jpg#*#https://img.alicdn.com/b.jpg",
from_id=self.customer_id,
from_name="t",
cy_id=self.customer_id,
acc_type="AliWorkbench",
msg_type=0,
cy_name="t",
goods_name="专业找图",
goods_order="",
)
resp = await agent.process_message(msg)
self.assertTrue(resp.should_reply)
self.assertIn("", resp.reply)
st = agent._get_conversation_state(self.customer_id)
self.assertEqual(len(st.pending_image_urls), 2)
async def test_finish_signal_triggers_batch_quote(self):
agent = CustomerServiceAgent()
st = agent._get_conversation_state(self.customer_id)
st.pending_image_urls = ["https://img.alicdn.com/a.jpg"]
st.pending_requirements = ["去背景"]
agent._sync_pending_quote_state(self.customer_id, st)
agent._quote_pending_images = AsyncMock(return_value={"reply": "打包15元确认我就安排", "need_transfer": False})
msg = CustomerMessage(
msg_id="m2",
acc_id="test_shop",
msg="发完了,报价吧",
from_id=self.customer_id,
from_name="t",
cy_id=self.customer_id,
acc_type="AliWorkbench",
msg_type=0,
cy_name="t",
goods_name="专业找图",
goods_order="",
)
resp = await agent.process_message(msg)
self.assertTrue(resp.should_reply)
self.assertIn("15", resp.reply)
agent._quote_pending_images.assert_awaited()
async def test_pending_state_restore(self):
db.update_pending_quote_state(self.customer_id, ["u1", "u2"], ["r1"])
agent = CustomerServiceAgent()
st = agent._get_conversation_state(self.customer_id)
self.assertEqual(st.pending_image_urls, ["u1", "u2"])
self.assertEqual(st.pending_requirements, ["r1"])
def tearDown(self):
db.clear_pending_quote_state(self.customer_id)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,70 @@
import os
import unittest
from unittest.mock import AsyncMock
from core.websocket_client import QingjianAPIClient
class SystemInquiryRulesTest(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.rules = {
"enabled": True,
"default_action": "silent",
"default_reply": "已收到",
"sender_keywords": ["系统客服", "官方客服"],
"message_keywords": ["系统询单", "代客咨询"],
"shops": {
"shop_reply": {
"enabled": True,
"action": "reply",
"reply": "店铺回复模板",
"sender_keywords": ["机器人客服"],
"message_keywords": ["询单"],
}
},
}
os.environ["SYSTEM_INQUIRY_ENABLED"] = "true"
os.environ["SYSTEM_INQUIRY_SHOPS"] = ""
async def test_detect_by_sender_keyword(self):
client = QingjianAPIClient(enable_agent=False)
client._system_inquiry_rules = self.rules
policy = client._resolve_system_inquiry_policy("shop_a")
data = {"acc_id": "shop_a", "from_name": "平台系统客服", "from_id": "kefu001", "msg": "你好"}
self.assertTrue(client._match_system_inquiry(data, policy))
async def test_shop_rule_reply_action(self):
client = QingjianAPIClient(enable_agent=False)
client._system_inquiry_rules = self.rules
client.send_reply = AsyncMock()
client.transfer_to_human = AsyncMock()
data = {
"acc_id": "shop_reply",
"from_name": "机器人客服A",
"from_id": "robot_01",
"msg": "有个询单请处理",
"acc_type": "AliWorkbench",
}
handled = await client._handle_system_inquiry(data)
self.assertTrue(handled)
client.send_reply.assert_awaited_once()
client.transfer_to_human.assert_not_awaited()
async def test_shop_whitelist_blocks_other_shops(self):
os.environ["SYSTEM_INQUIRY_SHOPS"] = "shop_only"
client = QingjianAPIClient(enable_agent=False)
client._system_inquiry_rules = self.rules
client.send_reply = AsyncMock()
data = {"acc_id": "shop_other", "from_name": "系统客服", "from_id": "sys_1", "msg": "系统询单"}
handled = await client._handle_system_inquiry(data)
self.assertFalse(handled)
client.send_reply.assert_not_awaited()
def tearDown(self):
for k in ("SYSTEM_INQUIRY_ENABLED", "SYSTEM_INQUIRY_SHOPS"):
os.environ.pop(k, None)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -0,0 +1,20 @@
import unittest
from core.websocket_client import QingjianAPIClient
class TransferGreetingContextTest(unittest.TestCase):
def test_transfer_greeting_is_non_empty(self):
c = QingjianAPIClient(enable_agent=False)
text = c._pick_transfer_greeting()
self.assertTrue(isinstance(text, str) and len(text) > 0)
def test_transfer_greeting_contains_presence_phrase(self):
c = QingjianAPIClient(enable_agent=False)
for _ in range(10):
text = c._pick_transfer_greeting()
self.assertTrue(("" in text) or ("我在" in text))
if __name__ == "__main__":
unittest.main(verbosity=2)

104
utils/metrics_tracker.py Normal file
View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
轻量指标采集器
- 事件落盘到 JSONL
- 提供近 N 小时聚合,给 /api/metrics 使用
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Any
ROOT = Path(__file__).resolve().parent.parent
METRICS_FILE = ROOT / "config" / ".runtime_metrics.jsonl"
def _now_iso() -> str:
return datetime.now().isoformat(timespec="seconds")
def emit(event: str, **fields: Any):
"""记录一条事件,失败不抛异常。"""
try:
METRICS_FILE.parent.mkdir(parents=True, exist_ok=True)
payload = {"ts": _now_iso(), "event": event}
payload.update(fields or {})
with METRICS_FILE.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
except Exception:
pass
def _iter_recent(hours: int = 24):
if not METRICS_FILE.exists():
return []
cutoff = datetime.now() - timedelta(hours=hours)
items = []
try:
with METRICS_FILE.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
ts = datetime.fromisoformat(obj.get("ts", ""))
except Exception:
continue
if ts >= cutoff:
items.append(obj)
except Exception:
return []
return items
def get_runtime_summary(hours: int = 24) -> Dict[str, Any]:
"""近 N 小时运行指标。"""
rows = _iter_recent(hours=hours)
counts: Dict[str, int] = {}
for r in rows:
e = r.get("event", "unknown")
counts[e] = counts.get(e, 0) + 1
inbound = counts.get("inbound_msg", 0)
transfer = counts.get("transfer_to_human", 0)
quote = counts.get("quote_generated", 0)
ai_fail = counts.get("ai_call_failed", 0)
gemini_req = counts.get("gemini_request", 0)
no_image = counts.get("gemini_no_image", 0)
return {
"window_hours": hours,
"counts": counts,
"rates": {
"transfer_rate": round((transfer / inbound) * 100, 2) if inbound else 0.0,
"quote_rate": round((quote / inbound) * 100, 2) if inbound else 0.0,
"ai_fail_rate": round((ai_fail / inbound) * 100, 2) if inbound else 0.0,
"no_image_rate": round((no_image / gemini_req) * 100, 2) if gemini_req else 0.0,
},
}
def get_dashboard(hours: int = 24) -> Dict[str, Any]:
"""业务+运行看板聚合。"""
runtime = get_runtime_summary(hours=hours)
try:
from db.deal_outcome_db import get_daily_summary
daily = get_daily_summary()
deal_ok = int(daily.get("成交数", 0))
deal_fail = int(daily.get("未成交数", 0))
total = deal_ok + deal_fail
conversion = round((deal_ok / total) * 100, 2) if total else 0.0
except Exception:
deal_ok = deal_fail = 0
conversion = 0.0
return {
"runtime": runtime,
"business": {
"deal_success": deal_ok,
"deal_fail": deal_fail,
"conversion_rate": conversion,
},
}