refactor: migrate workflow to v2 core and archive legacy modules
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
29
core/adapters/base.py
Normal file
29
core/adapters/base.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from core.schema import StandardMessage, StandardResponse
|
||||
|
||||
class BaseAdapter(ABC):
|
||||
"""
|
||||
消息适配器基类 (Interface)
|
||||
所有的平台接口(千牛、微信等)都必须继承并实现这几个方法
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def translate_inbound(self, raw_msg: any) -> StandardMessage:
|
||||
"""
|
||||
[接收]:把各个平台的原始 JSON 数据,格式化为大脑认的 StandardMessage
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def translate_outbound(self, response: StandardResponse, user_id: str):
|
||||
"""
|
||||
[发送]:把大脑生成的 StandardResponse,翻译回平台原生的接口发出去
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def platform_id(self) -> str:
|
||||
"""
|
||||
标识当前平台名称 (如 'qianniu', 'wechat')
|
||||
"""
|
||||
pass
|
||||
92
core/adapters/qianniu_adapter.py
Normal file
92
core/adapters/qianniu_adapter.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import re
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
from core.adapters.base import BaseAdapter
|
||||
from core.schema import StandardMessage, StandardResponse
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
class QianniuAdapter(BaseAdapter):
|
||||
"""
|
||||
千牛适配器:支持识别消息来源(客户 vs 商家人工)。
|
||||
"""
|
||||
def __init__(self, ws_client=None):
|
||||
self.ws_client = ws_client
|
||||
self._default_group_id = "20252916034"
|
||||
|
||||
def platform_id(self) -> str:
|
||||
return "qianniu"
|
||||
|
||||
def _resolve_group_id(self, acc_id: str) -> str:
|
||||
try:
|
||||
config_path = Path("config/transfer_groups.json")
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
return cfg.get(acc_id, self._default_group_id)
|
||||
except Exception: pass
|
||||
return self._default_group_id
|
||||
|
||||
async def translate_inbound(self, raw: dict) -> Tuple[StandardMessage, str]:
|
||||
"""
|
||||
返回: (标准消息, 消息方向)
|
||||
direction: 'in' (客户发给商家), 'out' (商家人工在后台回复)
|
||||
"""
|
||||
if not isinstance(raw, dict): raw = {}
|
||||
|
||||
acc_id = str(raw.get("acc_id") or raw.get("shop_id") or "")
|
||||
from_id = str(raw.get("from_id") or raw.get("cy_id") or "")
|
||||
msg_text = str(raw.get("msg") or raw.get("content") or "")
|
||||
|
||||
# 判断方向:如果 from_id 包含了店铺名或 acc_id,通常说明是商家自己在说话
|
||||
# 或者逆向接口通常有一个特定的标识,这里我们做一个通用的逻辑判断
|
||||
direction = "in"
|
||||
user_id = from_id
|
||||
|
||||
# 逻辑:如果发送者 ID 等于 店铺 ID,说明是【商家人工回复】
|
||||
if from_id == acc_id and acc_id != "":
|
||||
direction = "out"
|
||||
# 此时 cy_id (客户ID) 通常在另一个字段里
|
||||
user_id = str(raw.get("cy_id") or "")
|
||||
|
||||
msg = StandardMessage(
|
||||
platform=self.platform_id(),
|
||||
msg_id=str(raw.get("msg_id", "")),
|
||||
user_id=user_id,
|
||||
user_name=str(raw.get("from_name", "")),
|
||||
content=msg_text,
|
||||
image_urls=self._extract_urls(msg_text),
|
||||
acc_id=acc_id,
|
||||
acc_type=str(raw.get("acc_type") or "AliWorkbench"),
|
||||
raw_data=raw
|
||||
)
|
||||
return msg, direction
|
||||
|
||||
async def translate_outbound(self, res: StandardResponse, user_id: str):
|
||||
if not self.ws_client: return
|
||||
if not res or (not res.should_reply and not res.need_transfer): return
|
||||
|
||||
meta = res.metadata if isinstance(res.metadata, dict) else {}
|
||||
acc_id = meta.get("acc_id", "")
|
||||
acc_type = meta.get("acc_type", "AliWorkbench")
|
||||
|
||||
if "[转移会话]" in res.reply_content:
|
||||
content = res.reply_content
|
||||
elif res.need_transfer:
|
||||
group_id = self._resolve_group_id(acc_id)
|
||||
content = f"正在为您转接|[转移会话],分组{group_id},无原因"
|
||||
else:
|
||||
content = res.reply_content
|
||||
|
||||
try:
|
||||
await self.ws_client.send(customer_id=user_id, acc_id=acc_id, acc_type=acc_type, content=content, msg_type=res.msg_type)
|
||||
except Exception as e:
|
||||
logger.error(f"[QianniuAdapter] 发送失败: {e}")
|
||||
|
||||
def _extract_urls(self, text: str) -> List[str]:
|
||||
if not text: return []
|
||||
image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
|
||||
candidates = re.findall(r'https?://[^\s#]+', text)
|
||||
return [u for u in candidates if any(ext in u.lower() for ext in image_exts)]
|
||||
@@ -1,201 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from core.rules import Rule, RuleContext, RuleEngine, RuleResult
|
||||
from services.risk_service import RiskService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.pydantic_ai_agent import (
|
||||
AgentResponse,
|
||||
ConversationState,
|
||||
CustomerMessage,
|
||||
CustomerServiceAgent,
|
||||
)
|
||||
|
||||
|
||||
class AgentPreRuleService:
|
||||
"""Pre-processing rule chain for short replies, cooldown, and text risk."""
|
||||
|
||||
def __init__(self, agent: "CustomerServiceAgent", risk_service: RiskService):
|
||||
self.agent = agent
|
||||
self.risk_service = risk_service
|
||||
self.engine = self._build_engine()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
*,
|
||||
message: "CustomerMessage",
|
||||
state: "ConversationState",
|
||||
trace_id: str,
|
||||
) -> Optional["AgentResponse"]:
|
||||
ctx = RuleContext(data={"message": message, "state": state, "trace_id": trace_id})
|
||||
result = await self.engine.run(ctx)
|
||||
if not result.stop:
|
||||
return None
|
||||
response = result.payload.get("response")
|
||||
return response
|
||||
|
||||
def _build_engine(self) -> RuleEngine:
|
||||
return RuleEngine(
|
||||
rules=[
|
||||
Rule(
|
||||
name="meaningless_short_text",
|
||||
priority=10,
|
||||
predicate=self._rule_pred_meaningless_short_text,
|
||||
action=self._rule_act_meaningless_short_text,
|
||||
),
|
||||
Rule(
|
||||
name="cooldown_silent",
|
||||
priority=20,
|
||||
predicate=self._rule_pred_cooldown_silent,
|
||||
action=self._rule_act_cooldown_silent,
|
||||
),
|
||||
Rule(
|
||||
name="manual_risk_block",
|
||||
priority=30,
|
||||
predicate=self._rule_pred_manual_risk_block,
|
||||
action=self._rule_act_manual_risk_block,
|
||||
),
|
||||
Rule(
|
||||
name="text_risk_block",
|
||||
priority=40,
|
||||
predicate=self._rule_pred_text_risk_block,
|
||||
action=self._rule_act_text_risk_block,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def _rule_pred_meaningless_short_text(self, ctx: RuleContext) -> bool:
|
||||
message = ctx.get("message")
|
||||
state = ctx.get("state")
|
||||
return self.agent._should_handle_as_meaningless_short_text(state, message.msg)
|
||||
|
||||
async def _rule_act_meaningless_short_text(self, ctx: RuleContext) -> RuleResult:
|
||||
from core.pydantic_ai_agent import AgentResponse
|
||||
|
||||
message = ctx.get("message")
|
||||
state = ctx.get("state")
|
||||
trace_id = ctx.get("trace_id", "")
|
||||
ping = random.choice(("嗯咯", "嗯啦", "嗯", "哦"))
|
||||
state.last_reply_at = datetime.now()
|
||||
self.agent._activity_log(
|
||||
"agent_ping_reply",
|
||||
trace_id=trace_id,
|
||||
customer_id=message.from_id,
|
||||
msg=message.msg,
|
||||
reply=ping,
|
||||
)
|
||||
return RuleResult(
|
||||
matched=True,
|
||||
stop=True,
|
||||
action="agent_ping_reply",
|
||||
payload={"response": AgentResponse(reply=ping, should_reply=True, need_transfer=False)},
|
||||
)
|
||||
|
||||
async def _rule_pred_cooldown_silent(self, ctx: RuleContext) -> bool:
|
||||
message = ctx.get("message")
|
||||
state = ctx.get("state")
|
||||
return self.agent._in_cooldown(state, message.msg)
|
||||
|
||||
async def _rule_act_cooldown_silent(self, ctx: RuleContext) -> RuleResult:
|
||||
from core.pydantic_ai_agent import AgentResponse
|
||||
|
||||
message = ctx.get("message")
|
||||
state = ctx.get("state")
|
||||
trace_id = ctx.get("trace_id", "")
|
||||
elapsed = int((datetime.now() - state.last_reply_at).total_seconds()) if state.last_reply_at else 0
|
||||
logger.info("[Agent] 冷却期静默(距上次回复 %ss):%r", elapsed, message.msg)
|
||||
self.agent._activity_log(
|
||||
"agent_cooldown_silent",
|
||||
trace_id=trace_id,
|
||||
customer_id=message.from_id,
|
||||
elapsed_s=elapsed,
|
||||
)
|
||||
return RuleResult(
|
||||
matched=True,
|
||||
stop=True,
|
||||
action="agent_cooldown_silent",
|
||||
payload={"response": AgentResponse(reply="", should_reply=False, need_transfer=False)},
|
||||
)
|
||||
|
||||
async def _rule_pred_manual_risk_block(self, ctx: RuleContext) -> bool:
|
||||
message = ctx.get("message")
|
||||
decision = self.risk_service.check_manual_block(message.from_id)
|
||||
ctx.set("manual_risk_decision", decision)
|
||||
return decision.blocked
|
||||
|
||||
async def _rule_act_manual_risk_block(self, ctx: RuleContext) -> RuleResult:
|
||||
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
|
||||
|
||||
message = ctx.get("message")
|
||||
trace_id = ctx.get("trace_id", "")
|
||||
decision = ctx.get("manual_risk_decision")
|
||||
self.agent._activity_log(
|
||||
"agent_manual_risk_reject",
|
||||
trace_id=trace_id,
|
||||
customer_id=message.from_id,
|
||||
risk=(decision.profile if decision else {}),
|
||||
)
|
||||
return RuleResult(
|
||||
matched=True,
|
||||
stop=True,
|
||||
action="agent_manual_risk_reject",
|
||||
payload={
|
||||
"response": AgentResponse(
|
||||
reply="这边无法继续为你处理该类需求,给你转人工专员对接。",
|
||||
should_reply=True,
|
||||
need_transfer=True,
|
||||
transfer_msg=TRANSFER_MESSAGE,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async def _rule_pred_text_risk_block(self, ctx: RuleContext) -> bool:
|
||||
message = ctx.get("message")
|
||||
decision = await self.risk_service.check_text_block(
|
||||
message.msg,
|
||||
political_detector=self.agent._is_political_inquiry,
|
||||
map_detector=self.agent._is_map_inquiry,
|
||||
)
|
||||
ctx.set("text_risk_decision", decision)
|
||||
return decision.blocked
|
||||
|
||||
async def _rule_act_text_risk_block(self, ctx: RuleContext) -> RuleResult:
|
||||
from core.pydantic_ai_agent import AgentResponse
|
||||
|
||||
message = ctx.get("message")
|
||||
state = ctx.get("state")
|
||||
trace_id = ctx.get("trace_id", "")
|
||||
decision = ctx.get("text_risk_decision")
|
||||
state.pending_image_urls.clear()
|
||||
state.pending_requirements.clear()
|
||||
self.agent._sync_pending_quote_state(message.from_id, state)
|
||||
|
||||
reject_text = self.risk_service.build_reject_text(decision.category if decision else "other")
|
||||
reply = await self.agent._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply=reject_text,
|
||||
scene="risk_reject",
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", reply)
|
||||
self.agent._activity_log(
|
||||
"agent_risk_reject",
|
||||
trace_id=trace_id,
|
||||
customer_id=message.from_id,
|
||||
risk_category=(decision.category if decision else "other"),
|
||||
risk_source=(decision.source if decision else "unknown"),
|
||||
reply=reply,
|
||||
)
|
||||
return RuleResult(
|
||||
matched=True,
|
||||
stop=True,
|
||||
action="agent_risk_reject",
|
||||
payload={"response": AgentResponse(reply=reply, should_reply=True, need_transfer=False)},
|
||||
)
|
||||
logger = logging.getLogger("cs_agent")
|
||||
@@ -1,163 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _attach_skill_docs(prompt: str, *skill_docs: str) -> str:
|
||||
docs = [d for d in skill_docs if d]
|
||||
if not docs:
|
||||
return prompt
|
||||
return prompt + "\n\n=== 角色技能 ===\n" + "\n\n".join(docs)
|
||||
|
||||
|
||||
def build_system_prompt(reply_persona: str, skill_pre_sales: str, skill_style: str) -> str:
|
||||
base_prompt = """你是淘宝找原图店的客服,对外说帮客户找原图/高清图,实际是帮客户把模糊图处理成高清版,擅长快速成交。
|
||||
|
||||
核心原则:快、准、狠。**回复要像真人聊天,自然多变,禁止套模板、背台词。**
|
||||
|
||||
【你拥有的工具,按需调用】
|
||||
- analyze_image(url):客户确认“图片发完”后调用,分析复杂度用于统一报价
|
||||
- process_image_gemini(customer_id):客户付款或说「安排/处理」时调用,走完整流程
|
||||
- remove_background(image_url):只要去背景时单独调用
|
||||
- perspective_correct(image_url):只要透视矫正时调用(需白底图)
|
||||
- extract_pattern_tool(image_url, prompt, aspect_ratio):只要印花提取时调用
|
||||
- enhance_image_tool(image_url):只要高清增强时调用
|
||||
- color_match_tool(orig_url, result_url, strength):颜色匹配
|
||||
- trim_border_tool(image_url):裁切四周背景边
|
||||
- resize_image(image_url, width, height):改尺寸,height=0则等比缩放
|
||||
- get_customer_info(customer_id):老客户来时调用,了解历史消费和性格
|
||||
- transfer_to_human():退款/投诉/情绪激动时调用
|
||||
- update_contact_info(customer_id, type, value):客户说出邮箱/手机/微信时调用,type填"email"/"phone"/"wechat"
|
||||
- record_quote(customer_id, price, description):每次报价后调用,记录报价保持一致
|
||||
- calculate_bulk_price(count, complexities):客户要做多张图时调用,获取打包价
|
||||
- save_customer_note(customer_id, note):记录其他重要信息
|
||||
|
||||
【报价规则】
|
||||
- 价格必须为5的整数倍(10/15/20/25/30),禁止报12、17、23等
|
||||
- 客户只是文字询价,没发图 → 自然引导发图,不报价
|
||||
- 收到图片先收集,不立刻报单张价;等客户明确“发完了/统一报价”后,再统一报价
|
||||
- 报价和推成交的话术要自然多变,跟着客户语气走,不要每次都一样
|
||||
- 客户确认发完后,分析完成的下一句话必须是明确报价
|
||||
- 报价后立刻推成交,不等客户反应
|
||||
|
||||
【文字加价规则】⚠️ 重要
|
||||
- 含文字很多时不能低价,有文字跟没文字是两个价格
|
||||
- 含文字的图必须 complex 起步(20 元以上)
|
||||
- 客户嫌贵时明确告知:「有文字跟没文字是两个价格」
|
||||
- 简单图但含文字 → normal 价格(15-20 元)
|
||||
- normal 图含文字 → complex 价格(20-25 元)
|
||||
|
||||
【压价规则】
|
||||
- 客户说「贵」「有点贵」「算了」「便宜点」→ 直接让价一次,禁止追问「什么问题」「说清楚点」
|
||||
- 只让价一次,话术自然变化
|
||||
- 第二次压价:表达最低了即可,换着说
|
||||
|
||||
【转接规则】
|
||||
- 退款/退货/投诉/情绪激动/test → 调用 transfer_to_human()
|
||||
- 调用后只回复"转接",不加其他内容
|
||||
|
||||
【找茬客户识别】⚠️ 重要
|
||||
识别以下高风险信号,建议不做这单:
|
||||
1. 下单后立即申请退款
|
||||
2. 从高价砍到低价(30→10 元)
|
||||
3. 反复问"不满意可以退吗"(2 次以上)
|
||||
4. 质疑服务内容("源文件还是什么")
|
||||
5. 质疑价值("就一张图片")
|
||||
6. 问"小一点就快一点的嘛"(想占便宜)
|
||||
7. 重复问同一个问题(想找麻烦)
|
||||
|
||||
识别到以上 3 个以上信号 → 建议转人工或直接拒绝接单
|
||||
话术:「不好意思,这单做不了」「去别家做吧」
|
||||
|
||||
【售后规则】
|
||||
- 催进度:自然回复在做了/快了/马上好之类
|
||||
- 要修改:自然问哪里要改
|
||||
|
||||
【禁忌】
|
||||
- 没看到图不报价
|
||||
- 不说"不行/不可以"
|
||||
- 不解释技术细节
|
||||
- 不给价格区间
|
||||
- 回复不超过2句话
|
||||
- 绝对禁止输出任何内部独白或状态说明,包括但不限于:"无需回复""已完成""已经完成""不需要回复""流程结束""操作完成""任务完成""记录完成""报价已记录"等
|
||||
- 每次必须输出真实的、发给客户看的回复文字,哪怕只有一句话"""
|
||||
base_prompt += f"\n\n【人设语气】\n- 人设:{reply_persona}\n- 语气像真人店主,不官腔,不机械,不背模板。"
|
||||
return _attach_skill_docs(base_prompt, skill_pre_sales, skill_style)
|
||||
|
||||
|
||||
def build_natural_reply_prompt(reply_persona: str, skill_style: str) -> str:
|
||||
base = f"""你是淘宝店主客服,专门把系统给你的“回复意图”改写成自然的一句话或两句话。
|
||||
人设:{reply_persona}
|
||||
规则:
|
||||
- 只输出发给客户的话,不要解释你的思考。
|
||||
- 口语化、简短、有温度,避免“这个需求我收到了”这类机械表达。
|
||||
- 不要编造价格、订单、进度;只按输入意图表达。
|
||||
- 默认不超过2句话。"""
|
||||
return _attach_skill_docs(base, skill_style)
|
||||
|
||||
|
||||
def build_after_sale_prompt(skill_after_sale: str, skill_style: str) -> str:
|
||||
base = """你是淘宝客服的售后助手,负责售后阶段的自然沟通与处理进度反馈。
|
||||
核心:简洁、自然、不解释技术细节、尽量不调用报价相关工具。
|
||||
规则:
|
||||
- 已付款客户优先:确认安排、说明进度、承诺时间点
|
||||
- 修改需求:礼貌询问具体改哪里,尽量一句话
|
||||
- 催进度:自然回复在做了/快了/马上好,给预计时间
|
||||
- 投诉/情绪激动/退款:转人工
|
||||
- 输出不超过2句话,不说内部状态"""
|
||||
return _attach_skill_docs(base, skill_after_sale, skill_style)
|
||||
|
||||
|
||||
def build_pricing_prompt(
|
||||
*,
|
||||
min_price_floor: int,
|
||||
case_library_link: str,
|
||||
skill_pricing: str,
|
||||
skill_style: str,
|
||||
) -> str:
|
||||
base = f"""你是淘宝客服的报价助手,负责在客户明确提到价格/询价时快速给出自然报价并推动成交。
|
||||
规则:
|
||||
- 收到图片或历史有图片依据时尽量结合复杂度给出单价,价格为5的整数倍
|
||||
- 没有图片时引导发图,不给价格区间
|
||||
- 报价后紧跟一句推动成交,话术自然不重复,避免机械重复“最低了”
|
||||
- 客户说“有点贵/优惠点/两张优惠点”时,优先给打包价或数量优惠,不要只会拒绝
|
||||
- 客户说“不放心/先看效果”时,先建立信任:可发案例链接 {case_library_link},并说明不满意可退
|
||||
- 可直接复用这条信任话术(按需微调,不要每次完全一样):
|
||||
小妹整理了一些案例图,亲点这个链接就能看到啦({case_library_link})。
|
||||
有什么想要的效果随时告诉我哈,我这边都可以按您的要求来做哦~/:065 效果不好不满意,我们这边包退的哦。
|
||||
- 最低价不低于{min_price_floor}元,客户出价低于底线时礼貌拒绝(不好意思)
|
||||
- 输出不超过2句话"""
|
||||
return _attach_skill_docs(base, skill_pricing, skill_style)
|
||||
|
||||
|
||||
def build_processing_prompt(skill_after_sale: str, skill_style: str) -> str:
|
||||
base = """你是淘宝客服的处理助手,负责在客户说安排/处理/开始做或已付款的场景下进行处理安排与进度反馈。
|
||||
规则:
|
||||
- 已付款或明确要求开始时,确认安排并给预计时间点
|
||||
- 可调用处理流程工具
|
||||
- 投诉/退款时转人工
|
||||
- 输出不超过2句话"""
|
||||
return _attach_skill_docs(base, skill_after_sale, skill_style)
|
||||
|
||||
|
||||
def build_similar_prompt(skill_pre_sales: str, skill_style: str) -> str:
|
||||
base = """你是淘宝客服的相似图助手,客户问“有一样的吗/类似的吗/同款吗”时,给出自然回复与参考建议。
|
||||
规则:
|
||||
- 先确认可以找类似款,建议拍后我发参考图
|
||||
- 如已知图案/类型,简要说明“同类型都有”,推动成交
|
||||
- 输出不超过2句话"""
|
||||
return _attach_skill_docs(base, skill_pre_sales, skill_style)
|
||||
|
||||
|
||||
def build_order_prompt(skill_after_sale: str, skill_style: str) -> str:
|
||||
base = """你是淘宝客服的订单助手,负责系统订单通知的处理。
|
||||
规则:
|
||||
- 已付款时自然确认安排;其他状态静默(输出空字符串)
|
||||
- 输出不超过1句话"""
|
||||
return _attach_skill_docs(base, skill_after_sale, skill_style)
|
||||
|
||||
|
||||
def build_risk_prompt(skill_risk: str, skill_style: str) -> str:
|
||||
base = """你是淘宝客服的风控助手,负责敏感/违规内容的前置拦截与替代话术。
|
||||
规则:
|
||||
- 黄色/擦边/涉政/政治人物/政治事件/政治图片/地图类内容等不接单,礼貌拒绝
|
||||
- 输出不超过1句话"""
|
||||
return _attach_skill_docs(base, skill_risk, skill_style)
|
||||
@@ -1,688 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_ai import RunContext
|
||||
|
||||
from db.customer_risk_db import risk_db
|
||||
from services.service_tuhui_upload import upload_to_tuhui
|
||||
from core.order_helpers import parse_order_info
|
||||
from core.schema import StandardResponse
|
||||
from services.dispatch_service import dispatch_service
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
async def transfer_to_human_tool(ctx: RunContext[Any], reason: str = Field(description="转人工的原因")) -> str:
|
||||
"""
|
||||
【核心工具】执行转人工逻辑。
|
||||
获取设计师姓名并生成精准转接指令。
|
||||
"""
|
||||
logger.info(f"[Tool] 尝试呼叫设计师接手: {reason}")
|
||||
|
||||
# 1. 尝试派单获取设计师姓名
|
||||
designer_name = await dispatch_service.assign_designer()
|
||||
|
||||
if designer_name:
|
||||
# 2. 有设计师在线:生成标准转接指令
|
||||
magic_cmd = f"正在为您转接|[转移会话],{designer_name},无原因"
|
||||
logger.info(f"[Tool] 成功呼叫设计师: {designer_name}")
|
||||
return magic_cmd
|
||||
else:
|
||||
# 3. 设计师下线:返回特定信号
|
||||
logger.warning("[Tool] 派单失败:设计师们已下线或不在位")
|
||||
return "ERROR_NO_DESIGNER_ONLINE"
|
||||
|
||||
def register_tools(agent) -> None:
|
||||
"""注册所有 Tool,让 Agent 可以主动调用。"""
|
||||
async def check_order_status_tool(ctx: RunContext[Any], customer_id: str = Field(description="客户ID")) -> str:
|
||||
"""查询订单状态。"""
|
||||
return "设计师正在后台加急处理中,请稍等哈。"
|
||||
|
||||
@agent.agent.tool
|
||||
async def analyze_image(ctx: RunContext[Any], image_url: str) -> str:
|
||||
"""
|
||||
分析客户发来的图片复杂度,用于报价。
|
||||
收到图片URL时调用此工具,返回复杂度和建议报价。
|
||||
"""
|
||||
try:
|
||||
from image.image_analyzer import image_analyzer
|
||||
result = await image_analyzer.analyze(image_url)
|
||||
complexity_label = {
|
||||
"simple": "简单(画面干净)",
|
||||
"normal": "一般复杂度",
|
||||
"complex": "细节偏多",
|
||||
"hard": "非常复杂",
|
||||
}.get(result["complexity"], result["complexity"])
|
||||
# 持久化图片URL和复杂度,重启后仍能记住这张图
|
||||
try:
|
||||
from db.customer_db import db
|
||||
db.update_last_image(
|
||||
ctx.deps.from_id,
|
||||
image_url,
|
||||
complexity=result["complexity"],
|
||||
gemini_prompt=result.get("gemini_prompt", ""),
|
||||
aspect_ratio=result.get("aspect_ratio", "1:1"),
|
||||
perspective=result.get("perspective", "no"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 存图片类型到客户画像
|
||||
try:
|
||||
from db.customer_db import db as _db
|
||||
if result.get("subject"):
|
||||
_db.add_image_type(ctx.deps.from_id, result["subject"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 在 workflow 里创建待处理任务(付款后自动触发 Gemini)
|
||||
try:
|
||||
from core.workflow import workflow
|
||||
await workflow.image_analysis_result(
|
||||
customer_id=ctx.deps.from_id,
|
||||
image_url=image_url,
|
||||
complexity=result["complexity"],
|
||||
acc_id=ctx.deps.acc_id,
|
||||
acc_type=ctx.deps.platform,
|
||||
gemini_prompt=result.get("gemini_prompt", ""),
|
||||
aspect_ratio=result.get("aspect_ratio", "1:1"),
|
||||
perspective=result.get("perspective", "no"),
|
||||
proc_type=result.get("proc_type", ""),
|
||||
subject=result.get("subject", ""),
|
||||
quality=result.get("quality", ""),
|
||||
)
|
||||
logger.info(
|
||||
"[Agent] Workflow 任务已创建 | 客户: %s | 比例: %s | 透视: %s | 图片: %s...",
|
||||
ctx.deps.from_id,
|
||||
result.get("aspect_ratio"),
|
||||
result.get("perspective"),
|
||||
image_url[:60],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("[Agent] Workflow 任务创建失败: %s", e)
|
||||
|
||||
# 组装给 AI 的分析报告
|
||||
risk = result.get("risk", "none")
|
||||
has_face = result.get("has_face", "no")
|
||||
feasibility = result.get("feasibility", "yes")
|
||||
note = result.get("note", "")
|
||||
|
||||
lines = [
|
||||
f"图片主体:{result['subject'] or '未识别'}",
|
||||
f"处理类型:{result['proc_type'] or '高清修复'}",
|
||||
f"原图质量:{result['quality'] or '未知'}",
|
||||
f"图片类型:{result.get('category', '') or '通用'}",
|
||||
f"图片尺寸:{(result.get('width') or 0)}x{(result.get('height') or 0)}({result.get('megapixels', 0.0)}MP)",
|
||||
f"含人脸:{'是' if has_face == 'yes' else '否'}",
|
||||
f"复杂度:{complexity_label}",
|
||||
f"原因:{result['reason']}",
|
||||
]
|
||||
if result.get("size_surcharge"):
|
||||
lines.append(f"尺寸加价:+{result['size_surcharge']}元")
|
||||
if result.get("size_note"):
|
||||
lines.append(f"尺寸提示:{result['size_note']}")
|
||||
try:
|
||||
st = agent._get_conversation_state(ctx.deps.from_id)
|
||||
if isinstance(result.get("price_min"), (int, float)):
|
||||
st.last_min_price = int(result.get("price_min") or 0)
|
||||
try:
|
||||
from db.customer_db import db as _db
|
||||
_db.update_last_min_price(ctx.deps.from_id, st.last_min_price)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 根据可做性和风险等级给 AI 不同的行动指引
|
||||
if feasibility == "no":
|
||||
if "敏感" in (note or ""):
|
||||
lines.append("【拒绝】图片含敏感/黄色/擦边内容,不接单。")
|
||||
lines.append("→ 直接拒绝,不说「发图来看看」,自然回复如:这类不做/不接。")
|
||||
else:
|
||||
lines.append("【无法处理】此图无法处理(纯黑/纯白/完全损坏/要找原始RAW文件)。")
|
||||
lines.append("→ 告知客户无法处理,建议换图或说明原因,不要报价。")
|
||||
elif risk == "high":
|
||||
lines.append(f"【高风险】此图处理风险高:{note or 'AI修复后效果不能保证与原图一致'}")
|
||||
lines.append(f"建议报价:{result['price_suggest']}元")
|
||||
lines.append("→ 先自然说明风险(人脸/效果可能不完美),再报价,满意再拍。话术自然。")
|
||||
elif risk == "low":
|
||||
lines.append(f"【低风险-含人脸】修复后人脸相似度约70-90%,效果不稳定。")
|
||||
lines.append(f"建议报价:{result['price_suggest']}元")
|
||||
lines.append(f"→ 报价时自然加一句风险提示(人脸可能有轻微变化、满意再付等)")
|
||||
else:
|
||||
# 无风险,正常报价
|
||||
base_price = result.get('price_suggest', 20)
|
||||
text_surcharge = result.get('text_surcharge', 0)
|
||||
layer_surcharge = result.get('layer_surcharge', 0)
|
||||
total_price = base_price + text_surcharge + layer_surcharge
|
||||
|
||||
# 构建报价说明
|
||||
price_explanation = f"建议报价:{total_price}元"
|
||||
if text_surcharge > 0:
|
||||
price_explanation += f"(含文字处理 +{text_surcharge}元)"
|
||||
if layer_surcharge > 0:
|
||||
price_explanation += f"(含分层 +{layer_surcharge}元)"
|
||||
|
||||
lines.append(price_explanation)
|
||||
|
||||
# 添加文字数量说明
|
||||
text_amount = result.get('text_amount', 'none')
|
||||
if text_amount != 'none':
|
||||
lines.append(f"文字数量:{text_amount},需要精细处理")
|
||||
|
||||
if feasibility == "partial":
|
||||
lines.append(f"⚠️ 此图有一定难度:{note or '效果可能不完美'},回复时可加「效果不满意退款」")
|
||||
if note and note not in ("无", ""):
|
||||
lines.append(f"提示:{note}")
|
||||
lines.append(f"【立刻回复客户报价 {total_price} 元,话术自然多变】")
|
||||
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
return f"图片分析失败: {e},请根据经验判断报价"
|
||||
|
||||
@agent.agent.tool
|
||||
async def get_customer_info(ctx: RunContext[Any], customer_id: str) -> str:
|
||||
"""
|
||||
查询客户历史信息:消费记录、性格标签、报价历史等。
|
||||
对话开始时或需要了解客户背景时调用。
|
||||
"""
|
||||
try:
|
||||
from db.customer_db import db
|
||||
return db.get_profile_text(customer_id)
|
||||
except Exception as e:
|
||||
return f"查询失败: {e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def transfer_to_human(ctx: RunContext[Any]) -> str:
|
||||
"""
|
||||
转接人工客服。
|
||||
遇到退款/投诉/情绪激动/复杂售后时调用。
|
||||
"""
|
||||
return "TRANSFER_REQUESTED"
|
||||
|
||||
@agent.agent.tool
|
||||
async def get_customer_risk_profile(ctx: RunContext[Any], customer_id: str = "") -> str:
|
||||
"""查询客户风控画像:退款/不付款/差评/人工黑名单等。"""
|
||||
cid = customer_id or ctx.deps.from_id
|
||||
try:
|
||||
info = risk_db.evaluate_customer(cid)
|
||||
return (
|
||||
f"客户:{cid}\n"
|
||||
f"不接单:{'是' if info.get('do_not_serve') else '否'}\n"
|
||||
f"风险等级:{info.get('computed_level','low')} 分数:{info.get('computed_score',0)}\n"
|
||||
f"近30天退款:{info.get('refund_30d',0)}\n"
|
||||
f"近7天未付款下单:{info.get('unpaid_7d',0)}\n"
|
||||
f"近90天差评:{info.get('bad_review_90d',0)}\n"
|
||||
f"备注:{info.get('note','') or '无'}"
|
||||
)
|
||||
except Exception as e:
|
||||
return f"查询风控画像失败: {e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def mark_customer_risk(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str,
|
||||
do_not_serve: bool = False,
|
||||
risk_level: str = "low",
|
||||
risk_score: int = 0,
|
||||
note: str = "",
|
||||
tag: str = "",
|
||||
) -> str:
|
||||
"""人工标记客户风控画像(不接单/高风险/备注标签)。"""
|
||||
try:
|
||||
tags = [tag] if tag else []
|
||||
risk_db.set_profile(
|
||||
customer_id=customer_id,
|
||||
do_not_serve=do_not_serve,
|
||||
risk_level=risk_level,
|
||||
risk_score=risk_score,
|
||||
note=note,
|
||||
tags=tags,
|
||||
)
|
||||
return "风控画像已更新"
|
||||
except Exception as e:
|
||||
return f"更新风控画像失败: {e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def record_customer_risk_event(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str,
|
||||
event_type: str,
|
||||
event_count: int = 1,
|
||||
note: str = "",
|
||||
) -> str:
|
||||
"""记录风控事件:refund/unpaid_order/bad_review/blacklist_hit 等。"""
|
||||
try:
|
||||
risk_db.record_event(
|
||||
customer_id=customer_id,
|
||||
event_type=event_type,
|
||||
event_count=event_count,
|
||||
note=note,
|
||||
)
|
||||
return "风控事件已记录"
|
||||
except Exception as e:
|
||||
return f"记录风控事件失败: {e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def save_customer_note(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str,
|
||||
note: str
|
||||
) -> str:
|
||||
"""
|
||||
记录客户关键信息到画像(邮箱/微信/特殊需求等)。
|
||||
客户提供联系方式或重要信息时调用。
|
||||
"""
|
||||
try:
|
||||
from db.customer_db import db
|
||||
db.add_note(customer_id, note)
|
||||
return "已记录"
|
||||
except Exception as e:
|
||||
return f"记录失败: {e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def update_contact_info(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str,
|
||||
contact_type: str,
|
||||
value: str
|
||||
) -> str:
|
||||
"""
|
||||
更新客户联系方式。
|
||||
当客户说出邮箱/手机/微信时调用,比正则提取更准确。
|
||||
|
||||
contact_type 枚举值:
|
||||
email - 邮箱
|
||||
phone - 手机号
|
||||
wechat - 微信号
|
||||
"""
|
||||
try:
|
||||
from db.customer_db import db
|
||||
if contact_type == "email":
|
||||
db.update_email(customer_id, value)
|
||||
elif contact_type == "phone":
|
||||
db.update_phone(customer_id, value)
|
||||
elif contact_type == "wechat":
|
||||
db.update_wechat(customer_id, value)
|
||||
else:
|
||||
return f"未知联系方式类型: {contact_type}"
|
||||
return f"已保存 {contact_type}: {value}"
|
||||
except Exception as e:
|
||||
return f"保存失败: {e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def record_quote(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str,
|
||||
price: int,
|
||||
description: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
记录本次报价到客户画像,用于后续对话保持价格一致。
|
||||
每次给客户报价后调用。
|
||||
|
||||
Args:
|
||||
customer_id: 客户ID
|
||||
price: 报价金额(元)
|
||||
description: 报价描述,如"单图处理"/"三图打包"
|
||||
"""
|
||||
try:
|
||||
from db.customer_db import db
|
||||
db.update_last_price(customer_id, price)
|
||||
if description:
|
||||
db.add_note(customer_id, f"报价 {price}元({description})")
|
||||
# 同步到内存状态
|
||||
state = agent.conversations.get(customer_id)
|
||||
if state:
|
||||
state.last_price = price
|
||||
return f"已记录报价 {price}元"
|
||||
except Exception as e:
|
||||
return f"记录失败: {e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def process_image_gemini(ctx: RunContext[Any], customer_id: str = "") -> str:
|
||||
"""
|
||||
触发 Gemini 作图处理。客户付款后或说「安排一下」「处理一下」时调用。
|
||||
会从客户档案读取上次发图的 URL 和处理参数(提示词、比例、透视),启动 Gemini 流程。
|
||||
处理完成后会自动发图给客户。
|
||||
"""
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不自动作图"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不自动作图"
|
||||
cid = customer_id or ctx.deps.from_id
|
||||
try:
|
||||
from core.workflow import workflow
|
||||
ok = await workflow.trigger_processing_on_payment(
|
||||
customer_id=cid,
|
||||
acc_id=ctx.deps.acc_id,
|
||||
acc_type=ctx.deps.platform,
|
||||
)
|
||||
if ok:
|
||||
return "已安排,稍后发你"
|
||||
return "该客户暂无待处理图片,请先发图"
|
||||
except Exception as e:
|
||||
return f"触发作图失败: {e},请稍后重试或转人工"
|
||||
|
||||
@agent.agent_pricing.tool
|
||||
async def analyze_image_pricing(ctx: RunContext[Any], image_url: str) -> str:
|
||||
try:
|
||||
from image.image_analyzer import image_analyzer
|
||||
result = await image_analyzer.analyze(image_url)
|
||||
if result.get("feasibility") == "no" or result.get("risk") == "high":
|
||||
return "该图风险高或不可做:不报价,建议换图或转人工评估。"
|
||||
if not result.get("success", False):
|
||||
return "图片识别异常:先不报价,建议客户重发更清晰图片。"
|
||||
p = result.get("price_suggest", 20)
|
||||
try:
|
||||
st = agent._get_conversation_state(ctx.deps.from_id)
|
||||
if isinstance(result.get("price_min"), (int, float)):
|
||||
st.last_min_price = int(result.get("price_min") or 0)
|
||||
try:
|
||||
from db.customer_db import db as _db
|
||||
_db.update_last_min_price(ctx.deps.from_id, st.last_min_price)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return f"建议报价:{p}元"
|
||||
except Exception as e:
|
||||
return f"图片分析失败: {e}"
|
||||
|
||||
@agent.agent_pricing.tool
|
||||
async def record_quote_pricing(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str,
|
||||
price: int,
|
||||
description: str = ""
|
||||
) -> str:
|
||||
try:
|
||||
from db.customer_db import db
|
||||
db.update_last_price(customer_id, price)
|
||||
return "ok"
|
||||
except Exception as e:
|
||||
return f"记录失败: {e}"
|
||||
|
||||
@agent.agent_processing.tool
|
||||
async def process_image_gemini_run(ctx: RunContext[Any], customer_id: str = "") -> str:
|
||||
"""触发 Gemini 作图处理(processing agent 专用入口)。"""
|
||||
return await process_image_gemini(ctx, customer_id)
|
||||
|
||||
@agent.agent_similar.tool
|
||||
async def recommend_similar(ctx: RunContext[Any], hint: str = "") -> str:
|
||||
try:
|
||||
return "有类似款,拍下我发你参考图。"
|
||||
except Exception as e:
|
||||
return f"推荐失败: {e}"
|
||||
|
||||
@agent.agent_order.tool
|
||||
async def handle_order(ctx: RunContext[Any], raw_msg: str = "") -> str:
|
||||
try:
|
||||
info = parse_order_info(raw_msg or "")
|
||||
paid_kw = ["等待发货", "已付款", "付款成功", "买家已付款"]
|
||||
if any(k in (info.get("pay_status", "") or "") for k in paid_kw) or any(k in (info.get("order_status", "") or "") for k in paid_kw):
|
||||
return "已安排,稍后发你"
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@agent.agent_risk.tool
|
||||
async def risk_filter(ctx: RunContext[Any], text: str = "") -> str:
|
||||
return "这类不做哈,政治/敏感内容都不接。"
|
||||
|
||||
@agent.agent_risk.tool
|
||||
async def get_customer_risk_profile_risk(ctx: RunContext[Any], customer_id: str = "") -> str:
|
||||
return await get_customer_risk_profile(ctx, customer_id)
|
||||
|
||||
@agent.agent_risk.tool
|
||||
async def mark_customer_risk_risk(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str,
|
||||
do_not_serve: bool = False,
|
||||
risk_level: str = "low",
|
||||
risk_score: int = 0,
|
||||
note: str = "",
|
||||
tag: str = "",
|
||||
) -> str:
|
||||
return await mark_customer_risk(
|
||||
ctx=ctx,
|
||||
customer_id=customer_id,
|
||||
do_not_serve=do_not_serve,
|
||||
risk_level=risk_level,
|
||||
risk_score=risk_score,
|
||||
note=note,
|
||||
tag=tag,
|
||||
)
|
||||
|
||||
@agent.agent_risk.tool
|
||||
async def record_customer_risk_event_risk(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str,
|
||||
event_type: str,
|
||||
event_count: int = 1,
|
||||
note: str = "",
|
||||
) -> str:
|
||||
return await record_customer_risk_event(
|
||||
ctx=ctx,
|
||||
customer_id=customer_id,
|
||||
event_type=event_type,
|
||||
event_count=event_count,
|
||||
note=note,
|
||||
)
|
||||
|
||||
@agent.agent.tool
|
||||
async def remove_background(ctx: RunContext[Any], image_url: str) -> str:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
"""【独立工具】去背景,输出白底图。客户只要去背景时调用。"""
|
||||
try:
|
||||
from image.image_tools import remove_background as _rb
|
||||
r = await _rb(image_url)
|
||||
if r["success"]:
|
||||
return f"去背景完成,已保存。自然回复客户好了发你"
|
||||
return f"去背景失败:{r['message']}"
|
||||
except Exception as e:
|
||||
return f"去背景失败:{e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def perspective_correct(ctx: RunContext[Any], image_url: str) -> str:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
"""【独立工具】透视矫正。输入需白底图,输出展平图。"""
|
||||
try:
|
||||
from image.image_tools import perspective_correct as _pc
|
||||
r = await _pc(image_url)
|
||||
if r["success"]:
|
||||
return f"透视矫正完成。自然回复客户好了"
|
||||
return f"透视矫正失败:{r['message']}"
|
||||
except Exception as e:
|
||||
return f"透视矫正失败:{e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def extract_pattern_tool(
|
||||
ctx: RunContext[Any],
|
||||
image_url: str,
|
||||
prompt: str = "",
|
||||
aspect_ratio: str = "1:1"
|
||||
) -> str:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
"""【独立工具】印花提取/主处理。按提示词和比例处理。"""
|
||||
try:
|
||||
from image.image_tools import extract_pattern
|
||||
r = await extract_pattern(image_url, prompt=prompt, aspect_ratio=aspect_ratio)
|
||||
if r["success"]:
|
||||
return f"提取完成。自然回复客户好了发你"
|
||||
return f"提取失败:{r['message']}"
|
||||
except Exception as e:
|
||||
return f"提取失败:{e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def enhance_image_tool(ctx: RunContext[Any], image_url: str) -> str:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
"""【独立工具】高清增强。客户只要清晰化时调用。"""
|
||||
try:
|
||||
from image.image_tools import enhance_image
|
||||
r = await enhance_image(image_url)
|
||||
if r["success"]:
|
||||
return f"高清增强完成。自然回复客户好了"
|
||||
return f"增强失败:{r['message']}"
|
||||
except Exception as e:
|
||||
return f"增强失败:{e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def color_match_tool(
|
||||
ctx: RunContext[Any],
|
||||
orig_url: str,
|
||||
result_url: str,
|
||||
strength: float = 0.75
|
||||
) -> str:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
"""【独立工具】颜色匹配。将 result 色调匹配到 orig。"""
|
||||
try:
|
||||
from image.image_tools import color_match_images
|
||||
r = await color_match_images(orig_url, result_url, strength=strength)
|
||||
if r["success"]:
|
||||
return f"颜色匹配完成"
|
||||
return f"颜色匹配失败:{r['message']}"
|
||||
except Exception as e:
|
||||
return f"颜色匹配失败:{e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def trim_border_tool(ctx: RunContext[Any], image_url: str) -> str:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
"""【独立工具】裁切四周背景边(白/黄/米等)。"""
|
||||
try:
|
||||
from image.image_tools import trim_border
|
||||
r = await trim_border(image_url)
|
||||
if r["success"]:
|
||||
return f"裁边完成"
|
||||
return f"裁边失败:{r['message']}"
|
||||
except Exception as e:
|
||||
return f"裁边失败:{e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def vectorize_to_eps_tool(ctx: RunContext[Any], image_url: str) -> str:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
"""【独立工具】矢量化 - 将图片转为 EPS 矢量文件。客户要做矢量图、转 EPS、转 AI 格式时调用。"""
|
||||
try:
|
||||
from image.image_tools import vectorize_to_eps
|
||||
r = await vectorize_to_eps(image_url)
|
||||
if r["success"]:
|
||||
return f"矢量化完成,已生成 EPS 文件。自然回复客户好了发你"
|
||||
return f"矢量化失败:{r['message']}"
|
||||
except Exception as e:
|
||||
return f"矢量化失败:{e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def meitu_enhance_tool(
|
||||
ctx: RunContext[Any],
|
||||
image_url: str,
|
||||
mode: str = "standard"
|
||||
) -> str:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
"""
|
||||
【独立工具】美图画质增强。客户要画质增强、清晰化、美图处理时调用。
|
||||
|
||||
Args:
|
||||
image_url: 图片 URL 或本地路径
|
||||
mode: 处理模式。crystal(极速重绘) standard(标准) enhance(增强) hdr(HDR) portrait(人像优化)
|
||||
"""
|
||||
try:
|
||||
from image.image_tools import meitu_enhance
|
||||
r = await meitu_enhance(image_url, mode=mode)
|
||||
if r["success"]:
|
||||
return f"画质增强完成。自然回复客户好了发你"
|
||||
return f"画质增强失败:{r['message']}"
|
||||
except Exception as e:
|
||||
return f"画质增强失败:{e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def resize_image(
|
||||
ctx: RunContext[Any],
|
||||
image_url: str,
|
||||
width: int,
|
||||
height: int = 0
|
||||
) -> str:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
except Exception:
|
||||
return "现在处理模块暂时暂停,先不处理图片"
|
||||
"""
|
||||
改图片尺寸。客户说「改成1920x1080」「弄成横图」「改下尺寸」时调用。
|
||||
|
||||
Args:
|
||||
image_url: 图片URL(客户刚发的图,或从对话中获取)
|
||||
width: 目标宽度(像素),如 1920
|
||||
height: 目标高度(0=按宽度等比缩放),如 1080
|
||||
|
||||
常用尺寸:1920x1080(横屏) 1080x1920(竖屏) 2000x2000(方图)
|
||||
"""
|
||||
try:
|
||||
from image.image_processor import image_processor
|
||||
result = await image_processor.resize(image_url, width, height)
|
||||
if result["success"]:
|
||||
return f"改尺寸完成:{width}x{height},已保存。自然回复客户改好了"
|
||||
else:
|
||||
return f"改尺寸失败:{result['message']},告知客户稍后重试"
|
||||
except Exception as e:
|
||||
return f"改尺寸失败:{e}"
|
||||
|
||||
@agent.agent.tool
|
||||
async def calculate_bulk_price(
|
||||
ctx: RunContext[Any],
|
||||
image_count: int,
|
||||
complexities: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
计算多图打包价格。
|
||||
客户要做多张图时调用,返回建议总价。
|
||||
|
||||
Args:
|
||||
image_count: 图片数量
|
||||
complexities: 各图复杂度,逗号分隔,如 "normal,complex,simple"
|
||||
没有识别结果时留空,按平均价格估算
|
||||
"""
|
||||
if image_count <= 0:
|
||||
return "图片数量无效"
|
||||
|
||||
# 各复杂度单价(必须为5的整数倍)
|
||||
unit_price = {"simple": 15, "normal": 20, "complex": 25, "hard": 30}
|
||||
default_unit = 20 # 没有识别结果时的默认单价
|
||||
|
||||
if complexities:
|
||||
levels = [c.strip() for c in complexities.split(",")]
|
||||
total = sum(unit_price.get(lv, default_unit) for lv in levels)
|
||||
else:
|
||||
total = image_count * default_unit
|
||||
|
||||
# 打包优惠:3张以上9折,5张以上8折,价格必须为5的整数倍
|
||||
if image_count >= 5:
|
||||
discounted = round(total * 0.8 / 5) * 5
|
||||
tip = f"({image_count}张8折优惠)"
|
||||
elif image_count >= 3:
|
||||
discounted = round(total * 0.9 / 5) * 5
|
||||
tip = f"({image_count}张9折优惠)"
|
||||
else:
|
||||
discounted = round(total / 5) * 5
|
||||
tip = ""
|
||||
|
||||
return f"建议打包报价:{discounted}元{tip}(原价{total}元)"
|
||||
def register_agent_tools(agent: Any):
|
||||
"""注册工具"""
|
||||
agent.tool(transfer_to_human_tool)
|
||||
agent.tool(check_order_status_tool)
|
||||
logger.info("[Agent] 工具箱已更新:称呼统一为“设计师”。")
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Optional, Tuple
|
||||
from core.post_ops import negotiation_strategy_reply
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.pydantic_ai_agent import AgentDeps, ConversationState, CustomerMessage, CustomerServiceAgent
|
||||
|
||||
|
||||
def _select_agent_by_intent(
|
||||
agent: "CustomerServiceAgent",
|
||||
message: "CustomerMessage",
|
||||
state: "ConversationState",
|
||||
) -> Tuple[Optional[Any], str]:
|
||||
"""
|
||||
AI 意图优先路由;识别不到时返回 (None, "intent:none"),由关键词兜底。
|
||||
"""
|
||||
try:
|
||||
from utils.intent_analyzer import detect_intent
|
||||
|
||||
decision = detect_intent(message.msg or "")
|
||||
intent = (decision.intent or "").strip()
|
||||
source = decision.source or "none"
|
||||
score = float(decision.score or 0.0)
|
||||
except Exception:
|
||||
intent, source, score = "", "error", 0.0
|
||||
|
||||
if not intent:
|
||||
return None, "intent:none"
|
||||
|
||||
if intent in ("询价", "砍价"):
|
||||
return agent.agent_pricing, f"intent:{intent}|src:{source}|score:{score:.3f}"
|
||||
if intent in ("修改", "加急"):
|
||||
return agent.agent_processing, f"intent:{intent}|src:{source}|score:{score:.3f}"
|
||||
if intent == "售后":
|
||||
return agent.agent_after_sale, f"intent:{intent}|src:{source}|score:{score:.3f}"
|
||||
if intent == "转接":
|
||||
return agent.agent_after_sale, f"intent:{intent}|src:{source}|score:{score:.3f}"
|
||||
if intent in ("打招呼", "批量", "发图"):
|
||||
target = agent.agent_after_sale if state.stage == "售后" else agent.agent
|
||||
return target, f"intent:{intent}|src:{source}|score:{score:.3f}"
|
||||
|
||||
return None, f"intent:unmapped:{intent}|src:{source}|score:{score:.3f}"
|
||||
|
||||
|
||||
def select_target_agent(agent: "CustomerServiceAgent", message: "CustomerMessage", state: "ConversationState") -> Tuple[Any, str]:
|
||||
msg_lower = message.msg.lower()
|
||||
pricing_kw = ["多少钱", "多少一张", "报价", "给个价", "几块", "价位", "能便宜点吗"]
|
||||
processing_kw = ["安排", "处理一下", "开始做", "做一下", "尽快", "加急", "付款了", "已付款"]
|
||||
similar_kw = ["有一样的", "有一样吗", "一样的吗", "类似的", "类似的吗", "同款", "相似", "类似吗"]
|
||||
order_markers = ["[系统订单信息]", "订单状态", "买家已付款"]
|
||||
risk_kw = [
|
||||
"黄色",
|
||||
"擦边",
|
||||
"色情",
|
||||
"涉黄",
|
||||
"涉政",
|
||||
"政治",
|
||||
"裸",
|
||||
"不雅",
|
||||
"天安门",
|
||||
"政治人物",
|
||||
"政治事件",
|
||||
"领导人",
|
||||
"党政",
|
||||
"习近平",
|
||||
"毛泽东",
|
||||
"邓小平",
|
||||
"江泽民",
|
||||
"胡锦涛",
|
||||
"特朗普",
|
||||
"拜登",
|
||||
"普京",
|
||||
"泽连斯基",
|
||||
"地图",
|
||||
"地形图",
|
||||
"行政区划图",
|
||||
"卫星地图",
|
||||
]
|
||||
target_agent = agent.agent_after_sale if state.stage == "售后" else agent.agent
|
||||
|
||||
ai_target, ai_reason = _select_agent_by_intent(agent, message, state)
|
||||
if ai_target is not None:
|
||||
return ai_target, ai_reason
|
||||
|
||||
risk_hit = any(k in msg_lower for k in risk_kw) or agent._is_political_inquiry(message.msg) or agent._is_map_inquiry(message.msg)
|
||||
if risk_hit:
|
||||
return agent.agent_risk, "keyword:risk"
|
||||
if any(k in message.msg for k in order_markers):
|
||||
return agent.agent_order, "keyword:order"
|
||||
if any(k in msg_lower for k in processing_kw):
|
||||
return agent.agent_processing, "keyword:processing"
|
||||
if any(k in msg_lower for k in pricing_kw):
|
||||
return agent.agent_pricing, "keyword:pricing"
|
||||
if any(k in msg_lower for k in similar_kw):
|
||||
return agent.agent_similar, "keyword:similar"
|
||||
return target_agent, "fallback:default"
|
||||
|
||||
|
||||
async def execute_ai_turn(
|
||||
agent: "CustomerServiceAgent",
|
||||
*,
|
||||
message: "CustomerMessage",
|
||||
state: "ConversationState",
|
||||
user_prompt: str,
|
||||
deps: "AgentDeps",
|
||||
history: list,
|
||||
) -> str:
|
||||
target_agent, route_reason = select_target_agent(agent, message, state)
|
||||
logger.info("[路由] %s", route_reason)
|
||||
result = await target_agent.run(user_prompt, deps=deps, message_history=history)
|
||||
agent.message_histories[message.from_id] = result.all_messages()[-30:]
|
||||
reply_text = agent._colloquialize_reply(agent._normalize_reply_text(result.output))
|
||||
|
||||
strategy_reply = negotiation_strategy_reply(message.msg, state)
|
||||
if strategy_reply:
|
||||
reply_text = strategy_reply
|
||||
|
||||
try:
|
||||
from config.config import MIN_PRICE_FLOOR
|
||||
import re
|
||||
|
||||
offer = None
|
||||
m = re.search(r"(\d{1,4})\s*(?:元|块|块钱|元钱)\b", message.msg)
|
||||
if m:
|
||||
offer = int(m.group(1))
|
||||
else:
|
||||
m2 = re.search(r"(?:能|可以|可否|能否)\s*(\d{1,4})\b", message.msg)
|
||||
offer = int(m2.group(1)) if m2 else None
|
||||
st = agent._get_conversation_state(message.from_id)
|
||||
floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR
|
||||
if offer is not None and offer < floor:
|
||||
reply_text = "不好意思"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from config.config import MIN_PRICE_FLOOR
|
||||
import re
|
||||
|
||||
st = agent._get_conversation_state(message.from_id)
|
||||
floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR
|
||||
|
||||
def _adjust(text: str) -> str:
|
||||
def _repl(m: Any):
|
||||
num = int(m.group(1))
|
||||
adj = max(floor, round(num / 5) * 5)
|
||||
return m.group(0).replace(str(num), str(adj))
|
||||
|
||||
patterns = [
|
||||
r"按(\d{1,4})元",
|
||||
r"报价[::]\s*(\d{1,4})\s*元",
|
||||
r"(\d{1,4})\s*元一张",
|
||||
r"打包(\d{1,4})\s*元",
|
||||
]
|
||||
t = text
|
||||
for p in patterns:
|
||||
t = re.sub(p, _repl, t)
|
||||
return t
|
||||
|
||||
reply_text = _adjust(reply_text or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for msg in result.new_messages():
|
||||
for part in getattr(msg, "parts", []):
|
||||
part_type = type(part).__name__
|
||||
if "ToolCall" in part_type:
|
||||
logger.info(
|
||||
"[THINK/TOOL_CALL] %s(%s)",
|
||||
getattr(part, "tool_name", ""),
|
||||
getattr(part, "args", ""),
|
||||
)
|
||||
elif "ToolReturn" in part_type:
|
||||
ret = str(getattr(part, "content", ""))[:120]
|
||||
logger.info("[THINK/TOOL_RETURN] %s", ret)
|
||||
|
||||
logger.info("[THINK/RAW_OUTPUT] %r", reply_text)
|
||||
return reply_text
|
||||
@@ -1,181 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
|
||||
def calc_requirement_surcharge(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)
|
||||
total = round(total / 5) * 5
|
||||
return {"extra": total, "hits": hits}
|
||||
|
||||
|
||||
def build_batch_quote_reply(
|
||||
*,
|
||||
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 ""
|
||||
|
||||
if len(results) == 1:
|
||||
line = detail_lines[0].replace("图1:", "这张:")
|
||||
heads = [
|
||||
"这张我看过了,先给你报下:",
|
||||
"这张可以做,价格给你报下:",
|
||||
"看了这张图,报价如下:",
|
||||
"我先按这张给你算下:",
|
||||
"这张处理没问题,我给你报个实在价:",
|
||||
"我看完这张了,价格给你说下:",
|
||||
"按这张图的难度,报价是:",
|
||||
"这张我已经评估完了,先给你个价格:",
|
||||
]
|
||||
lines = [f"{random.choice(heads)}{line.split(':', 1)[1]}"]
|
||||
if req_hit:
|
||||
lines.append(f"按你的需求另加{extra}元({req_hit})。")
|
||||
tails = [
|
||||
f"这张做下来共{single_total}元,定了我马上开工。",
|
||||
f"合下来是{single_total}元,你点头我这边立刻安排。",
|
||||
f"总价{single_total}元,可以的话我现在就给你做。",
|
||||
f"这一张算下来{single_total}元,你说开做我就马上弄。",
|
||||
f"给你按{single_total}元做,确定的话我现在就排上。",
|
||||
f"这张我按{single_total}元给你做,没问题就直接开始。",
|
||||
f"这张最终{single_total}元,你点头我立刻开干。",
|
||||
f"这张就按{single_total}元走,你确认我就马上安排。",
|
||||
]
|
||||
lines.append(random.choice(tails))
|
||||
return "\n".join(lines)
|
||||
|
||||
heads = [
|
||||
"我先按这几张给你报一下:",
|
||||
"这几张我都看过了,价格给你列一下:",
|
||||
"我把每张价格先给你说清楚:",
|
||||
"我先把这几张的价格拆开给你看:",
|
||||
"这几张我都评估过了,报价给你写明白:",
|
||||
"先别急,我把每张大概价给你列出来:",
|
||||
"我按这批图先报个明细给你:",
|
||||
"我先把每张费用和总价给你算出来:",
|
||||
]
|
||||
lines = [random.choice(heads)]
|
||||
lines.extend(detail_lines)
|
||||
if req_hit:
|
||||
lines.append(f"需求加价:+{extra}元({req_hit})")
|
||||
option_line = random.choice([
|
||||
f"可选:按单张做(共{single_total}元),或打包做({bundle_price}元,会更省一点)。",
|
||||
f"可选:单张算下来一共{single_total}元;打包给你{bundle_price}元,更划算。",
|
||||
f"可选:你按单张做共{single_total}元,按打包做我给你{bundle_price}元。",
|
||||
f"可选:分开做总共{single_total}元,打包做{bundle_price}元(省一点)。",
|
||||
f"可选:按张算共{single_total}元;直接打包{bundle_price}元。",
|
||||
])
|
||||
lines.append(option_line)
|
||||
lines.append(
|
||||
random.choice(
|
||||
[
|
||||
"你定一个,我这边马上开工。",
|
||||
"你选个方案,我立刻给你安排上。",
|
||||
"你拍板就行,我这边马上开做。",
|
||||
"你看选哪个合适,我这边马上给你做。",
|
||||
"你一句话定下来,我现在就给你安排。",
|
||||
]
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def prepare_batch_intake(state: Any) -> dict[str, Any]:
|
||||
"""Stage 1: 收集阶段,标准化输入并做上限约束。"""
|
||||
urls = list(getattr(state, "pending_image_urls", []) or [])
|
||||
if not urls:
|
||||
return {"ok": False, "reply": "你先把图片发我,我看完再给你统一报价。", "need_transfer": False}
|
||||
try:
|
||||
from config.config import BATCH_ANALYZE_CONCURRENCY, BATCH_MAX_IMAGES
|
||||
|
||||
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 {
|
||||
"ok": False,
|
||||
"reply": f"这次图片有点多({len(urls)}张),我先按前{max_images}张处理报价,剩下的下一批继续发我。",
|
||||
"need_transfer": False,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"urls": urls[:max_images],
|
||||
"requirements": list(getattr(state, "pending_requirements", []) or []),
|
||||
"analyze_concurrency": analyze_concurrency,
|
||||
}
|
||||
|
||||
|
||||
def assess_batch_risk(results: list[tuple[str, dict[str, Any]]]) -> dict[str, list[str]]:
|
||||
"""Stage 2.5: 分离可做和风险图。"""
|
||||
unsafe: list[str] = []
|
||||
dense_text_reject: list[str] = []
|
||||
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}")
|
||||
return {"unsafe": unsafe, "dense_text_reject": dense_text_reject}
|
||||
|
||||
|
||||
def build_batch_pricing_plan(results: list[tuple[str, dict[str, Any]]], requirements: list[str]) -> dict[str, Any]:
|
||||
"""Stage 3: 报价计算(图片成本 + 需求加价 + 打包价)。"""
|
||||
total_suggest = sum(int(r.get("price_suggest", 20) or 20) for _, r in results)
|
||||
req_fee = calc_requirement_surcharge(requirements)
|
||||
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
|
||||
return {
|
||||
"total_suggest": total_suggest,
|
||||
"req_fee": req_fee,
|
||||
"bundle_price": bundle_price,
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
|
||||
def classify_short_customer_text(text: str) -> str:
|
||||
"""
|
||||
短句分类器(状态机前置):
|
||||
- finish_signal: 发图完成,可报价
|
||||
- progress_query: 追问进度/结果
|
||||
- ack: 简短确认
|
||||
- unknown: 未识别
|
||||
"""
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return "unknown"
|
||||
if len(s) > 8:
|
||||
return "unknown"
|
||||
|
||||
finish_kw = (
|
||||
"没了",
|
||||
"没有了",
|
||||
"就这",
|
||||
"就这张",
|
||||
"就这一张",
|
||||
"就这一个",
|
||||
"就一个",
|
||||
"先这些",
|
||||
"就这些",
|
||||
"发完了",
|
||||
"都发完了",
|
||||
)
|
||||
if any(k in s for k in finish_kw):
|
||||
return "finish_signal"
|
||||
|
||||
progress_kw = (
|
||||
"有吗",
|
||||
"有没",
|
||||
"有没有",
|
||||
"找到了吗",
|
||||
"找到了没",
|
||||
"没找到吗",
|
||||
"找到没",
|
||||
"找到没有",
|
||||
"进度",
|
||||
"结果",
|
||||
"多久好",
|
||||
"什么时候好",
|
||||
"好了没",
|
||||
"弄好了吗",
|
||||
"做了没",
|
||||
"高清",
|
||||
"发我",
|
||||
"重新发",
|
||||
"你重新发给我",
|
||||
)
|
||||
if any(k in s for k in progress_kw) or s in {"?", "?", "在吗", "人呢"}:
|
||||
return "progress_query"
|
||||
|
||||
ack_kw = ("嗯", "嗯嗯", "好", "好的", "行", "可以", "ok", "OK", "收到", "明白")
|
||||
if s in ack_kw:
|
||||
return "ack"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def is_batch_finish_signal(text: str) -> bool:
|
||||
"""客户是否表达“图发完了,可以统一报价”"""
|
||||
if not text:
|
||||
return False
|
||||
if classify_short_customer_text(text) == "finish_signal":
|
||||
return True
|
||||
finish_keywords = [
|
||||
"发完了",
|
||||
"都发完了",
|
||||
"发齐了",
|
||||
"齐了",
|
||||
"先这些",
|
||||
"就这些",
|
||||
"全部",
|
||||
"一起报",
|
||||
"统一报价",
|
||||
"总共多少钱",
|
||||
"一共多少钱",
|
||||
"打包价",
|
||||
"总价",
|
||||
"报价吧",
|
||||
"报个总价",
|
||||
"给个总价",
|
||||
"没了",
|
||||
"没有了",
|
||||
"没图了",
|
||||
"就这",
|
||||
"就这张",
|
||||
"就这一张",
|
||||
"就这一个",
|
||||
"就一个",
|
||||
"先报吧",
|
||||
"报下价",
|
||||
"报个价",
|
||||
"可以报价了",
|
||||
"能报吗",
|
||||
]
|
||||
return any(k in text for k in finish_keywords)
|
||||
|
||||
|
||||
def is_cross_image_composite_intent(text: str) -> bool:
|
||||
"""
|
||||
识别多图跨图修改意图(A图元素放到B图)。
|
||||
例:A图的图案转到B图、这个图案放到另一张上。
|
||||
"""
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
pair_marks = ("a图", "b图", "第一张", "第二张", "这张", "那张", "上一张", "另一张")
|
||||
op_kw = (
|
||||
"转到",
|
||||
"换到",
|
||||
"放到",
|
||||
"贴到",
|
||||
"移到",
|
||||
"套到",
|
||||
"合成",
|
||||
"融合",
|
||||
"替换到",
|
||||
"图案上去",
|
||||
"字放到",
|
||||
"元素放到",
|
||||
"logo放到",
|
||||
)
|
||||
return any(k in s.lower() for k in pair_marks) and any(k in s for k in op_kw)
|
||||
|
||||
|
||||
def is_batch_finish_intent(text: str, state: Any, has_incoming_urls: bool) -> bool:
|
||||
"""
|
||||
语义结束识别:
|
||||
- 显式口令:发完了/统一报价
|
||||
- 隐式意图:询价/砍价
|
||||
- 单图需求明确:如“这个门头上面的字做一下”可直接进入报价
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
if is_batch_finish_signal(text):
|
||||
return True
|
||||
if has_incoming_urls:
|
||||
return False
|
||||
if not (getattr(state, "pending_image_urls", None) or []):
|
||||
return False
|
||||
|
||||
try:
|
||||
from utils.intent_analyzer import detect_intent
|
||||
intent = detect_intent(text).intent
|
||||
except Exception:
|
||||
intent = ""
|
||||
if intent in ("询价", "砍价"):
|
||||
return True
|
||||
|
||||
msg = (text or "").strip()
|
||||
if not msg:
|
||||
return False
|
||||
single_image_action_kw = (
|
||||
"做一下",
|
||||
"改一下",
|
||||
"处理一下",
|
||||
"就这张",
|
||||
"按这个做",
|
||||
"照这个做",
|
||||
"这个门头",
|
||||
"上面的字",
|
||||
"这个字",
|
||||
"这个图做",
|
||||
"能做吗",
|
||||
)
|
||||
multi_image_finish_kw = (
|
||||
"就这些",
|
||||
"就这几张",
|
||||
"按这几张",
|
||||
"这几张一起做",
|
||||
"一起做一下",
|
||||
"先按这些",
|
||||
"先按这几张",
|
||||
"直接报价",
|
||||
"现在报价",
|
||||
"看下报价",
|
||||
"先报个总价",
|
||||
"总价多少",
|
||||
"一起多少钱",
|
||||
"先做这几张",
|
||||
)
|
||||
hold_kw = ("还有", "再发", "先等", "稍后", "等会", "回头")
|
||||
image_count = len(getattr(state, "pending_image_urls", []) or [])
|
||||
if image_count == 1:
|
||||
if any(k in msg for k in single_image_action_kw) and not any(k in msg for k in hold_kw):
|
||||
return True
|
||||
elif image_count >= 2:
|
||||
if any(k in msg for k in multi_image_finish_kw) and not any(k in msg for k in hold_kw):
|
||||
return True
|
||||
if is_cross_image_composite_intent(msg) and not any(k in msg for k in hold_kw):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_related_image_followup_intent(text: str) -> bool:
|
||||
"""识别“新发的是上一张的截图/局部细节”的关联意图。"""
|
||||
s = (text or "").strip().lower()
|
||||
if not s:
|
||||
return False
|
||||
relation_kw = (
|
||||
"截图",
|
||||
"截屏",
|
||||
"局部",
|
||||
"细节",
|
||||
"放大",
|
||||
"裁剪",
|
||||
"同一张",
|
||||
"同一幅",
|
||||
"上一张",
|
||||
"上张",
|
||||
"前一张",
|
||||
"前面那张",
|
||||
"刚才那张",
|
||||
"这个是上面",
|
||||
"这个是那张",
|
||||
"补一张细节",
|
||||
"补个截图",
|
||||
)
|
||||
return any(k in s for k in relation_kw)
|
||||
|
||||
|
||||
def is_result_followup_query(text: str) -> bool:
|
||||
"""识别客户在找图流程中的结果/进度追问。"""
|
||||
if classify_short_customer_text(text) == "progress_query":
|
||||
return True
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
followup_kw = (
|
||||
"找到了吗",
|
||||
"没找到吗",
|
||||
"找到没",
|
||||
"找到没有",
|
||||
"找到了没",
|
||||
"有吗",
|
||||
"有没",
|
||||
"有没有",
|
||||
"有结果吗",
|
||||
"结果呢",
|
||||
"进度",
|
||||
"多久好",
|
||||
"什么时候好",
|
||||
"好了没",
|
||||
"弄好了吗",
|
||||
"做了没",
|
||||
"你重新发",
|
||||
"重新发给我",
|
||||
"高清",
|
||||
"发我",
|
||||
)
|
||||
if any(k in s for k in followup_kw):
|
||||
return True
|
||||
return s in {"?", "?", "在吗", "人呢"}
|
||||
|
||||
|
||||
def build_collect_ack(count: int, related_followup: bool = False) -> str:
|
||||
if related_followup and count >= 2:
|
||||
related_templates = [
|
||||
"这张我收到了,看起来是上一张的截图/细节图,我按同一单一起处理。还有补充就继续发。",
|
||||
"收到,这张是关联补图我记上了(按同一需求处理)。你还有图就继续发。",
|
||||
"明白,这张是前图的局部截图,我会和前面那张一起算,不会分开漏掉。",
|
||||
]
|
||||
return random.choice(related_templates)
|
||||
if count <= 1:
|
||||
one_templates = [
|
||||
"这张收到啦,还有图就继续发,我一起给你看。",
|
||||
"图我看到了,后面还有就接着发,最后我一口价给你。",
|
||||
"收到这张了,你有其他图也发来,我统一帮你算。",
|
||||
"这张我先记上了,你那边还有的话接着发,我一起给你报。",
|
||||
"第1张收到,你继续发就行,发完我这边一次给你算清楚。",
|
||||
"这张没问题,我先收着。要是还有图,你直接连着发我就行。",
|
||||
"我先看到了这张,你后面还有就一起发来,我统一给你报价。",
|
||||
"这张图我已经记下了,后面有补充就继续甩过来哈。",
|
||||
]
|
||||
return random.choice(one_templates)
|
||||
templates = [
|
||||
"这几张我都收到了(现在{n}张)。还有的话继续发,我一起给你报。",
|
||||
"好嘞,先看到{n}张了。你可以继续发,或者直接说“就这些”我现在就报价。",
|
||||
"收到哈(共{n}张)。你还要补图就继续发,不补的话我现在也可以直接给价。",
|
||||
"我这边先收到了{n}张。你继续补图,或者直接说“按这些算”我就开始报。",
|
||||
"这波我已经记了{n}张,你要是还有就接着发,不补的话我立刻给总价。",
|
||||
"先看到{n}张图了,后面你看是继续发,还是直接让我现在报价都可以。",
|
||||
"好的,目前{n}张到位。你一句“就这些”,我马上给你打包价。",
|
||||
"图我都看到了({n}张)。你还发我就继续收,不发我现在就给你报。",
|
||||
]
|
||||
return random.choice(templates).format(n=count)
|
||||
|
||||
|
||||
def build_collect_progress_reply(count: int) -> str:
|
||||
if count <= 1:
|
||||
templates = [
|
||||
"我这边在处理了,这张有结果我第一时间回你。",
|
||||
"在跟进中,这张一有进展我马上发你。",
|
||||
"这张我正在看,稍等我一会儿,结果出来就回你。",
|
||||
]
|
||||
return random.choice(templates)
|
||||
templates = [
|
||||
"我这边在按你这{n}张一起处理,有结果我立刻同步你。",
|
||||
"正在跟进这{n}张,出结果我第一时间发你,不会漏。",
|
||||
"进度在跑了(共{n}张),你稍等一下,我这边有结果马上回。",
|
||||
]
|
||||
return random.choice(templates).format(n=count)
|
||||
|
||||
|
||||
def build_collect_remind(count: int) -> str:
|
||||
if count <= 1:
|
||||
one_templates = [
|
||||
"这个要求我记住了。你还有图就继续发,不补图我就按这张给你报价。",
|
||||
"明白,这个需求我加上了。你继续发图也行,想直接报价也可以。",
|
||||
"我先记下这张。你如果是要我找图,不是做图,直接说一声,我按找图思路给你走。",
|
||||
"收到,这张我先按你的要求记好了。就做这一张的话,我现在直接给你报实价。",
|
||||
"你这要求我记下了,后面还有图就发,没有的话我现在直接算价。",
|
||||
"行,我按你这个要求来。继续补图也行,不补我就先报这张。",
|
||||
"这个点我懂了,你还要补图就接着发,不补我立刻给你报价。",
|
||||
"要求我已经加上了。你看是继续发,还是我现在直接报这张。",
|
||||
]
|
||||
return random.choice(one_templates)
|
||||
templates = [
|
||||
"需求我记下了(当前{n}张)。你继续补图,或者直接说“就这些”我现在报价。",
|
||||
"好,这个要求也加上了(现在{n}张)。不再补图的话我立刻给你打包价。",
|
||||
"收到(共{n}张)。你还发就继续,不发的话我现在就给总价。",
|
||||
"这个需求我加进去了(现在{n}张)。你继续发也行,直接报价也行。",
|
||||
"我这边都记好了({n}张+需求)。你一句“先按这些算”,我马上报价。",
|
||||
"要求同步好了,目前{n}张。要补图继续发,不补图我现在就给你打包价。",
|
||||
"行,需求和图片我都收着了({n}张)。你直接让我报价也可以。",
|
||||
"好的,这条需求也算进去了(共{n}张)。你看要不要我现在直接报。",
|
||||
]
|
||||
return random.choice(templates).format(n=count)
|
||||
|
||||
|
||||
def is_find_image_not_edit_conflict(text: str) -> bool:
|
||||
"""识别客户明确声明“要找图,不是做图”的冲突语义。"""
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
find_kw = ("找图", "找原图", "找素材", "找同款")
|
||||
deny_edit_kw = ("不是让你做图", "不是做图", "不用做图", "不需要做图", "不是修图", "不用修图")
|
||||
return any(k in s for k in find_kw) and any(k in s for k in deny_edit_kw)
|
||||
|
||||
|
||||
def needs_clarification_in_collecting(text: str) -> bool:
|
||||
"""信息不足时先追问,不急着报价。"""
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
short_non_vague_kw = (
|
||||
"?",
|
||||
"?",
|
||||
"没了",
|
||||
"没有了",
|
||||
"就这",
|
||||
"行",
|
||||
"好的",
|
||||
"ok",
|
||||
"报价",
|
||||
"找到了吗",
|
||||
"没找到吗",
|
||||
"找到没",
|
||||
"找到了没",
|
||||
"有吗",
|
||||
"有没",
|
||||
"有没有",
|
||||
"多久好",
|
||||
"什么时候好",
|
||||
"高清",
|
||||
)
|
||||
if len(s) <= 4:
|
||||
if any(k in s for k in short_non_vague_kw):
|
||||
return False
|
||||
return True
|
||||
vague_kw = (
|
||||
"这个也是",
|
||||
"一共几个图",
|
||||
"几个图",
|
||||
"啥意思",
|
||||
"没明白",
|
||||
"什么意思",
|
||||
"这个呢",
|
||||
"这个可以吗",
|
||||
"然后呢",
|
||||
"咋办",
|
||||
"怎么搞",
|
||||
)
|
||||
return any(k in s for k in vague_kw)
|
||||
|
||||
|
||||
def build_find_image_clarify_reply(state: Any) -> str:
|
||||
count = len(getattr(state, "pending_image_urls", []) or [])
|
||||
return (
|
||||
f"明白,你是要我帮你找图,不是做图。现在我这边先记了{count}张,"
|
||||
"你告诉我具体要找哪种:原图/同款/高清版,我按这个方向给你找。"
|
||||
)
|
||||
|
||||
|
||||
def build_not_understood_reply() -> str:
|
||||
"""信息不足时的澄清话术(随机)。"""
|
||||
templates = [
|
||||
"不好意思,不太懂你的意思,你再具体说下哈。",
|
||||
"抱歉我这边没完全理解,你可以换个说法再说一次吗?",
|
||||
"我有点没听明白,你是要找图还是要做图呀?",
|
||||
"不好意思我没抓到重点,你再补一句我就能接着处理。",
|
||||
"这句我理解得不太准,你再说具体一点我马上给你办。",
|
||||
"抱歉,这里我没太看懂。你是想让我找原图,还是按图处理?",
|
||||
"我这边还没完全明白你的意思,麻烦你再具体描述一下。",
|
||||
"不好意思,这条我没读懂,你再详细说一点我马上跟上。",
|
||||
]
|
||||
return random.choice(templates)
|
||||
|
||||
|
||||
def append_requirement(state: Any, text: str) -> None:
|
||||
"""追加需求并做去重/截断,减少上下文噪音。"""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return
|
||||
t = t[:120]
|
||||
existing = list(getattr(state, "pending_requirements", []) or [])
|
||||
if existing and existing[-1] == t:
|
||||
return
|
||||
if t in existing[-5:]:
|
||||
return
|
||||
existing.append(t)
|
||||
if len(existing) > 20:
|
||||
existing = existing[-20:]
|
||||
state.pending_requirements = existing
|
||||
@@ -1,229 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import logging
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
def calc_avg_complexity(complexity_history: list) -> str:
|
||||
"""计算平均复杂度。"""
|
||||
if not complexity_history:
|
||||
return "未知"
|
||||
level_map = {"simple": 1, "normal": 2, "complex": 3, "hard": 4}
|
||||
label_map = {1: "简单", 2: "一般", 3: "复杂", 4: "很复杂"}
|
||||
try:
|
||||
avg = sum(level_map.get(c, 2) for c in complexity_history) / len(complexity_history)
|
||||
return label_map.get(round(avg), "一般")
|
||||
except Exception:
|
||||
return "一般"
|
||||
|
||||
|
||||
def get_customer_profile_context(agent, customer_id: str) -> str:
|
||||
"""从数据库读取客户画像,注入给 AI。含个性化语气、报价策略、主动预测、近期对话。"""
|
||||
try:
|
||||
from db.customer_db import db
|
||||
|
||||
profile = db.get_customer(customer_id)
|
||||
|
||||
if profile.blacklist:
|
||||
return f"【⚠️黑名单客户】原因:{profile.blacklist_reason or '已标记'},请转接人工处理,不要自动回复"
|
||||
|
||||
lines = []
|
||||
lines.append("=== 客户档案 ===")
|
||||
|
||||
basic_info = []
|
||||
basic_info.append(f"客户ID: {customer_id}")
|
||||
basic_info.append(f"姓名: {profile.name or '未知'}")
|
||||
if profile.email:
|
||||
basic_info.append(f"邮箱: {profile.email}")
|
||||
if profile.phone:
|
||||
basic_info.append(f"电话: {profile.phone}")
|
||||
if profile.wechat:
|
||||
basic_info.append(f"微信: {profile.wechat}")
|
||||
lines.append(" | ".join(basic_info))
|
||||
|
||||
consume_info = []
|
||||
consume_info.append(f"客户等级: {profile.customer_level}级")
|
||||
if profile.vip:
|
||||
consume_info.append("VIP客户")
|
||||
consume_info.append(f"总订单: {profile.total_orders}单")
|
||||
consume_info.append(f"总消费: {profile.total_spent}元")
|
||||
if profile.total_orders > 0:
|
||||
consume_info.append(f"客单价: {profile.total_spent // profile.total_orders}元")
|
||||
lines.append("--- 消费分析 ---")
|
||||
lines.append(" | ".join(consume_info))
|
||||
|
||||
price_info = []
|
||||
if profile.vip_custom_price:
|
||||
price_info.append(f"VIP专属价: {profile.vip_custom_price}元(直接报这个价)")
|
||||
if profile.last_price:
|
||||
price_info.append(f"上次报价: {profile.last_price}元")
|
||||
if profile.lowest_price_accepted:
|
||||
price_info.append(f"历史最低成交: {profile.lowest_price_accepted}元")
|
||||
if profile.discount_given_count:
|
||||
price_info.append(f"历史让价: {profile.discount_given_count}次")
|
||||
if profile.price_sensitivity:
|
||||
price_info.append(f"价格敏感度: {profile.price_sensitivity}")
|
||||
if getattr(profile, "last_quote_no_convert", False):
|
||||
price_info.append("【策略】上次报价未成交,本次可降5-10元")
|
||||
if price_info:
|
||||
lines.append("--- 报价历史 ---")
|
||||
lines.append(" | ".join(price_info))
|
||||
|
||||
personality_info = []
|
||||
if profile.personality:
|
||||
personality_info.append(f"性格: {'/'.join(profile.personality)}")
|
||||
if profile.decision_speed:
|
||||
personality_info.append(f"决策速度: {profile.decision_speed}")
|
||||
if profile.communication_prefer:
|
||||
personality_info.append(f"沟通偏好: {profile.communication_prefer}")
|
||||
if personality_info:
|
||||
lines.append("--- 性格特征 ---")
|
||||
lines.append(" | ".join(personality_info))
|
||||
|
||||
image_info = []
|
||||
image_info.append(f"累计发图: {profile.total_images_sent}张")
|
||||
if profile.complexity_history:
|
||||
image_info.append(f"平均复杂度: {calc_avg_complexity(profile.complexity_history)}")
|
||||
if profile.image_type_history:
|
||||
top_types = Counter(profile.image_type_history).most_common(3)
|
||||
types_str = "、".join(f"{t}({c}次)" for t, c in top_types)
|
||||
image_info.append(f"常见类型: {types_str}")
|
||||
if profile.preferred_format:
|
||||
image_info.append(f"格式偏好: {profile.preferred_format}")
|
||||
if profile.preferred_size:
|
||||
image_info.append(f"尺寸要求: {profile.preferred_size}")
|
||||
if profile.last_image_url:
|
||||
image_info.append(f"最近发图: {profile.last_image_url[:60]}...")
|
||||
lines.append("--- 图片习惯 ---")
|
||||
lines.append(" | ".join(image_info))
|
||||
|
||||
if profile.processing_status:
|
||||
task_info = []
|
||||
task_info.append(f"状态: {profile.processing_status}")
|
||||
if profile.processing_image_url:
|
||||
task_info.append(f"处理中: {profile.processing_image_url[:40]}...")
|
||||
if profile.expected_done_at:
|
||||
task_info.append(f"预计完成: {profile.expected_done_at}")
|
||||
lines.append("--- 当前任务 ---")
|
||||
lines.append(" | ".join(task_info))
|
||||
|
||||
if profile.last_conversation_summary:
|
||||
time_str = ""
|
||||
if profile.last_conversation_time:
|
||||
try:
|
||||
t = datetime.fromisoformat(profile.last_conversation_time)
|
||||
diff = datetime.now() - t
|
||||
if diff.days > 0:
|
||||
time_str = f"({diff.days}天前)"
|
||||
else:
|
||||
h = diff.seconds // 3600
|
||||
time_str = f"({h}小时前)" if h > 0 else "(刚刚)"
|
||||
except Exception:
|
||||
pass
|
||||
lines.append(f"--- 上次对话 {time_str} ---")
|
||||
lines.append(profile.last_conversation_summary)
|
||||
|
||||
hints = []
|
||||
if profile.personality:
|
||||
if "爽快" in profile.personality:
|
||||
hints.append("回复简洁直接,不废话,快速报价")
|
||||
if "砍价" in profile.personality or "砍价狂" in profile.personality:
|
||||
hints.append("报价时强调性价比,只让价一次,第二次引导去 xinhui.cloud")
|
||||
if "纠结" in profile.personality or "墨迹" in profile.personality:
|
||||
hints.append("多给一点说明,耐心回答")
|
||||
if profile.price_sensitivity == "高":
|
||||
hints.append("报价时顺带提「满意再拍」降低顾虑")
|
||||
if profile.decision_speed == "快":
|
||||
hints.append("直接报价推成交,少铺垫")
|
||||
if profile.total_orders > 0 and profile.decision_speed == "快":
|
||||
hints.append("老客爽快,直接报价成交")
|
||||
if hints:
|
||||
lines.append("--- 回复策略 ---")
|
||||
lines.append(";".join(hints))
|
||||
|
||||
proactive = []
|
||||
if profile.bulk_potential == "有" or (profile.total_images_sent or 0) >= 2:
|
||||
proactive.append("可问「要做多张吗,多张有优惠」")
|
||||
if profile.upsell_opportunity:
|
||||
proactive.append(f"加购机会: {'、'.join(profile.upsell_opportunity)}")
|
||||
if proactive:
|
||||
lines.append("--- 主动推荐 ---")
|
||||
lines.append(";".join(proactive))
|
||||
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.exception("[Agent] 获取客户画像失败: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
def get_refusal_context_hint(agent, customer_id: str, current_msg: str, profile_context: str) -> str:
|
||||
"""
|
||||
检测「刚拒绝某张图 + 客户问能找到吗」场景,注入显式提示,避免前后矛盾。
|
||||
"""
|
||||
ask_keywords = ["能找到吗", "可以吗", "有吗", "能做吗", "可以找吗", "可以弄吗"]
|
||||
if not any(kw in current_msg for kw in ask_keywords):
|
||||
return ""
|
||||
refusal_keywords = ["不做", "不接", "拒绝", "不做这类", "这类不做"]
|
||||
if any(kw in profile_context for kw in refusal_keywords):
|
||||
return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。"
|
||||
history = getattr(agent, "message_histories", {}).get(customer_id, [])
|
||||
for msg in reversed(history[-6:]):
|
||||
msg_str = str(msg)
|
||||
if any(kw in msg_str for kw in refusal_keywords):
|
||||
return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。"
|
||||
return ""
|
||||
|
||||
|
||||
def get_conversation_context(customer_id: str, acc_id: str = "", limit: int = 12, max_len: int = 80) -> str:
|
||||
"""每一次对话都从数据库加载近期对话,压缩后注入 prompt。"""
|
||||
try:
|
||||
try:
|
||||
from config.config import CHAT_CONTEXT_LIMIT, CHAT_CONTEXT_TRUNCATE_LEN
|
||||
|
||||
limit = CHAT_CONTEXT_LIMIT
|
||||
max_len = CHAT_CONTEXT_TRUNCATE_LEN
|
||||
except Exception:
|
||||
pass
|
||||
from db.chat_log_db import get_recent_conversation
|
||||
|
||||
msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=limit)
|
||||
if not msgs:
|
||||
return ""
|
||||
lines = []
|
||||
for m in msgs:
|
||||
role = "客" if m.get("direction") == "in" else "服"
|
||||
msg_text = (m.get("message") or "").strip().replace("\n", " ")[:max_len]
|
||||
if not msg_text:
|
||||
continue
|
||||
lines.append(f"{role}:{msg_text}")
|
||||
if not lines:
|
||||
return ""
|
||||
return "【近期】\n" + "\n".join(lines) + "\n\n"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_intent_emotion_hint(msg: str) -> str:
|
||||
"""语义匹配:意图/情绪识别,注入提示。EMBEDDING_MODEL 未配置时用关键词。"""
|
||||
try:
|
||||
from utils.intent_analyzer import detect_emotion_embedding, detect_intent
|
||||
|
||||
decision = detect_intent(msg)
|
||||
intent = decision.intent
|
||||
emotion = detect_emotion_embedding(msg) if os.getenv("EMBEDDING_MODEL") else None
|
||||
parts = []
|
||||
if intent:
|
||||
parts.append(f"意图:{intent}")
|
||||
if decision.source:
|
||||
parts.append(f"意图来源:{decision.source}")
|
||||
if emotion:
|
||||
parts.append(f"情绪:{emotion}")
|
||||
if parts:
|
||||
return f"【当前消息】{', '.join(parts)}"
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
@@ -1,95 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from core.quote_state_machine import QuoteStateMachine
|
||||
|
||||
|
||||
def refresh_quote_phase(state: Any, phase_hint: str = "") -> None:
|
||||
"""统一维护收图报价状态机。"""
|
||||
QuoteStateMachine().refresh(state, phase_hint=phase_hint)
|
||||
|
||||
|
||||
def sync_pending_quote_state(agent: Any, customer_id: str, state: Any) -> None:
|
||||
"""把待报价队列同步到客户库,避免重启丢失。"""
|
||||
try:
|
||||
refresh_quote_phase(state)
|
||||
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(customer_id: str, state: Any) -> None:
|
||||
"""从客户库恢复待报价队列。"""
|
||||
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)
|
||||
refresh_quote_phase(state)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cleanup_inactive(conversations: dict, message_histories: dict, now: datetime) -> None:
|
||||
"""清理超过 7 天没有消息的对话状态,释放内存。"""
|
||||
if len(conversations) % 100 != 0:
|
||||
return
|
||||
expired = [
|
||||
cid
|
||||
for cid, state in conversations.items()
|
||||
if state.last_update and (now - datetime.fromisoformat(state.last_update)).days > 7
|
||||
]
|
||||
for cid in expired:
|
||||
conversations.pop(cid, None)
|
||||
message_histories.pop(cid, None)
|
||||
|
||||
|
||||
def get_conversation_state(agent: Any, customer_id: str) -> Any:
|
||||
"""获取或创建对话状态,超时自动重置。"""
|
||||
now = datetime.now()
|
||||
|
||||
if customer_id in agent.conversations:
|
||||
state = agent.conversations[customer_id]
|
||||
if state.last_update:
|
||||
try:
|
||||
last = datetime.fromisoformat(state.last_update)
|
||||
hours = (now - last).total_seconds() / 3600
|
||||
if hours > agent.CONVERSATION_TIMEOUT_HOURS:
|
||||
state.stage = "售前"
|
||||
state.discount_count = 0
|
||||
agent.message_histories.pop(customer_id, None)
|
||||
except Exception:
|
||||
pass
|
||||
if not state.pending_image_urls and not state.pending_requirements:
|
||||
restore_pending_quote_state(customer_id, state)
|
||||
else:
|
||||
agent.conversations[customer_id] = agent.ConversationStateClass(
|
||||
customer_id=customer_id,
|
||||
last_update=now.isoformat(),
|
||||
)
|
||||
restore_pending_quote_state(customer_id, agent.conversations[customer_id])
|
||||
|
||||
cleanup_inactive(agent.conversations, agent.message_histories, now)
|
||||
return agent.conversations[customer_id]
|
||||
|
||||
|
||||
def should_defer_batch_quote(agent: Any, state: Any, mark_ready: bool = False) -> bool:
|
||||
"""批量报价延后控制。"""
|
||||
agent.quote_state_machine.delay_turns = max(0, int(agent.batch_quote_delay_turns))
|
||||
return agent.quote_state_machine.should_defer_batch_quote(state, mark_ready=mark_ready)
|
||||
|
||||
|
||||
def mark_quote_ready(agent: Any, state: Any) -> None:
|
||||
"""仅标记 ready 状态,不消费等待轮次。"""
|
||||
agent.quote_state_machine.delay_turns = max(0, int(agent.batch_quote_delay_turns))
|
||||
agent.quote_state_machine.mark_ready(state)
|
||||
63
core/engine.py
Normal file
63
core/engine.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
from core.schema import StandardMessage, StandardResponse
|
||||
from core.events.event_bus import bus
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
class BusinessEngine:
|
||||
"""
|
||||
业务逻辑中枢:
|
||||
1. 接收 StandardMessage。
|
||||
2. 决定由哪个 AI 工具或流程处理。
|
||||
3. 返回 StandardResponse。
|
||||
4. 对外广播异步事件。
|
||||
"""
|
||||
def __init__(self, agent_instance: Any = None):
|
||||
"""
|
||||
:param agent_instance: 核心 AI Agent 的实例(比如重构后的 CustomerServiceAgent)
|
||||
"""
|
||||
self.agent = agent_instance
|
||||
|
||||
async def handle_message(self, msg: StandardMessage) -> StandardResponse:
|
||||
"""
|
||||
大脑的思考主入口
|
||||
"""
|
||||
logger.info(f"[Engine] 收到来自 {msg.platform} 的消息: {msg.user_id} -> {msg.content[:50]}")
|
||||
|
||||
# TODO: 这里将接入重构后的 Single Agent + Tool Calling
|
||||
# 目前模拟一个简单的规则响应,展示 StandardResponse 的用法
|
||||
|
||||
if "报价" in msg.content or msg.image_urls:
|
||||
return StandardResponse(
|
||||
reply_content="正在为你查看图片,请稍等...",
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
)
|
||||
|
||||
if "转人工" in msg.content:
|
||||
return StandardResponse(
|
||||
reply_content="正在为你转接设计师...",
|
||||
need_transfer=True,
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
)
|
||||
|
||||
# 兜底回复
|
||||
return StandardResponse(
|
||||
reply_content="你好,我是AI助手,有什么可以帮你的?",
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
)
|
||||
|
||||
async def emit_image_result(self, user_id: str, platform: str, url: str, acc_id: str):
|
||||
"""
|
||||
这是一个业务触发器示例:当图片处理完成时,由 Engine 主动发广播。
|
||||
"""
|
||||
await bus.emit(
|
||||
"MESSAGE_OUTBOUND",
|
||||
user_id=user_id,
|
||||
platform=platform,
|
||||
response=StandardResponse(
|
||||
reply_content=url,
|
||||
msg_type=1, # 图片
|
||||
metadata={"acc_id": acc_id}
|
||||
)
|
||||
)
|
||||
36
core/events/event_bus.py
Normal file
36
core/events/event_bus.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Callable, Dict, List, Any, Awaitable
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
class AsyncEventBus:
|
||||
"""
|
||||
异步事件总线:解耦业务触发与平台发送。
|
||||
支持一个事件被多个订阅者监听。
|
||||
"""
|
||||
def __init__(self):
|
||||
self._listeners: Dict[str, List[Callable[..., Awaitable[None]]]] = {}
|
||||
|
||||
def subscribe(self, event_type: str, callback: Callable[..., Awaitable[None]]):
|
||||
"""订阅事件"""
|
||||
if event_type not in self._listeners:
|
||||
self._listeners[event_type] = []
|
||||
self._listeners[event_type].append(callback)
|
||||
logger.info(f"[EventBus] 新订阅者已注册到事件: {event_type}")
|
||||
|
||||
async def emit(self, event_type: str, **kwargs):
|
||||
"""发布事件:异步广播给所有订阅者"""
|
||||
if event_type not in self._listeners:
|
||||
return
|
||||
|
||||
tasks = []
|
||||
for callback in self._listeners[event_type]:
|
||||
tasks.append(asyncio.create_task(callback(**kwargs)))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info(f"[EventBus] 事件 {event_type} 已成功广播给 {len(tasks)} 个订阅者")
|
||||
|
||||
# 全局单例,所有模块共用这一个广播台
|
||||
bus = AsyncEventBus()
|
||||
@@ -1,218 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.pydantic_ai_agent import AgentResponse, ConversationState, CustomerMessage, CustomerServiceAgent
|
||||
|
||||
|
||||
async def handle_find_image_batch_flow(
|
||||
agent: "CustomerServiceAgent",
|
||||
*,
|
||||
message: "CustomerMessage",
|
||||
state: "ConversationState",
|
||||
customer_text: str,
|
||||
shop_type: str,
|
||||
) -> Optional["AgentResponse"]:
|
||||
"""Handle find-image collecting/quote flow. Return response when handled."""
|
||||
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
|
||||
|
||||
if not (shop_type == "find_image" and agent._is_batch_quote_enabled(message.from_id, message.acc_id)):
|
||||
return None
|
||||
|
||||
incoming_urls = agent._extract_image_urls(customer_text)
|
||||
text_without_urls = agent._strip_urls_from_text(customer_text)
|
||||
short_intent = agent._classify_short_customer_text(text_without_urls)
|
||||
|
||||
if incoming_urls:
|
||||
is_related_followup = bool(text_without_urls and agent._is_related_image_followup_intent(text_without_urls))
|
||||
for u in incoming_urls:
|
||||
if u not in state.pending_image_urls:
|
||||
state.pending_image_urls.append(u)
|
||||
if text_without_urls:
|
||||
agent._append_requirement(state, text_without_urls)
|
||||
if is_related_followup:
|
||||
agent._append_requirement(state, "与上一张相关(截图/局部细节)")
|
||||
state.image_count = len(state.pending_image_urls)
|
||||
agent._refresh_quote_phase(state, "collecting")
|
||||
agent._sync_pending_quote_state(message.from_id, state)
|
||||
|
||||
if agent._is_batch_finish_intent(
|
||||
text=customer_text,
|
||||
state=state,
|
||||
has_incoming_urls=bool(incoming_urls),
|
||||
):
|
||||
should_defer = agent._should_defer_batch_quote(state, mark_ready=True)
|
||||
agent._sync_pending_quote_state(message.from_id, state)
|
||||
if should_defer:
|
||||
defer_fallback = "图片和需求我都收齐了,我先整理下,马上给你报总价。"
|
||||
defer_reply = await agent._render_collection_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
scene="quote_defer_notice",
|
||||
intent_hint="确认已收齐图片与需求,先承接,告知稍后马上报价。",
|
||||
fallback=defer_fallback,
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", defer_reply)
|
||||
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
|
||||
quote_res = await agent._quote_pending_images(state, message)
|
||||
reply_text = agent._colloquialize_reply(quote_res.get("reply", ""))
|
||||
reply_text = await agent._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply=reply_text,
|
||||
scene="batch_quote_reply",
|
||||
)
|
||||
need_transfer = bool(quote_res.get("need_transfer"))
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", 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_fallback = "图片收到了,你有补充就继续发,我这边一起看。"
|
||||
ack_intent = (
|
||||
"告知图片已收到;如果客户继续发图就继续收,发完可统一报价。"
|
||||
if not is_related_followup
|
||||
else "告知这是和上一张相关的截图/局部图,已按同一需求一起处理。"
|
||||
)
|
||||
ack = await agent._render_collection_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
scene="collect_ack",
|
||||
intent_hint=ack_intent,
|
||||
fallback=ack_fallback,
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", ack)
|
||||
return AgentResponse(reply=ack, should_reply=True, need_transfer=False)
|
||||
|
||||
if not state.pending_image_urls:
|
||||
return None
|
||||
|
||||
if text_without_urls:
|
||||
if short_intent == "finish_signal":
|
||||
agent._mark_quote_ready(state)
|
||||
elif short_intent == "progress_query":
|
||||
if state.quote_phase != "ready_to_quote":
|
||||
agent._refresh_quote_phase(state, "waiting_result")
|
||||
elif short_intent == "ack":
|
||||
if state.quote_phase != "ready_to_quote":
|
||||
agent._refresh_quote_phase(state, "collecting")
|
||||
else:
|
||||
agent._append_requirement(state, text_without_urls)
|
||||
agent._refresh_quote_phase(state, "collecting")
|
||||
agent._sync_pending_quote_state(message.from_id, state)
|
||||
if agent._is_find_image_not_edit_conflict(text_without_urls):
|
||||
clarify_fallback = "明白你是要找图,不是做图。你说下要找原图、同款还是高清版,我按这个给你找。"
|
||||
clarify = await agent._render_collection_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
scene="find_not_edit_clarify",
|
||||
intent_hint="确认客户要找图不是做图,并追问是找原图/同款/高清版。",
|
||||
fallback=clarify_fallback,
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", clarify)
|
||||
return AgentResponse(reply=clarify, should_reply=True, need_transfer=False)
|
||||
|
||||
if state.quote_phase == "ready_to_quote" and state.quote_ready_turns <= 0 and short_intent in {"progress_query", "ack", "finish_signal"}:
|
||||
quote_res = await agent._quote_pending_images(state, message)
|
||||
reply_text = agent._colloquialize_reply(quote_res.get("reply", ""))
|
||||
reply_text = await agent._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply=reply_text,
|
||||
scene="batch_quote_reply",
|
||||
)
|
||||
need_transfer = bool(quote_res.get("need_transfer"))
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", reply_text)
|
||||
return AgentResponse(
|
||||
reply=reply_text,
|
||||
should_reply=not need_transfer,
|
||||
need_transfer=need_transfer,
|
||||
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
|
||||
)
|
||||
|
||||
if short_intent == "progress_query" or agent._is_result_followup_query(text_without_urls):
|
||||
progress_fallback = "我这边在跟进了,一有结果马上发你。"
|
||||
progress = await agent._render_collection_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
scene="collect_progress",
|
||||
intent_hint="承接客户的进度/结果追问,简短说明正在跟进,有结果会第一时间回复。",
|
||||
fallback=progress_fallback,
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", progress)
|
||||
return AgentResponse(reply=progress, should_reply=True, need_transfer=False)
|
||||
|
||||
if agent._needs_clarification_in_collecting(text_without_urls):
|
||||
ask_fallback = "你再补一句具体要什么效果,我马上按你的要求来。"
|
||||
ask = await agent._render_collection_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
scene="collect_clarify",
|
||||
intent_hint="客户表达不清,礼貌请对方补充一句关键需求,不要机械,不要生硬。",
|
||||
fallback=ask_fallback,
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", ask)
|
||||
return AgentResponse(reply=ask, should_reply=True, need_transfer=False)
|
||||
if agent._is_batch_finish_intent(
|
||||
text=customer_text,
|
||||
state=state,
|
||||
has_incoming_urls=False,
|
||||
):
|
||||
should_defer = agent._should_defer_batch_quote(state, mark_ready=True)
|
||||
agent._sync_pending_quote_state(message.from_id, state)
|
||||
if should_defer:
|
||||
defer_fallback = "收到,我先把这批图过一遍,马上给你总价。"
|
||||
defer_reply = await agent._render_collection_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
scene="quote_defer_notice",
|
||||
intent_hint="确认已收齐,先承接并告知稍后马上报价。",
|
||||
fallback=defer_fallback,
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", defer_reply)
|
||||
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
|
||||
quote_res = await agent._quote_pending_images(state, message)
|
||||
reply_text = agent._colloquialize_reply(quote_res.get("reply", ""))
|
||||
reply_text = await agent._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply=reply_text,
|
||||
scene="batch_quote_reply",
|
||||
)
|
||||
need_transfer = bool(quote_res.get("need_transfer"))
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", 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_fallback = "需求我记上了,你继续发图,或者让我直接给你报价都行。"
|
||||
remind = await agent._render_collection_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
scene="collect_remind",
|
||||
intent_hint="确认需求已记录,引导客户继续补图或直接让你报价。",
|
||||
fallback=remind_fallback,
|
||||
)
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", remind)
|
||||
return AgentResponse(reply=remind, should_reply=True, need_transfer=False)
|
||||
@@ -1,55 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def handle_image_workflow(*, workflow_router: Any, message: str, data: dict, image_urls: list) -> bool:
|
||||
"""处理图片工作流(根据客户说的话判断执行哪种工作流)。"""
|
||||
if not image_urls:
|
||||
return False
|
||||
|
||||
workflow_type, confidence = workflow_router.detect_workflow(message)
|
||||
|
||||
customer_id = data.get("from_id")
|
||||
acc_id = data.get("acc_id", "")
|
||||
acc_type = data.get("acc_type", "AliWorkbench")
|
||||
image_url = image_urls[0]
|
||||
|
||||
logger.info("[Agent] 检测到工作流类型:%s (置信度:%s)", workflow_type, confidence)
|
||||
|
||||
if workflow_type == "find_image":
|
||||
logger.info("[Agent] 执行查找图片工作流 | 客户:%s", customer_id)
|
||||
from core.workflow import workflow
|
||||
|
||||
return await workflow.find_image_workflow(
|
||||
customer_id=customer_id,
|
||||
image_url=image_url,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
)
|
||||
if workflow_type == "process_image":
|
||||
logger.info("[Agent] 执行处理图片工作流 | 客户:%s", customer_id)
|
||||
from core.workflow import workflow
|
||||
|
||||
return await workflow.process_image_workflow(
|
||||
customer_id=customer_id,
|
||||
image_url=image_url,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
)
|
||||
if workflow_type == "transfer_human":
|
||||
logger.info("[Agent] 执行转人工派单工作流 | 客户:%s", customer_id)
|
||||
from core.workflow import workflow
|
||||
|
||||
return await workflow.transfer_to_designer_workflow(
|
||||
customer_id=customer_id,
|
||||
image_url=image_url,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
reason="客户主动要求转人工",
|
||||
)
|
||||
|
||||
return False
|
||||
@@ -1,113 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from core.ai_reply_flow import execute_ai_turn
|
||||
from core.find_image_flow import handle_find_image_batch_flow
|
||||
from core.order_flow import handle_order_notification
|
||||
from core.prompt_flow import build_prompt_bundle
|
||||
from core.reply_finalize_flow import finalize_ai_reply
|
||||
from utils.metrics_tracker import emit as metrics_emit
|
||||
from utils.observability import build_trace_id
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def process_incoming_message(agent: Any, message: Any) -> Any:
|
||||
"""主消息处理编排:预处理 -> 业务流 -> AI -> 收尾。"""
|
||||
trace_id = build_trace_id(message.acc_id, message.from_id, message.msg_id, message.msg[:64])
|
||||
agent._activity_log(
|
||||
"agent_inbound",
|
||||
trace_id=trace_id,
|
||||
acc_id=message.acc_id,
|
||||
customer_id=message.from_id,
|
||||
msg=message.msg,
|
||||
msg_type=message.msg_type,
|
||||
)
|
||||
metrics_emit("inbound_msg", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
|
||||
state = agent._get_conversation_state(message.from_id)
|
||||
pre_response = await agent.pre_rule_service.run(message=message, state=state, trace_id=trace_id)
|
||||
if pre_response is not None:
|
||||
return pre_response
|
||||
|
||||
new_stage = agent._detect_stage(message.msg)
|
||||
if new_stage != state.stage:
|
||||
state.stage = new_stage
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
state.last_update = datetime.now().isoformat()
|
||||
|
||||
order_response = await handle_order_notification(agent, message=message, state=state)
|
||||
if order_response is not None:
|
||||
return order_response
|
||||
|
||||
customer_text, _ = agent._split_customer_text(message.msg)
|
||||
shop_type = agent._get_shop_type(message.acc_id or "", message.goods_name or "")
|
||||
flow_response = await handle_find_image_batch_flow(
|
||||
agent,
|
||||
message=message,
|
||||
state=state,
|
||||
customer_text=customer_text,
|
||||
shop_type=shop_type,
|
||||
)
|
||||
if flow_response is not None:
|
||||
return flow_response
|
||||
|
||||
prompt_bundle = build_prompt_bundle(agent, message=message, state=state)
|
||||
user_prompt = prompt_bundle.user_prompt
|
||||
deps = prompt_bundle.deps
|
||||
history = prompt_bundle.history
|
||||
|
||||
agent._log_block("PROMPT->AI 前置提示词", user_prompt)
|
||||
|
||||
try:
|
||||
reply_text = await execute_ai_turn(
|
||||
agent,
|
||||
message=message,
|
||||
state=state,
|
||||
user_prompt=user_prompt,
|
||||
deps=deps,
|
||||
history=history,
|
||||
)
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
logger.exception("[Agent] AI 调用失败,使用兜底回复: %s", err_str)
|
||||
agent._activity_log("agent_ai_error", customer_id=message.from_id, acc_id=message.acc_id, error=err_str)
|
||||
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(agent._notify_wechat_overdue())
|
||||
else:
|
||||
asyncio.create_task(
|
||||
agent._notify_wechat(
|
||||
f"⚠️ **AI调用异常**\n"
|
||||
f"客户:{message.from_id}\n"
|
||||
f"店铺:{message.acc_id}\n"
|
||||
f"错误:{err_str[:200]}",
|
||||
tag="AI异常",
|
||||
)
|
||||
)
|
||||
reply_text = None
|
||||
else:
|
||||
metrics_emit("ai_call_success", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
|
||||
if not reply_text:
|
||||
fallback_text = await agent._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply="好嘞,你稍等下,我这边看一下",
|
||||
scene="fallback_reply",
|
||||
)
|
||||
from core.pydantic_ai_agent import AgentResponse
|
||||
|
||||
return AgentResponse(reply=fallback_text, should_reply=True, need_transfer=False)
|
||||
|
||||
return await finalize_ai_reply(
|
||||
agent,
|
||||
message=message,
|
||||
state=state,
|
||||
reply_text=reply_text,
|
||||
)
|
||||
156
core/orchestrator.py
Normal file
156
core/orchestrator.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, List, Any, Dict
|
||||
from collections import deque
|
||||
from core.schema import StandardMessage, StandardResponse
|
||||
from core.adapters.qianniu_adapter import QianniuAdapter
|
||||
from core.pydantic_ai_agent_v2 import CustomerServiceBrain
|
||||
from core.events.event_bus import bus
|
||||
from core.repository import repo
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
class SystemOrchestrator:
|
||||
"""
|
||||
全系统总编排:具备转接冷却、防抖合并、多消息去重、以及精准日志。
|
||||
"""
|
||||
def __init__(self, ws_client=None):
|
||||
self.ws_client = ws_client
|
||||
self.qianniu_adapter = QianniuAdapter(ws_client)
|
||||
self.brain = CustomerServiceBrain()
|
||||
|
||||
# 1. 消息 ID 去重
|
||||
self._processed_msg_ids = deque(maxlen=200)
|
||||
|
||||
# 2. 转接冷却存储 (customer_id -> last_transfer_time)
|
||||
self._last_transfer_time: Dict[str, float] = {}
|
||||
|
||||
# 3. 防抖配置
|
||||
self._debounce_seconds = 5.0
|
||||
self._debounce_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._pending_messages: Dict[str, List[StandardMessage]] = {}
|
||||
self._user_locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
bus.subscribe("MESSAGE_OUTBOUND", self.handle_outbound_event)
|
||||
|
||||
def _get_user_lock(self, user_id: str) -> asyncio.Lock:
|
||||
if user_id not in self._user_locks:
|
||||
self._user_locks[user_id] = asyncio.Lock()
|
||||
return self._user_locks[user_id]
|
||||
|
||||
async def on_raw_message_received(self, platform: str, raw_data: dict):
|
||||
"""链路入口"""
|
||||
try:
|
||||
if platform != "qianniu": return
|
||||
|
||||
std_msg, direction = await self.qianniu_adapter.translate_inbound(raw_data)
|
||||
|
||||
# 过滤心跳
|
||||
if not std_msg.content.strip() and not std_msg.image_urls: return
|
||||
|
||||
# 如果是商家人工回复,静默入库
|
||||
if direction == "out":
|
||||
await repo.save_chat(platform, std_msg.user_id, std_msg.content, "out", acc_id=std_msg.acc_id)
|
||||
return
|
||||
|
||||
# 订单消息处理:静默记录
|
||||
if "[系统订单信息]" in std_msg.content:
|
||||
await self._handle_order_packet(platform, std_msg)
|
||||
await repo.save_chat(platform, std_msg.user_id, std_msg.content, "in", acc_id=std_msg.acc_id)
|
||||
return
|
||||
|
||||
# ID 去重
|
||||
if std_msg.msg_id:
|
||||
if std_msg.msg_id in self._processed_msg_ids: return
|
||||
self._processed_msg_ids.append(std_msg.msg_id)
|
||||
|
||||
# 进入防抖
|
||||
user_id = std_msg.user_id
|
||||
if user_id in self._debounce_tasks: self._debounce_tasks[user_id].cancel()
|
||||
if user_id not in self._pending_messages: self._pending_messages[user_id] = []
|
||||
self._pending_messages[user_id].append(std_msg)
|
||||
|
||||
self._debounce_tasks[user_id] = asyncio.create_task(self._debounced_process(user_id, platform))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Orchestrator] 处理失败: {e}")
|
||||
|
||||
async def _handle_order_packet(self, platform: str, msg: StandardMessage):
|
||||
try:
|
||||
price_match = re.search(r"订单金额:金额:\s*([\d\.]+)元", msg.content)
|
||||
if price_match: await repo.update_task_price(platform, msg.user_id, float(price_match.group(1)))
|
||||
if "买家已付款" in msg.content: await repo.update_task_outcome(platform, msg.user_id, "deal_success")
|
||||
elif any(k in msg.content for k in ["退款", "已关闭", "已取消"]): await repo.update_task_outcome(platform, msg.user_id, "refunded")
|
||||
except Exception: pass
|
||||
|
||||
async def _debounced_process(self, user_id: str, platform: str):
|
||||
try:
|
||||
await asyncio.sleep(self._debounce_seconds)
|
||||
async with self._get_user_lock(user_id):
|
||||
messages = self._pending_messages.pop(user_id, [])
|
||||
if not messages: return
|
||||
|
||||
# A. 合并与元数据修复
|
||||
combined_content = "\n".join([m.content for m in messages if m.content.strip()])
|
||||
all_image_urls = []
|
||||
acc_id = messages[-1].acc_id
|
||||
acc_type = messages[-1].acc_type
|
||||
for m in messages:
|
||||
for url in m.image_urls:
|
||||
if url not in all_image_urls: all_image_urls.append(url)
|
||||
|
||||
# 防抖合并后的消息仍需有 msg_id,避免触发 StandardMessage 校验失败
|
||||
merged_msg_id = messages[-1].msg_id if messages[-1].msg_id else f"merged-{user_id}-{int(time.time() * 1000)}"
|
||||
final_msg = StandardMessage(
|
||||
platform=platform,
|
||||
msg_id=merged_msg_id,
|
||||
user_id=user_id,
|
||||
content=combined_content,
|
||||
image_urls=all_image_urls,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type
|
||||
)
|
||||
|
||||
# B. 持久化
|
||||
db_content = combined_content
|
||||
if all_image_urls: db_content = f"【系统:已收到{len(all_image_urls)}张图】\n{combined_content}"
|
||||
await repo.save_chat(platform, user_id, db_content, "in", acc_id=acc_id)
|
||||
|
||||
# C. 冷却检查:如果 60秒内发过转接,告诉大脑“已处于转接中”
|
||||
is_in_cooldown = (time.time() - self._last_transfer_time.get(user_id, 0)) < 60
|
||||
|
||||
# D. 思考
|
||||
history = await repo.get_chat_history(user_id, limit=10)
|
||||
if history and history[-1]['content'] == db_content: history = history[:-1]
|
||||
|
||||
# 如果在冷却中,在当前消息里注入“当前已在转接中”的信息
|
||||
if is_in_cooldown:
|
||||
final_msg.content = f"【系统:当前已向设计师发出转接请求,请勿再次调用转接工具】\n{final_msg.content}"
|
||||
|
||||
std_res = await self.brain.think_and_reply(final_msg, history=history)
|
||||
|
||||
# E. 发送并记录时间
|
||||
if std_res.should_reply:
|
||||
# 关键修复:补全发送时的元数据,解决日志 customer_id 为空的问题
|
||||
std_res.metadata = {"acc_id": acc_id, "acc_type": acc_type}
|
||||
await self.qianniu_adapter.translate_outbound(std_res, user_id)
|
||||
await repo.save_chat(platform, user_id, std_res.reply_content, "out", acc_id=acc_id)
|
||||
|
||||
if "[转移会话]" in std_res.reply_content:
|
||||
self._last_transfer_time[user_id] = time.time()
|
||||
|
||||
except asyncio.CancelledError: pass
|
||||
except Exception as e: logger.exception(f"[Orchestrator] 处理失败: {e}")
|
||||
|
||||
async def handle_outbound_event(self, user_id: str, platform: str, response: StandardResponse):
|
||||
if platform == "qianniu":
|
||||
await self.qianniu_adapter.translate_outbound(response, user_id)
|
||||
|
||||
# 全局单例
|
||||
orchestrator: Optional[SystemOrchestrator] = None
|
||||
def init_orchestrator(ws_client):
|
||||
global orchestrator
|
||||
orchestrator = SystemOrchestrator(ws_client)
|
||||
return orchestrator
|
||||
@@ -1,64 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from core.post_ops import record_deal_success
|
||||
from core.order_helpers import parse_order_info
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.pydantic_ai_agent import AgentResponse, ConversationState, CustomerMessage, CustomerServiceAgent
|
||||
|
||||
|
||||
async def handle_order_notification(
|
||||
agent: "CustomerServiceAgent",
|
||||
*,
|
||||
message: "CustomerMessage",
|
||||
state: "ConversationState",
|
||||
) -> Optional["AgentResponse"]:
|
||||
"""Handle system order notifications before normal AI dialogue."""
|
||||
from core.pydantic_ai_agent import AgentResponse
|
||||
|
||||
if "系统订单信息" not in message.msg and "订单状态" not in message.msg:
|
||||
return None
|
||||
|
||||
_, order_block = agent._split_customer_text(message.msg)
|
||||
customer_text, _ = agent._split_customer_text(message.msg)
|
||||
order = parse_order_info(order_block or message.msg)
|
||||
pay_status = order.get("pay_status", "")
|
||||
order_status = order.get("order_status", "")
|
||||
|
||||
paid_keywords = ["等待发货", "已付款", "付款成功", "买家已付款"]
|
||||
is_paid = any(kw in pay_status or kw in order_status for kw in paid_keywords)
|
||||
|
||||
if is_paid:
|
||||
asyncio.create_task(agent._check_order_amount(message.from_id, order, message.acc_id))
|
||||
asyncio.create_task(
|
||||
record_deal_success(
|
||||
customer_id=message.from_id,
|
||||
customer_name=message.from_name,
|
||||
acc_id=message.acc_id,
|
||||
platform=message.acc_type,
|
||||
order=order,
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
try:
|
||||
from core.workflow import workflow
|
||||
|
||||
asyncio.create_task(
|
||||
workflow.trigger_processing_on_payment(
|
||||
customer_id=message.from_id,
|
||||
acc_id=message.acc_id,
|
||||
acc_type=message.acc_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("[Agent] 触发作图失败: %s", e)
|
||||
elif not customer_text:
|
||||
logger.info("[Agent] 订单通知静默(%s),跳过回复", pay_status or order_status)
|
||||
return AgentResponse(reply="", should_reply=False, need_transfer=False)
|
||||
|
||||
return None
|
||||
171
core/post_ops.py
171
core/post_ops.py
@@ -1,171 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from utils.metrics_tracker import emit as metrics_emit
|
||||
|
||||
CASE_LIBRARY_LINK = "https://www.yuque.com/zuowei-dfvpq/kge0in/mynala0g35b8cec5"
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
def detect_price(reply: str, state: Any) -> None:
|
||||
numbers = re.findall(r"(\d+)[元]", reply or "")
|
||||
if not numbers:
|
||||
return
|
||||
price = round(int(numbers[0]) / 5) * 5
|
||||
state.last_price = price
|
||||
metrics_emit("quote_generated", customer_id=state.customer_id, price=price)
|
||||
try:
|
||||
from db.customer_db import db
|
||||
|
||||
db.update_last_price(state.customer_id, price)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def detect_discount(message: str, state: Any) -> None:
|
||||
text = message or ""
|
||||
if any(kw in text for kw in ["贵", "便宜", "太贵", "有点贵"]):
|
||||
state.discount_count += 1
|
||||
if state.last_price:
|
||||
try:
|
||||
from db.customer_db import db
|
||||
|
||||
db.record_discount(state.customer_id, state.last_price)
|
||||
except Exception:
|
||||
pass
|
||||
m = re.search(r"(\d+)\s*元|\b(\d+)\s*块", text)
|
||||
offer = None
|
||||
if m:
|
||||
offer = int(m.group(1) or m.group(2))
|
||||
if offer:
|
||||
try:
|
||||
from config.config import MIN_PRICE_FLOOR
|
||||
|
||||
if offer < MIN_PRICE_FLOOR:
|
||||
state.last_price = state.last_price or 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def negotiation_strategy_reply(customer_text: str, state: Any) -> str:
|
||||
text = (customer_text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if any(k in text for k in ["先发效果图", "先看效果", "不放心", "没法确认"]):
|
||||
return (
|
||||
f"小妹整理了一些案例图,亲点这个链接就能看到啦({CASE_LIBRARY_LINK})。"
|
||||
"有什么想要的效果随时告诉我哈,不满意我们这边包退。"
|
||||
)
|
||||
if "有点贵" in text or "就是贵" in text:
|
||||
base = state.last_price if isinstance(state.last_price, int) and state.last_price > 0 else 25
|
||||
two_pack = max(10, round(((base * 2) - 5) / 5) * 5)
|
||||
return f"理解你这边的预算,我给你个实在点的:两张一起按 {two_pack} 元做,行不行?"
|
||||
if any(k in text for k in ["优惠点", "便宜点", "少点", "打折"]):
|
||||
return "可以的,你这边数量上来我就好给价,3张以上我给你打包价。"
|
||||
return ""
|
||||
|
||||
|
||||
async def record_deal_success(
|
||||
*,
|
||||
customer_id: str,
|
||||
customer_name: str,
|
||||
acc_id: str,
|
||||
platform: str,
|
||||
order: dict,
|
||||
state: Any,
|
||||
) -> None:
|
||||
try:
|
||||
from db.deal_outcome_db import record_deal
|
||||
|
||||
order_id = order.get("order_id", "")
|
||||
raw_amount = order.get("amount", "")
|
||||
m = re.search(r"[\d.]+", str(raw_amount))
|
||||
amount = float(m.group()) if m else 0
|
||||
reason = "让价后成交" if (state.discount_count or 0) > 0 else "直接成交"
|
||||
record_deal(
|
||||
customer_id=customer_id,
|
||||
outcome="成交",
|
||||
reason=reason,
|
||||
customer_name=customer_name or "",
|
||||
acc_id=acc_id or "",
|
||||
platform=platform or "",
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
discount_given=(state.discount_count or 0) > 0,
|
||||
)
|
||||
try:
|
||||
from db.customer_db import db
|
||||
|
||||
if order_id:
|
||||
db.add_order(customer_id, order_id, amount)
|
||||
db.clear_quote_no_convert(customer_id)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("[Agent] 成交记录: %s %s %s元", customer_id, reason, amount)
|
||||
except Exception as e:
|
||||
logger.exception("[Agent] 成交记录失败: %s", e)
|
||||
|
||||
|
||||
async def record_deal_fail(
|
||||
*,
|
||||
customer_id: str,
|
||||
customer_name: str,
|
||||
acc_id: str,
|
||||
platform: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
try:
|
||||
from db.deal_outcome_db import record_deal
|
||||
from db.customer_db import db
|
||||
|
||||
record_deal(
|
||||
customer_id=customer_id,
|
||||
outcome="未成交",
|
||||
reason=reason,
|
||||
customer_name=customer_name or "",
|
||||
acc_id=acc_id or "",
|
||||
platform=platform or "",
|
||||
)
|
||||
db.mark_quote_no_convert(customer_id)
|
||||
logger.info("[Agent] 未成交记录: %s %s", customer_id, reason)
|
||||
except Exception as e:
|
||||
logger.exception("[Agent] 未成交记录失败: %s", e)
|
||||
|
||||
|
||||
async def auto_tag(message: Any, state: Any) -> None:
|
||||
try:
|
||||
from db.customer_db import db
|
||||
|
||||
cid = message.from_id
|
||||
msg = (message.msg or "").lower()
|
||||
if any(kw in msg for kw in ["还有", "多张", "好几张", "一批", "下次还"]):
|
||||
db.set_bulk_potential(cid, "有")
|
||||
db.add_upsell_opportunity(cid, "批量打包")
|
||||
if any(kw in msg for kw in ["psd", "分层", "源文件"]):
|
||||
db.add_upsell_opportunity(cid, "分层PSD")
|
||||
db.update_preferred_format(cid, "psd")
|
||||
if "jpg" in msg or "jpeg" in msg:
|
||||
db.update_preferred_format(cid, "jpg")
|
||||
if "png" in msg:
|
||||
db.update_preferred_format(cid, "png")
|
||||
if any(kw in msg for kw in ["分辨率", "dpi", "尺寸", "大图", "印刷"]):
|
||||
db.update_preferred_size(cid, message.msg[:30])
|
||||
if any(kw in msg for kw in ["拍了", "下单了", "好的", "行"]) and state.last_price:
|
||||
db.update_decision_speed(cid, "快")
|
||||
type_keywords = {
|
||||
"印花": ["印花", "花纹", "图案", "面料", "布料", "纺织"],
|
||||
"logo": ["logo", "标志", "品牌", "商标"],
|
||||
"人物": ["人物", "人像", "照片", "脸", "头像"],
|
||||
"产品": ["产品", "商品", "包装", "实物"],
|
||||
"老照片": ["老照片", "旧照片", "发黄", "修复"],
|
||||
}
|
||||
for img_type, keywords in type_keywords.items():
|
||||
if any(kw in message.msg for kw in keywords):
|
||||
db.add_image_type(cid, img_type)
|
||||
break
|
||||
db.auto_compute_tags(cid)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,191 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def split_customer_text(msg: str) -> tuple[str, str]:
|
||||
"""
|
||||
把混合消息拆分为(客户真实文字, 系统订单块)。
|
||||
平台有时把客户文字和系统订单通知拼在同一条消息里。
|
||||
"""
|
||||
order_marker = re.search(r"\[系统订单信息\]|\[系统通知\]", msg or "")
|
||||
if order_marker:
|
||||
customer_text = (msg or "")[: order_marker.start()].strip()
|
||||
order_block = (msg or "")[order_marker.start() :].strip()
|
||||
else:
|
||||
customer_text = (msg or "").strip()
|
||||
order_block = ""
|
||||
return customer_text, order_block
|
||||
|
||||
|
||||
def build_prompt(
|
||||
*,
|
||||
message: Any,
|
||||
state: Any,
|
||||
extract_image_url: Callable[[str], str],
|
||||
shop_type_resolver: Callable[[str, str], str],
|
||||
shop_persona_resolver: Callable[[str, str], str],
|
||||
parse_order_info: Callable[[str], dict[str, str]],
|
||||
build_order_instruction: Callable[[str, str], str],
|
||||
) -> str:
|
||||
"""构建提示词。"""
|
||||
msg_content = message.msg
|
||||
stage_info = f"【当前阶段】{state.stage}"
|
||||
|
||||
customer_text, order_block = split_customer_text(msg_content)
|
||||
has_order = bool(order_block)
|
||||
|
||||
if has_order:
|
||||
order = parse_order_info(order_block)
|
||||
if order.get("order_id"):
|
||||
state.last_order_id = order["order_id"]
|
||||
stage_info += f"\n【订单号】{order['order_id']}"
|
||||
if order.get("order_status"):
|
||||
state.order_status = order["order_status"]
|
||||
stage_info += f"\n【订单状态】{order['order_status']}"
|
||||
if order.get("pay_status"):
|
||||
stage_info += f"\n【支付状态】{order['pay_status']}"
|
||||
if order.get("amount"):
|
||||
stage_info += f"\n【订单金额】{order['amount']}元"
|
||||
if order.get("quantity"):
|
||||
stage_info += f"\n【数量】{order['quantity']}件"
|
||||
if order.get("order_time"):
|
||||
stage_info += f"\n【下单时间】{order['order_time']}"
|
||||
if order.get("buyer_note"):
|
||||
stage_info += f"\n【买家备注】{order['buyer_note']}"
|
||||
|
||||
if state.discount_count > 0:
|
||||
stage_info += f"\n【客户压价次数】{state.discount_count}"
|
||||
|
||||
shop_type = shop_type_resolver(message.acc_id or "", message.goods_name or "")
|
||||
shop_persona = shop_persona_resolver(message.acc_id or "", message.goods_name or "")
|
||||
shop_hint = ""
|
||||
try:
|
||||
from config.config import CONFIG_DIR
|
||||
import json
|
||||
|
||||
cfg_path = CONFIG_DIR / "shop_prompts.json"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
hints = cfg.get("type_hints", {})
|
||||
shop_hint = hints.get(shop_type, "")
|
||||
if not shop_hint and message.acc_id:
|
||||
sh = cfg.get("shops", {}).get(message.acc_id, {})
|
||||
shop_hint = sh.get("hint", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prompt = f"""收到新消息:
|
||||
{stage_info}
|
||||
|
||||
发送者: {message.from_name} ({message.from_id})
|
||||
"""
|
||||
if message.goods_name:
|
||||
prompt += f"商品名称: {message.goods_name}\n"
|
||||
if shop_hint:
|
||||
prompt += f"\n{shop_hint}\n"
|
||||
if shop_persona:
|
||||
prompt += f"\n【店铺人设】{shop_persona}\n"
|
||||
|
||||
order_paid = False
|
||||
order_unpaid = False
|
||||
if has_order:
|
||||
order = parse_order_info(order_block)
|
||||
paid_kws = ["等待发货", "已付款", "付款成功", "买家已付款"]
|
||||
unpaid_kws = ["等待买家付款", "待付款", "未付款"]
|
||||
ps = order.get("pay_status", "")
|
||||
os_ = order.get("order_status", "")
|
||||
if any(kw in ps or kw in os_ for kw in paid_kws):
|
||||
order_paid = True
|
||||
elif any(kw in ps or kw in os_ for kw in unpaid_kws):
|
||||
order_unpaid = True
|
||||
|
||||
progress_keywords = [
|
||||
"安排了吗",
|
||||
"安排好了吗",
|
||||
"好了吗",
|
||||
"做了吗",
|
||||
"做好了吗",
|
||||
"弄好了吗",
|
||||
"好了没",
|
||||
"做了没",
|
||||
"什么时候好",
|
||||
"多久好",
|
||||
"进度",
|
||||
"催一下",
|
||||
"快点",
|
||||
"什么时候能好",
|
||||
"做完了吗",
|
||||
]
|
||||
|
||||
if customer_text:
|
||||
prompt += f"\n客户说:{customer_text}\n"
|
||||
image_url = extract_image_url(customer_text)
|
||||
price_keywords = ["多少钱", "多少", "价格", "几块", "怎么收费", "报个价"]
|
||||
size_keywords = [
|
||||
"尺寸",
|
||||
"比例",
|
||||
"宽",
|
||||
"高",
|
||||
"米",
|
||||
"厘米",
|
||||
"mm",
|
||||
"cm",
|
||||
"横版",
|
||||
"竖版",
|
||||
"2米",
|
||||
"3米",
|
||||
"改成",
|
||||
"做成",
|
||||
]
|
||||
has_size_change = any(kw in customer_text.lower() for kw in [k.lower() for k in size_keywords])
|
||||
|
||||
if shop_type == "gemini_api":
|
||||
prompt += "\n【Gemini API 店铺】客户问账号/pro/续费/套餐等,按 API 客服自然回复,不要求发图。"
|
||||
elif image_url:
|
||||
prompt += "\n客户在继续发图阶段:先确认“已收图”,并引导客户把图和要求一次发完;等客户明确“发完了/统一报价”后再统一报价。"
|
||||
elif any(kw in customer_text for kw in price_keywords):
|
||||
last_url = extract_image_url(msg_content)
|
||||
if last_url:
|
||||
prompt += "\n客户在询问价格:若客户已确认发完,则给总报价;若还在发图,先引导发完后统一报价。"
|
||||
else:
|
||||
prompt += "\n客户在询问价格但未发图:先简短承接(如“在看呢/收到”),不要机械连发;再自然引导对方发图。"
|
||||
if has_size_change:
|
||||
prompt += (
|
||||
"\n⚠️ 尺寸改动场景:优先判断图片主体是否会被拉伸变形,"
|
||||
"不是只看整张图宽高比。若会变形,要先提示“需要补图/扩边”,再给报价。"
|
||||
)
|
||||
elif has_size_change:
|
||||
prompt += (
|
||||
"\n客户在改尺寸/改比例:先按主体比例判断是否会变形,"
|
||||
"不是只看整图比例。若目标尺寸会拉伸主体,先明确说明要补图(如上下补图/扩边)再报价。"
|
||||
)
|
||||
elif any(kw in customer_text for kw in progress_keywords):
|
||||
if order_unpaid:
|
||||
prompt += "\n⚠️【订单未付款】客户问安排进度,但订单还未付款。自然告知拍下付款后马上安排即可。"
|
||||
elif order_paid:
|
||||
prompt += "\n客户催单,订单已付款,自然回复在做了/快了之类。"
|
||||
else:
|
||||
prompt += "\n客户催单,查询当前处理状态后自然回复。"
|
||||
elif any(kw in customer_text for kw in ["贵", "有点贵", "太贵", "算了", "便宜点", "少点", "打折", "贵哦"]):
|
||||
prompt += "\n⚠️【客户嫌贵】客户已表达价格顾虑或要放弃。禁止追问「什么问题」「说清楚点」。\n→ 第一步:直接让价一次(如原价 20→15),话术自然。\n→ 第二步:如果让价后客户还是嫌贵,引导客户自己去注册星绘平台:\n - 每个话术都必须带网址 xinhui.cloud\n - 强调「自己去弄」「自己操作」「自己下单」\n - 示例:「嫌贵就去 xinhui.cloud 注册个号,自己有余额可以弄」「xinhui.cloud 去注册下,新用户送体验金,自己弄便宜」\n - 语气直接,让客户自己去操作」"
|
||||
elif any(kw in customer_text for kw in ["擦边", "黄色", "色情", "大尺度", "性感图", "露点", "半裸"]):
|
||||
prompt += "\n⚠️【拒绝】客户询问擦边/黄色/敏感内容。直接拒绝,不接单,不说「发图来看看」。自然回复如:这类不做/不接/做不了。"
|
||||
else:
|
||||
prompt += "\n根据客户说的内容自然回应,像真人聊天,不要套模板。"
|
||||
|
||||
if has_order:
|
||||
order = parse_order_info(order_block)
|
||||
order_instruction = build_order_instruction(order.get("pay_status", ""), order.get("order_status", ""))
|
||||
if customer_text:
|
||||
if not order_unpaid:
|
||||
prompt += f"\n\n【背景参考-订单通知】{order_instruction}"
|
||||
else:
|
||||
prompt += f"\n\n{order_instruction}"
|
||||
|
||||
if not customer_text and not has_order:
|
||||
prompt += f"\n消息内容: {msg_content}\n请按工作流规则回复。"
|
||||
|
||||
return prompt
|
||||
@@ -1,50 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.pydantic_ai_agent import AgentDeps, ConversationState, CustomerMessage, CustomerServiceAgent
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptBundle:
|
||||
user_prompt: str
|
||||
deps: "AgentDeps"
|
||||
history: List
|
||||
|
||||
|
||||
def build_prompt_bundle(
|
||||
agent: "CustomerServiceAgent",
|
||||
*,
|
||||
message: "CustomerMessage",
|
||||
state: "ConversationState",
|
||||
) -> PromptBundle:
|
||||
from core.pydantic_ai_agent import AgentDeps
|
||||
|
||||
user_prompt = agent._build_prompt(message, state)
|
||||
|
||||
profile_context = agent._get_customer_profile_context(message.from_id)
|
||||
if profile_context:
|
||||
user_prompt = profile_context + "\n\n" + user_prompt
|
||||
|
||||
refusal_hint = agent._get_refusal_context_hint(message.from_id, message.msg, profile_context or "")
|
||||
if refusal_hint:
|
||||
user_prompt = refusal_hint + "\n\n" + user_prompt
|
||||
|
||||
conv_context = agent._get_conversation_context(message.from_id, acc_id=message.acc_id or "")
|
||||
if conv_context:
|
||||
user_prompt = conv_context + user_prompt
|
||||
|
||||
intent_hint = agent._get_intent_emotion_hint(message.msg)
|
||||
if intent_hint:
|
||||
user_prompt = intent_hint + "\n\n" + user_prompt
|
||||
|
||||
deps = AgentDeps(
|
||||
msg_id=message.msg_id,
|
||||
acc_id=message.acc_id,
|
||||
from_id=message.from_id,
|
||||
platform=message.acc_type,
|
||||
)
|
||||
history = agent.message_histories.get(message.from_id, [])
|
||||
return PromptBundle(user_prompt=user_prompt, deps=deps, history=history)
|
||||
File diff suppressed because it is too large
Load Diff
91
core/pydantic_ai_agent_v2.py
Normal file
91
core/pydantic_ai_agent_v2.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Optional, Any, Dict
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
from core.schema import StandardMessage, StandardResponse
|
||||
from core.agent_tools import register_agent_tools
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
from core.skill_manager import skill_manager
|
||||
|
||||
class CustomerServiceBrain:
|
||||
"""
|
||||
重构后的单一 Agent 大脑:
|
||||
【全能终极版】统一称呼为“设计师”,支持下线安抚。
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str = None):
|
||||
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||
self.base_url = os.getenv("OPENAI_BASE_URL")
|
||||
self.model_name = model_name or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||
|
||||
model = OpenAIChatModel(
|
||||
model_name=self.model_name,
|
||||
provider=OpenAIProvider(api_key=self.api_key, base_url=self.base_url)
|
||||
)
|
||||
|
||||
all_skills = skill_manager.get_all_skills_text()
|
||||
|
||||
# --- 统一口径后的 System Prompt ---
|
||||
system_prompt = (
|
||||
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话高端、专业。\n\n"
|
||||
|
||||
"【统一称呼规范】\n"
|
||||
"1. 严禁使用'师傅'、'客服'、'专员'等词汇!\n"
|
||||
"2. 必须统一称呼为【设计师】。比如:'找设计师看下'、'设计师马上来'、'等设计师核价'。\n\n"
|
||||
|
||||
"【核心逻辑】\n"
|
||||
"1. 业务:只聊高清修复和找原图。引导发图 -> 问需求 -> 找设计师。\n"
|
||||
"2. 下线安抚:如果工具返回 'ERROR_NO_DESIGNER_ONLINE',说明设计师们【下班/下线】了。回:'亲亲,设计师现在下班啦,需求我先记下,明天第一时间回您哈!'。\n"
|
||||
"3. 正在转接中:如果看到系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'。\n"
|
||||
"4. 没转接时:引导发图 -> 问需求 -> 调工具转人工。\n\n"
|
||||
"5. 语气:淘宝亲切风,多用'亲亲'、'铁子'。每句回复【严禁超过15字】!\n\n"
|
||||
|
||||
"【必杀令】\n"
|
||||
"1. 每句回复严禁超过15个字!\n"
|
||||
"2. 严禁报价,严禁复读图片已收到的情况。\n"
|
||||
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n\n"
|
||||
|
||||
f"业务参考:\n{all_skills}"
|
||||
)
|
||||
|
||||
self.agent = Agent(model=model, system_prompt=system_prompt)
|
||||
register_agent_tools(self.agent)
|
||||
|
||||
async def think_and_reply(self, msg: StandardMessage, history: List[dict] = []) -> StandardResponse:
|
||||
try:
|
||||
# 构造增强上下文(强灌输)
|
||||
user_content = msg.content
|
||||
if msg.image_urls:
|
||||
user_content = f"【系统通知:收到客户 {len(msg.image_urls)} 张图】\n{user_content}"
|
||||
|
||||
recent_context = ""
|
||||
if history:
|
||||
lines = [f"{('客户' if h['role']=='user' else '我')}:{h['content']}" for h in history[-6:]]
|
||||
recent_context = "【近期对话回顾】\n" + "\n".join(lines) + "\n----------------\n"
|
||||
|
||||
full_input = f"{recent_context}现在的对话:{user_content}"
|
||||
|
||||
result = await self.agent.run(full_input, message_history=history)
|
||||
|
||||
if hasattr(result, 'data') and isinstance(result.data, str):
|
||||
reply_text = result.data
|
||||
elif hasattr(result, 'output') and isinstance(result.output, str):
|
||||
reply_text = result.output
|
||||
else:
|
||||
reply_text = str(result.data) if hasattr(result, 'data') else "在呢铁子。"
|
||||
|
||||
need_transfer = "[转移会话]" in reply_text
|
||||
|
||||
return StandardResponse(
|
||||
reply_content=reply_text,
|
||||
need_transfer=need_transfer,
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Brain Error]: {e}")
|
||||
return StandardResponse(reply_content="好哒,设计师正在看图,稍等回你。", metadata={"acc_id": msg.acc_id})
|
||||
@@ -1,112 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from utils.metrics_tracker import emit as metrics_emit
|
||||
from core.post_ops import auto_tag, detect_discount, detect_price, record_deal_fail
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.pydantic_ai_agent import AgentResponse, ConversationState, CustomerMessage, CustomerServiceAgent
|
||||
|
||||
|
||||
async def finalize_ai_reply(
|
||||
agent: "CustomerServiceAgent",
|
||||
*,
|
||||
message: "CustomerMessage",
|
||||
state: "ConversationState",
|
||||
reply_text: str,
|
||||
) -> "AgentResponse":
|
||||
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
|
||||
|
||||
try:
|
||||
from utils.content_filter import should_block_reply
|
||||
|
||||
blocked, fallback = should_block_reply(reply_text)
|
||||
if blocked:
|
||||
logger.warning("[Agent] 敏感词拦截,使用兜底回复")
|
||||
reply_text = fallback or "好的,您稍等,我帮您确认一下"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from utils.api_cost_tracker import record
|
||||
|
||||
record("openai_chat", count=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
detect_price(reply_text, state)
|
||||
detect_discount(message.msg, state)
|
||||
asyncio.create_task(auto_tag(message, state))
|
||||
|
||||
need_transfer = False
|
||||
transfer_msg = ""
|
||||
transfer_keywords = ["TRANSFER_REQUESTED", "[转移会话]", "转移会话", "转人工", "转接"]
|
||||
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)
|
||||
|
||||
evo_hit = agent._evolution_enabled_for_customer(message.from_id)
|
||||
if evo_hit and agent._is_service_risk_inquiry(message.msg):
|
||||
if agent._evolution_has_proposal("policy-risk-transfer"):
|
||||
need_transfer = True
|
||||
transfer_msg = TRANSFER_MESSAGE
|
||||
metrics_emit("evolution_force_transfer", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
if agent._evolution_has_proposal("tone-empathy-pack"):
|
||||
reply_text = "抱歉让您不舒服了,这边先为您转接人工专员马上处理。"
|
||||
metrics_emit("evolution_empathy_reply", customer_id=message.from_id, acc_id=message.acc_id)
|
||||
|
||||
customer_text, _ = agent._split_customer_text(message.msg)
|
||||
no_convert_keywords = ["算了", "不要了", "不做了", "下次再说", "先不弄了"]
|
||||
if customer_text and state.last_price and state.last_price > 0:
|
||||
if any(kw in customer_text for kw in no_convert_keywords):
|
||||
reason = "嫌贵放弃" if any(k in customer_text for k in ["贵", "贵了", "便宜"]) else "放弃"
|
||||
asyncio.create_task(
|
||||
record_deal_fail(
|
||||
customer_id=message.from_id,
|
||||
customer_name=message.from_name,
|
||||
acc_id=message.acc_id,
|
||||
platform=message.acc_type,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
|
||||
should_reply = bool(reply_text and reply_text.strip()) and not need_transfer
|
||||
if evo_hit and need_transfer and agent._evolution_has_proposal("tone-empathy-pack"):
|
||||
should_reply = True
|
||||
|
||||
if should_reply:
|
||||
reply_text = await agent._rewrite_reply_with_ai(
|
||||
message=message,
|
||||
state=state,
|
||||
reply=reply_text,
|
||||
scene="final_reply",
|
||||
)
|
||||
|
||||
if should_reply:
|
||||
state.last_reply_at = datetime.now()
|
||||
logger.info("[REPLY->CUSTOMER] %s", reply_text)
|
||||
else:
|
||||
logger.info("[REPLY->CUSTOMER] <静默/不发送>")
|
||||
|
||||
agent._activity_log(
|
||||
"agent_outbound_decision",
|
||||
customer_id=message.from_id,
|
||||
should_reply=should_reply,
|
||||
need_transfer=need_transfer,
|
||||
reply=reply_text or "",
|
||||
transfer_msg=transfer_msg,
|
||||
)
|
||||
|
||||
return AgentResponse(
|
||||
reply=reply_text or "",
|
||||
should_reply=should_reply,
|
||||
need_transfer=need_transfer,
|
||||
transfer_msg=transfer_msg,
|
||||
)
|
||||
69
core/repository.py
Normal file
69
core/repository.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime
|
||||
from db.customer_db import db as customer_db
|
||||
from db.image_tasks_db import db as task_db
|
||||
from db.chat_log_db import log_message, get_conversation
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
class DataRepository:
|
||||
"""
|
||||
异步数据仓库:使用 asyncio.to_thread 屏蔽底层同步 IO 阻塞。
|
||||
"""
|
||||
def __init__(self):
|
||||
self.customer_db = customer_db
|
||||
self.task_db = task_db
|
||||
|
||||
# --- 聊天记录 (异步化) ---
|
||||
|
||||
async def save_chat(self, platform: str, user_id: str, content: str, direction: str, acc_id: str = ""):
|
||||
"""异步持久化存储聊天记录"""
|
||||
return await asyncio.to_thread(
|
||||
log_message,
|
||||
customer_id=user_id,
|
||||
message=content,
|
||||
direction=direction,
|
||||
platform=platform,
|
||||
acc_id=acc_id
|
||||
)
|
||||
|
||||
async def get_chat_history(self, user_id: str, limit: int = 10) -> List[dict]:
|
||||
"""异步获取历史记录"""
|
||||
rows = await asyncio.to_thread(get_conversation, user_id, limit=limit)
|
||||
history = []
|
||||
for r in rows:
|
||||
role = "user" if r["direction"] == "in" else "assistant"
|
||||
history.append({"role": role, "content": r["message"]})
|
||||
return history
|
||||
|
||||
# --- 客户相关 (异步化) ---
|
||||
|
||||
async def get_customer(self, platform: str, user_id: str):
|
||||
customer_key = f"{platform}:{user_id}"
|
||||
return await asyncio.to_thread(self.customer_db.get_customer, customer_key)
|
||||
|
||||
# --- 任务相关 (异步化) ---
|
||||
|
||||
async def create_task(self, platform: str, user_id: str, image_url: str, operation: str, requirements: str = ""):
|
||||
return await asyncio.to_thread(
|
||||
self.task_db.add_task,
|
||||
customer_id=user_id,
|
||||
platform=platform,
|
||||
original_image=image_url,
|
||||
operation=operation,
|
||||
requirements=requirements,
|
||||
status="pending"
|
||||
)
|
||||
|
||||
async def update_task_price(self, platform: str, user_id: str, price: float):
|
||||
"""异步记录成交价"""
|
||||
return await asyncio.to_thread(self.task_db.update_price, user_id, platform, price)
|
||||
|
||||
async def update_task_outcome(self, platform: str, user_id: str, outcome: str):
|
||||
"""异步记录最终结局"""
|
||||
return await asyncio.to_thread(self.task_db.update_outcome, user_id, platform, outcome)
|
||||
|
||||
# 全局异步仓库单例
|
||||
repo = DataRepository()
|
||||
@@ -1,71 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def is_political_inquiry(text: str) -> bool:
|
||||
"""文本前置风控:政治人物/政治事件/政治图片相关询问一律拒绝。"""
|
||||
s = (text or "").strip().lower()
|
||||
if not s:
|
||||
return False
|
||||
kw = (
|
||||
"政治",
|
||||
"涉政",
|
||||
"党政",
|
||||
"政治人物",
|
||||
"政治事件",
|
||||
"政治图片",
|
||||
"政治海报",
|
||||
"政治宣传",
|
||||
"领导人",
|
||||
"伟人",
|
||||
"元帅",
|
||||
"将军",
|
||||
"红色人物",
|
||||
"党史",
|
||||
"天安门",
|
||||
"人民大会堂",
|
||||
"中南海",
|
||||
"习近平",
|
||||
"毛泽东",
|
||||
"邓小平",
|
||||
"江泽民",
|
||||
"胡锦涛",
|
||||
"李克强",
|
||||
"周恩来",
|
||||
"特朗普",
|
||||
"拜登",
|
||||
"普京",
|
||||
"泽连斯基",
|
||||
"trump",
|
||||
"biden",
|
||||
"putin",
|
||||
"zelensky",
|
||||
"xi jinping",
|
||||
)
|
||||
if any(k in s for k in kw):
|
||||
return True
|
||||
return bool(re.search(r"(元帅|将军|领导人|政治人物|政治事件).*(照片|图片|头像|原图)?", s))
|
||||
|
||||
|
||||
def is_map_inquiry(text: str) -> bool:
|
||||
"""地图类需求一律拒绝(按业务规则)。"""
|
||||
s = (text or "").strip().lower()
|
||||
if not s:
|
||||
return False
|
||||
kw = (
|
||||
"地图",
|
||||
"地形图",
|
||||
"行政区划图",
|
||||
"世界地图",
|
||||
"中国地图",
|
||||
"卫星地图",
|
||||
"导航图",
|
||||
"航海图",
|
||||
"作战地图",
|
||||
"军事地图",
|
||||
"map",
|
||||
"topographic map",
|
||||
"satellite map",
|
||||
)
|
||||
return any(k in s for k in kw)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .engine import Rule, RuleContext, RuleEngine, RuleResult
|
||||
|
||||
__all__ = ["Rule", "RuleContext", "RuleEngine", "RuleResult"]
|
||||
@@ -1,59 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleContext:
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self.data.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
self.data[key] = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleResult:
|
||||
matched: bool = False
|
||||
stop: bool = False
|
||||
action: str = ""
|
||||
payload: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
Predicate = Callable[[RuleContext], Awaitable[bool]]
|
||||
Action = Callable[[RuleContext], Awaitable[RuleResult]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
name: str
|
||||
priority: int
|
||||
predicate: Predicate
|
||||
action: Action
|
||||
|
||||
|
||||
class RuleEngine:
|
||||
"""Priority-ordered async rule chain."""
|
||||
|
||||
def __init__(self, rules: Optional[List[Rule]] = None):
|
||||
self._rules: List[Rule] = sorted(rules or [], key=lambda x: x.priority)
|
||||
|
||||
def add_rule(self, rule: Rule) -> None:
|
||||
self._rules.append(rule)
|
||||
self._rules.sort(key=lambda x: x.priority)
|
||||
|
||||
async def run(self, ctx: RuleContext) -> RuleResult:
|
||||
for rule in self._rules:
|
||||
if not await rule.predicate(ctx):
|
||||
continue
|
||||
result = await rule.action(ctx)
|
||||
if not result.matched:
|
||||
result.matched = True
|
||||
if not result.action:
|
||||
result.action = rule.name
|
||||
if result.stop:
|
||||
return result
|
||||
return RuleResult(matched=False, stop=False, action="no_match")
|
||||
29
core/schema.py
Normal file
29
core/schema.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Any
|
||||
from datetime import datetime
|
||||
|
||||
class StandardMessage(BaseModel):
|
||||
"""全平台通用的输入消息协议"""
|
||||
platform: str = "qianniu" # 来源平台:qianniu, wechat, feishu, console
|
||||
msg_id: str # 消息唯一ID
|
||||
user_id: str # 发送者唯一ID
|
||||
user_name: str = "" # 发送者昵称
|
||||
content: str # 消息文本内容
|
||||
image_urls: List[str] = [] # 提取出来的图片链接
|
||||
acc_id: str = "" # 商家/店铺账号ID
|
||||
acc_type: str = "" # 平台类型标识
|
||||
timestamp: datetime = Field(default_factory=datetime.now)
|
||||
raw_data: Any = None # 原始消息体(仅供调试或特殊逻辑备查)
|
||||
|
||||
# 扩展字段:针对电商场景
|
||||
goods_name: Optional[str] = None
|
||||
goods_order: Optional[str] = None
|
||||
|
||||
class StandardResponse(BaseModel):
|
||||
"""大脑给出的通用回复协议"""
|
||||
reply_content: str # 回复文本或图片URL
|
||||
msg_type: int = 0 # 0: 文本, 1: 图片, 2: 撤回, 9: 转人工
|
||||
should_reply: bool = True # 是否需要发送
|
||||
need_transfer: bool = False # 是否触发转人工
|
||||
transfer_group: str = "" # 转人工的分组ID
|
||||
metadata: dict = {} # 额外元数据(如埋点、调试信息)
|
||||
56
core/skill_manager.py
Normal file
56
core/skill_manager.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
import glob
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
class SkillManager:
|
||||
"""
|
||||
技能包管理器:
|
||||
1. 自动扫描 skills/ 目录下的 SKILL.md 文件。
|
||||
2. 提供按需加载和组合技能的能力。
|
||||
3. 支持热加载(无需重启即可更新 AI 知识)。
|
||||
"""
|
||||
def __init__(self, skills_dir: str = "skills"):
|
||||
self.skills_dir = Path(skills_dir)
|
||||
self._skill_cache: Dict[str, str] = {}
|
||||
self.reload_skills()
|
||||
|
||||
def reload_skills(self):
|
||||
"""扫描并加载所有技能文件"""
|
||||
new_cache = {}
|
||||
skill_files = glob.glob(str(self.skills_dir / "**/SKILL.md"), recursive=True)
|
||||
|
||||
for file_path in skill_files:
|
||||
try:
|
||||
path = Path(file_path)
|
||||
skill_name = path.parent.name.lower()
|
||||
content = path.read_text(encoding="utf-8")
|
||||
new_cache[skill_name] = content
|
||||
except Exception as e:
|
||||
logger.error(f"[SkillManager] 加载技能失败 {file_path}: {e}")
|
||||
|
||||
self._skill_cache = new_cache
|
||||
logger.info(f"[SkillManager] 成功加载 {len(self._skill_cache)} 个技能包: {list(self._skill_cache.keys())}")
|
||||
|
||||
def get_skill(self, name: str) -> str:
|
||||
"""获取单个技能内容"""
|
||||
return self._skill_cache.get(name.lower(), "")
|
||||
|
||||
def compose_skills(self, names: List[str]) -> str:
|
||||
"""组合多个技能内容,用于注入 System Prompt"""
|
||||
parts = []
|
||||
for name in names:
|
||||
content = self.get_skill(name)
|
||||
if content:
|
||||
parts.append(f"### 技能:{name}\n{content}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def get_all_skills_text(self) -> str:
|
||||
"""获取所有技能的合集(用于全能大脑模式)"""
|
||||
return self.compose_skills(list(self._skill_cache.keys()))
|
||||
|
||||
# 全局单例
|
||||
skill_manager = SkillManager()
|
||||
@@ -1,53 +0,0 @@
|
||||
import logging
|
||||
|
||||
from utils.observability import build_trace_id
|
||||
from core.websocket_brain_flow import decide_brain_action, execute_brain_action
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def handle_agent_reply_flow(client, data: dict, *, workflow, shop_type_resolver):
|
||||
"""处理单条消息:统一走 Brain 决策 + 执行。"""
|
||||
try:
|
||||
msg_text = client.to_chinese(data.get("msg", ""))
|
||||
customer_id = data.get("from_id", "")
|
||||
trace_id = build_trace_id(data.get("acc_id", ""), customer_id, data.get("msg_id", ""), msg_text[:64])
|
||||
data["_trace_id"] = trace_id
|
||||
shop_type = shop_type_resolver(data.get("acc_id", ""), client.to_chinese(data.get("goods_name", "") or ""))
|
||||
|
||||
customer_msg = client._build_customer_message(data)
|
||||
decision = await decide_brain_action(
|
||||
client,
|
||||
data,
|
||||
customer_msg,
|
||||
trace_id=trace_id,
|
||||
msg_text=msg_text,
|
||||
shop_type=shop_type,
|
||||
)
|
||||
client._activity_log(
|
||||
"brain_decision",
|
||||
trace_id=trace_id,
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("from_id", ""),
|
||||
action=decision.action,
|
||||
source=decision.source,
|
||||
should_reply=bool(decision.should_reply),
|
||||
need_transfer=bool(decision.need_transfer),
|
||||
)
|
||||
await execute_brain_action(
|
||||
client,
|
||||
data,
|
||||
decision=decision,
|
||||
trace_id=trace_id,
|
||||
msg_text=msg_text,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Agent 处理失败: %s", e)
|
||||
client._activity_log(
|
||||
"agent_process_error",
|
||||
trace_id=data.get("_trace_id", ""),
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("from_id", ""),
|
||||
error=str(e),
|
||||
)
|
||||
@@ -1,100 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
def cancel_auto_quote_task(client, key: str, reason: str = ""):
|
||||
task = client._auto_quote_tasks.get(key)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
client._activity_log("auto_quote_cancel", key=key, reason=reason or "unknown")
|
||||
|
||||
|
||||
def build_auto_quote_signature(state: Any) -> str:
|
||||
"""为待报价内容生成稳定签名,用于避免同一批内容反复自动触发。"""
|
||||
urls = list(getattr(state, "pending_image_urls", []) or [])
|
||||
reqs = list(getattr(state, "pending_requirements", []) or [])
|
||||
req_tail = reqs[-6:] if len(reqs) > 6 else reqs
|
||||
return "||".join(urls) + "##" + "||".join(req_tail)
|
||||
|
||||
|
||||
async def schedule_auto_quote(client, data: dict, *, shop_type_resolver):
|
||||
"""
|
||||
智能兜底:客户发图后若长时间不再补充消息,自动触发一次报价,避免会话卡住。
|
||||
"""
|
||||
if not client.enable_agent or not client.agent:
|
||||
return
|
||||
try:
|
||||
shop_type = shop_type_resolver(data.get('acc_id', ''), client.to_chinese(data.get('goods_name', '') or ''))
|
||||
if shop_type != "find_image":
|
||||
return
|
||||
cid = data.get('from_id', '')
|
||||
key = client._customer_key(data)
|
||||
state = client.agent._get_conversation_state(cid)
|
||||
if not state or not getattr(state, "pending_image_urls", None):
|
||||
cancel_auto_quote_task(client, key, reason="no_pending_images")
|
||||
client._auto_quote_done_sig.pop(key, None)
|
||||
return
|
||||
if state.quote_phase not in {"collecting", "waiting_result"}:
|
||||
return
|
||||
current_sig = build_auto_quote_signature(state)
|
||||
if current_sig and client._auto_quote_done_sig.get(key) == current_sig:
|
||||
client._activity_log(
|
||||
"auto_quote_skip_duplicate",
|
||||
key=key,
|
||||
pending_count=len(state.pending_image_urls),
|
||||
)
|
||||
return
|
||||
try:
|
||||
idle_seconds = max(8, int(os.getenv("AUTO_QUOTE_IDLE_SECONDS", "18")))
|
||||
except Exception:
|
||||
idle_seconds = 18
|
||||
|
||||
cancel_auto_quote_task(client, key, reason="reschedule")
|
||||
|
||||
async def _delayed_auto_quote(capture_key: str, capture_data: dict, wait_s: int, capture_sig: str):
|
||||
await asyncio.sleep(wait_s)
|
||||
async with client._get_customer_lock(capture_key):
|
||||
capture_cid = capture_data.get('from_id', '')
|
||||
st = client.agent._get_conversation_state(capture_cid)
|
||||
if not st or not st.pending_image_urls:
|
||||
client._auto_quote_done_sig.pop(capture_key, None)
|
||||
return
|
||||
# 内容变化时,放弃旧触发(会在新一轮消息后重新调度)。
|
||||
if build_auto_quote_signature(st) != capture_sig:
|
||||
return
|
||||
# 标记本批次已自动触发,避免同内容循环“马上报价”。
|
||||
client._auto_quote_done_sig[capture_key] = capture_sig
|
||||
# 直接置为可报价,走内部自动报价入口(不伪造客户语句)。
|
||||
client.agent._mark_quote_ready(st)
|
||||
client.agent._sync_pending_quote_state(capture_cid, st)
|
||||
client._activity_log(
|
||||
"auto_quote_trigger",
|
||||
key=capture_key,
|
||||
pending_count=len(st.pending_image_urls),
|
||||
wait_s=wait_s,
|
||||
)
|
||||
notify_data = dict(capture_data)
|
||||
notify_data["msg_id"] = "auto_quote_idle_trigger"
|
||||
notify_data["msg"] = "__AUTO_QUOTE_INTERNAL_TRIGGER__"
|
||||
notify_msg = client._build_customer_message(notify_data)
|
||||
response = await client.agent.build_auto_quote_reply(st, notify_msg)
|
||||
if response.should_reply and response.reply and not response.need_transfer:
|
||||
await client.send_reply(capture_data, response.reply)
|
||||
client._activity_log(
|
||||
"auto_quote_sent",
|
||||
key=capture_key,
|
||||
reply=response.reply,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(_delayed_auto_quote(key, dict(data), idle_seconds, current_sig))
|
||||
client._auto_quote_tasks[key] = task
|
||||
client._activity_log(
|
||||
"auto_quote_scheduled",
|
||||
key=key,
|
||||
pending_count=len(state.pending_image_urls),
|
||||
phase=state.quote_phase,
|
||||
wait_s=idle_seconds,
|
||||
)
|
||||
except Exception as e:
|
||||
client._activity_log("auto_quote_schedule_error", error=str(e), key=client._customer_key(data))
|
||||
@@ -1,311 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrainDecision:
|
||||
action: str # reply | quote | transfer | noop
|
||||
source: str
|
||||
reply: str = ""
|
||||
transfer_msg: str = ""
|
||||
should_reply: bool = False
|
||||
need_transfer: bool = False
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _extract_json_obj(text: str) -> dict[str, Any] | None:
|
||||
if not text:
|
||||
return None
|
||||
m = re.search(r"\{[\s\S]*\}", text)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return json.loads(m.group(0))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _ai_policy_brain_decide(client, data: dict, *, msg_text: str, shop_type: str) -> BrainDecision | None:
|
||||
if not client.enable_agent or not client.agent or not client.AgentDeps:
|
||||
return None
|
||||
|
||||
acc_id = str(data.get("acc_id", "") or "")
|
||||
customer_id = str(data.get("from_id", "") or "")
|
||||
current_urls = client._extract_image_urls(msg_text)
|
||||
recent_urls = client._collect_recent_image_urls(customer_id, acc_id, max_count=6)
|
||||
key = client._customer_key(data)
|
||||
pending_urls = client._pending_images.get(key) or []
|
||||
|
||||
try:
|
||||
order_status = client._detect_order_status(msg_text)
|
||||
has_image_url = client._msg_has_image_url(msg_text)
|
||||
refers_images = client._msg_refers_images(msg_text)
|
||||
is_price = client._msg_is_price_inquiry(msg_text)
|
||||
is_req = client._msg_is_requirement(msg_text)
|
||||
ext_contact = client._msg_requests_external_contact(msg_text)
|
||||
except Exception:
|
||||
order_status, has_image_url, refers_images, is_price, is_req, ext_contact = "", False, False, False, False, False
|
||||
|
||||
deps = client.AgentDeps(
|
||||
msg_id=str(data.get("msg_id", "") or "brain_policy"),
|
||||
acc_id=acc_id,
|
||||
from_id=customer_id,
|
||||
platform=str(data.get("acc_type", "") or "AliWorkbench"),
|
||||
)
|
||||
|
||||
prompt = (
|
||||
"你是淘宝客服系统的主决策Brain,只做决策,不要解释。\n"
|
||||
"你必须根据历史规则和当前上下文,输出唯一动作。\n"
|
||||
"可选动作 action: reply / quote / transfer / noop。\n"
|
||||
"历史规则(完整继承):\n"
|
||||
"1) 客户发图/补图:先自然承接,再根据上下文决定继续收集或报价;\n"
|
||||
"2) 客户询价且有可用图片(当前或最近)时,优先 action=quote;\n"
|
||||
"3) 若有 pending 图片且客户催报价/补充需求,优先 quote_mode=flush_pending;\n"
|
||||
"4) 仅打招呼/短无意义文本:可 action=reply 简短承接,不要机械模板;\n"
|
||||
"5) 索要外部联系方式(微信/QQ/手机号)时,不外呼,站内引导;\n"
|
||||
"6) 订单已付款:可回执安排处理;未付款/待付款:提醒完成付款;\n"
|
||||
"7) 地图/政治/高风险内容:谨慎,必要时 transfer 或拒绝性 reply;\n"
|
||||
"8) 尺寸超限/不可做场景:给明确边界,不要胡乱承诺;\n"
|
||||
"9) 客户没发图却问价:先承接,再引导发图;\n"
|
||||
"10) 避免重复外发,避免同一句话反复说。\n"
|
||||
"\n"
|
||||
"quote_mode 可选: flush_pending / analyze_current_or_recent / collect_only\n"
|
||||
"只输出 JSON:\n"
|
||||
'{"action":"reply|quote|transfer|noop","reply":"","transfer_msg":"","quote_mode":"","reason":""}\n\n'
|
||||
f"店铺类型: {shop_type}\n"
|
||||
f"legacy_fast_quote_enabled: {str(bool(client._legacy_fast_quote_enabled)).lower()}\n"
|
||||
f"客户原话: {msg_text}\n"
|
||||
f"has_image_url: {has_image_url}\n"
|
||||
f"current_image_urls_count: {len(current_urls)}\n"
|
||||
f"recent_image_urls_count: {len(recent_urls)}\n"
|
||||
f"pending_image_urls_count: {len(pending_urls)}\n"
|
||||
f"refers_images: {refers_images}\n"
|
||||
f"is_price_inquiry: {is_price}\n"
|
||||
f"is_requirement: {is_req}\n"
|
||||
f"requests_external_contact: {ext_contact}\n"
|
||||
f"order_status: {order_status or 'none'}\n"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
|
||||
raw = str(getattr(result, "output", "") or "").strip()
|
||||
obj = _extract_json_obj(raw)
|
||||
if not obj:
|
||||
client._activity_log(
|
||||
"brain_policy_parse_error",
|
||||
acc_id=acc_id,
|
||||
customer_id=customer_id,
|
||||
raw=raw[:300],
|
||||
)
|
||||
return None
|
||||
|
||||
action = str(obj.get("action", "") or "").strip().lower()
|
||||
reply = str(obj.get("reply", "") or "").strip()
|
||||
transfer_msg = str(obj.get("transfer_msg", "") or "").strip()
|
||||
quote_mode = str(obj.get("quote_mode", "") or "").strip().lower()
|
||||
reason = str(obj.get("reason", "") or "").strip()
|
||||
|
||||
payload: dict[str, Any] | None = None
|
||||
if action == "quote":
|
||||
mode = quote_mode or "analyze_current_or_recent"
|
||||
if mode == "flush_pending":
|
||||
payload = {"mode": "flush_pending", "key": key, "pre_reply": reply}
|
||||
elif mode == "collect_only":
|
||||
payload = {"mode": "collect_only", "pre_reply": reply}
|
||||
else:
|
||||
urls = current_urls or recent_urls
|
||||
payload = {"mode": "analyze_urls", "urls": urls, "pre_reply": reply}
|
||||
|
||||
decision = BrainDecision(
|
||||
action=action if action in {"reply", "quote", "transfer", "noop"} else "noop",
|
||||
source="brain_ai_policy",
|
||||
reply=reply,
|
||||
transfer_msg=transfer_msg,
|
||||
should_reply=bool(reply),
|
||||
need_transfer=(action == "transfer"),
|
||||
payload=payload,
|
||||
)
|
||||
client._activity_log(
|
||||
"brain_policy_raw",
|
||||
acc_id=acc_id,
|
||||
customer_id=customer_id,
|
||||
action=decision.action,
|
||||
quote_mode=quote_mode,
|
||||
reason=reason,
|
||||
)
|
||||
return decision
|
||||
except Exception as e:
|
||||
client._activity_log(
|
||||
"brain_policy_error",
|
||||
acc_id=acc_id,
|
||||
customer_id=customer_id,
|
||||
error=str(e),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def decide_brain_action(client, data: dict, customer_msg, *, trace_id: str, msg_text: str, shop_type: str) -> BrainDecision:
|
||||
"""统一主决策层:优先由 Brain AI 决策;失败时回退 Agent 默认决策。"""
|
||||
ai_decision = await _ai_policy_brain_decide(client, data, msg_text=msg_text, shop_type=shop_type)
|
||||
if ai_decision is not None:
|
||||
return ai_decision
|
||||
|
||||
# 回退:保持可用性
|
||||
logger.info("Agent 正在处理消息...")
|
||||
client._activity_log(
|
||||
"agent_process_start",
|
||||
trace_id=trace_id,
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("from_id", ""),
|
||||
msg=msg_text,
|
||||
)
|
||||
response = await client.agent.process_message(customer_msg)
|
||||
client._activity_log(
|
||||
"agent_process_done",
|
||||
trace_id=trace_id,
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("from_id", ""),
|
||||
result="ok",
|
||||
should_reply=bool(response.should_reply),
|
||||
need_transfer=bool(response.need_transfer),
|
||||
)
|
||||
if response.need_transfer:
|
||||
return BrainDecision(
|
||||
action="transfer",
|
||||
source="fallback_agent",
|
||||
reply=response.reply or "",
|
||||
transfer_msg=response.transfer_msg or "",
|
||||
should_reply=bool(response.should_reply),
|
||||
need_transfer=True,
|
||||
)
|
||||
if response.should_reply and response.reply:
|
||||
return BrainDecision(
|
||||
action="reply",
|
||||
source="fallback_agent",
|
||||
reply=response.reply,
|
||||
should_reply=True,
|
||||
need_transfer=False,
|
||||
)
|
||||
return BrainDecision(action="noop", source="fallback_agent", should_reply=False, need_transfer=False)
|
||||
|
||||
|
||||
async def execute_brain_action(client, data: dict, *, decision: BrainDecision, trace_id: str, msg_text: str):
|
||||
"""统一执行层:只执行标准动作。"""
|
||||
customer_id = data.get("from_id", "")
|
||||
|
||||
if customer_id:
|
||||
client._touch_customer_last_contact(customer_id)
|
||||
|
||||
if decision.action == "transfer":
|
||||
logger.info("Agent 决定转接人工")
|
||||
client._activity_log(
|
||||
"agent_transfer",
|
||||
trace_id=trace_id,
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("from_id", ""),
|
||||
transfer_msg=decision.transfer_msg,
|
||||
)
|
||||
client._fire_and_forget(
|
||||
client._post_tianwang_callback(
|
||||
"message_processed",
|
||||
data,
|
||||
extra={
|
||||
"should_reply": bool(decision.should_reply),
|
||||
"need_transfer": True,
|
||||
"agent_reply": decision.reply or "",
|
||||
"transfer_msg": decision.transfer_msg or "",
|
||||
},
|
||||
)
|
||||
)
|
||||
await client.transfer_to_human(data, decision.transfer_msg)
|
||||
client._push_chat_to_wechat_safe(
|
||||
data=data,
|
||||
customer_msg=msg_text,
|
||||
reply_msg=decision.transfer_msg or "转接",
|
||||
tag="转人工",
|
||||
)
|
||||
return
|
||||
|
||||
if decision.action == "reply":
|
||||
text = (decision.reply or "").strip()
|
||||
if not text:
|
||||
return
|
||||
await asyncio.sleep(0.6)
|
||||
client._activity_log(
|
||||
"agent_reply",
|
||||
trace_id=trace_id,
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("from_id", ""),
|
||||
reply=text,
|
||||
)
|
||||
await client.send_reply(data, text)
|
||||
await client._maybe_schedule_auto_quote(data)
|
||||
client._fire_and_forget(
|
||||
client._post_tianwang_callback(
|
||||
"message_processed",
|
||||
data,
|
||||
extra={
|
||||
"should_reply": True,
|
||||
"need_transfer": False,
|
||||
"agent_reply": text,
|
||||
},
|
||||
)
|
||||
)
|
||||
client._push_chat_to_wechat_safe(
|
||||
data=data,
|
||||
customer_msg=msg_text,
|
||||
reply_msg=text,
|
||||
tag="正常AI回复",
|
||||
)
|
||||
return
|
||||
|
||||
if decision.action == "quote":
|
||||
payload = decision.payload or {}
|
||||
pre_reply = str(payload.get("pre_reply", "") or "").strip()
|
||||
if pre_reply:
|
||||
await client.send_reply(data, pre_reply)
|
||||
mode = str(payload.get("mode", "") or "")
|
||||
if mode == "flush_pending":
|
||||
key = str(payload.get("key", "") or "")
|
||||
if key:
|
||||
await client._flush_pending_images(key, data)
|
||||
elif mode == "analyze_urls":
|
||||
urls = payload.get("urls") or []
|
||||
if isinstance(urls, list) and urls:
|
||||
if len(urls) == 1:
|
||||
asyncio.create_task(client._analyze_single_and_reply(data, urls[0]))
|
||||
else:
|
||||
asyncio.create_task(client._analyze_multi_and_reply(data, urls))
|
||||
else:
|
||||
await client.send_reply(data, "你把要处理的图再发我一下,我马上给你看。")
|
||||
else:
|
||||
if not pre_reply:
|
||||
await client.send_reply(data, "收到,我先看一下哈,稍等哈。")
|
||||
return
|
||||
|
||||
# noop
|
||||
client._activity_log(
|
||||
"agent_no_reply",
|
||||
trace_id=trace_id,
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("from_id", ""),
|
||||
)
|
||||
client._fire_and_forget(
|
||||
client._post_tianwang_callback(
|
||||
"message_processed",
|
||||
data,
|
||||
extra={
|
||||
"should_reply": False,
|
||||
"need_transfer": False,
|
||||
"agent_reply": "",
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -1,48 +0,0 @@
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
async def post_tianwang_callback_flow(client, event: str, data: dict, extra: Optional[Dict[str, Any]] = None):
|
||||
"""将消息处理事件回调给天网。"""
|
||||
if not client._tianwang_callback_url:
|
||||
return
|
||||
try:
|
||||
import httpx
|
||||
|
||||
trust_env = os.getenv("TIANWANG_CALLBACK_TRUST_ENV", "false").lower() in ("1", "true", "yes")
|
||||
payload = {
|
||||
"event": event,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"agent_name": client._tianwang_agent_name,
|
||||
"acc_id": str(data.get("acc_id", "") or ""),
|
||||
"customer_id": str(data.get("from_id", "") or ""),
|
||||
"customer_name": client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")),
|
||||
"msg_id": str(data.get("msg_id", "") or ""),
|
||||
"msg_type": int(data.get("msg_type", 0) or 0),
|
||||
"msg": client.to_chinese(data.get("msg", "") or ""),
|
||||
"goods_name": client.to_chinese(data.get("goods_name", "") or ""),
|
||||
"goods_order": client.to_chinese(data.get("goods_order", "") or ""),
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
async with httpx.AsyncClient(timeout=6, trust_env=trust_env) as http_client:
|
||||
resp = await http_client.post(client._tianwang_callback_url, json=payload)
|
||||
ok = 200 <= resp.status_code < 300
|
||||
client._activity_log(
|
||||
"tianwang_callback",
|
||||
result="ok" if ok else "http_error",
|
||||
event_name=event,
|
||||
status_code=resp.status_code,
|
||||
acc_id=payload["acc_id"],
|
||||
customer_id=payload["customer_id"],
|
||||
)
|
||||
except Exception as e:
|
||||
client._activity_log(
|
||||
"tianwang_callback",
|
||||
result="error",
|
||||
event_name=event,
|
||||
acc_id=str(data.get("acc_id", "") or ""),
|
||||
customer_id=str(data.get("from_id", "") or ""),
|
||||
error=str(e),
|
||||
)
|
||||
@@ -1,556 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from utils.observability import emit_activity
|
||||
from core.websocket_agent_reply_flow import handle_agent_reply_flow
|
||||
from core.websocket_quote_flow import handle_single_image_quote, handle_multi_image_quote
|
||||
from core.websocket_debounce_flow import (
|
||||
debounce_agent_reply,
|
||||
pick_debounce_seconds,
|
||||
guess_intent_for_debounce,
|
||||
looks_like_requirement_text,
|
||||
rand_between,
|
||||
msg_has_image_url,
|
||||
msg_refers_images,
|
||||
extract_image_urls,
|
||||
collect_recent_image_urls,
|
||||
)
|
||||
from core.websocket_auto_quote_flow import (
|
||||
cancel_auto_quote_task,
|
||||
build_auto_quote_signature,
|
||||
schedule_auto_quote,
|
||||
)
|
||||
from core.websocket_system_inquiry_flow import (
|
||||
load_system_inquiry_rules,
|
||||
normalize_kw_list,
|
||||
resolve_system_inquiry_policy,
|
||||
match_system_inquiry,
|
||||
handle_system_inquiry,
|
||||
)
|
||||
from core.websocket_transfer_flow import transfer_to_human_flow
|
||||
from core.websocket_outbound_arbiter_flow import (
|
||||
normalize_reply_semantic_key,
|
||||
classify_outbound_reply,
|
||||
template_family,
|
||||
outbound_arbiter,
|
||||
)
|
||||
from core.websocket_followup_flow import (
|
||||
unreplied_followup_loop,
|
||||
scan_and_send_unreplied_followups,
|
||||
compose_ai_scene_reply,
|
||||
)
|
||||
from core.websocket_outbound_flow import (
|
||||
send_reply_flow,
|
||||
ai_generate_outbound_reply,
|
||||
ai_guard_outbound_reply,
|
||||
colloquialize_outbound_reply,
|
||||
)
|
||||
from core.websocket_runtime_flow import command_handler_flow, run_client_flow
|
||||
from core.websocket_workflow_flow import workflow_agent_notify_flow, workflow_send_flow
|
||||
from core.websocket_connection_flow import connect_flow, receive_messages_flow, handle_message_flow
|
||||
from core.websocket_send_flow import send_text_flow, send_image_flow, send_message_flow
|
||||
from core.websocket_callback_flow import post_tianwang_callback_flow
|
||||
from core.websocket_customer_profile_flow import extract_and_save_customer_info_flow
|
||||
from core.websocket_message_utils_flow import (
|
||||
is_transfer_msg,
|
||||
pick_transfer_greeting,
|
||||
is_shop_card,
|
||||
extract_customer_text_from_shop_card_msg,
|
||||
has_chat_history,
|
||||
should_ignore,
|
||||
get_msg_type_name,
|
||||
to_chinese_text,
|
||||
)
|
||||
from core.websocket_dispatch_flow import dispatch_assign_once_flow
|
||||
from core.websocket_image_entry_flow import handle_image_message_flow
|
||||
from core.websocket_misc_rules_flow import (
|
||||
msg_is_price_inquiry,
|
||||
detect_order_status,
|
||||
msg_requests_external_contact,
|
||||
extract_size_pairs_m,
|
||||
oversize_reply_if_needed,
|
||||
)
|
||||
from core.websocket_summary_flow import save_conversation_summary_flow
|
||||
from core.websocket_helpers_flow import (
|
||||
fire_and_forget,
|
||||
prune_seen,
|
||||
log_inbound_once,
|
||||
log_outbound_once,
|
||||
build_customer_message,
|
||||
touch_customer_last_contact,
|
||||
push_chat_to_wechat_safe,
|
||||
)
|
||||
from core.websocket_logger_setup import setup_logger
|
||||
|
||||
# ========== 转接分组映射 ==========
|
||||
def _get_transfer_group(acc_id: str) -> str:
|
||||
"""根据店铺 acc_id 获取转接分组 ID。不同店铺对应不同客服分组。"""
|
||||
from config.config import CONFIG_DIR
|
||||
config_path = CONFIG_DIR / "transfer_groups.json"
|
||||
default_group = "20252916034"
|
||||
try:
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
return cfg.get(acc_id, cfg.get("default", default_group))
|
||||
except Exception:
|
||||
logger.debug("读取转接分组配置失败,使用默认分组", exc_info=True)
|
||||
return default_group
|
||||
|
||||
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:
|
||||
from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage, AgentDeps, _get_shop_type
|
||||
from db.customer_db import db
|
||||
from core.workflow import workflow
|
||||
AGENT_AVAILABLE = True
|
||||
except Exception as e:
|
||||
AGENT_AVAILABLE = False
|
||||
workflow = None
|
||||
AgentDeps = None
|
||||
_get_shop_type = lambda acc_id, goods_name: "find_image"
|
||||
import traceback
|
||||
logger.info(f"警告: Agent 模块导入失败: {e}")
|
||||
traceback.print_exc()
|
||||
logger.info("将使用基础回复功能")
|
||||
|
||||
|
||||
class QingjianAPIClient:
|
||||
"""轻简API WebSocket客户端"""
|
||||
|
||||
def __init__(self, uri=None, enable_agent: bool = True):
|
||||
from config.config import QINGJIAN_WS_URI
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
from config.config import MESSAGE_DEBOUNCE_SECONDS
|
||||
self.uri = uri or QINGJIAN_WS_URI
|
||||
self.websocket = None
|
||||
self.running = True
|
||||
self.reply_id = "tb001" # 回复时使用的from_id
|
||||
self.last_msg = None # 保存最后一条消息
|
||||
self.enable_agent = enable_agent and AGENT_AVAILABLE
|
||||
self.logger = logger
|
||||
self.AgentDeps = AgentDeps
|
||||
self.agent = None
|
||||
self._replied_msg_ids: deque = deque(maxlen=200) # 已回复消息ID,FIFO去重
|
||||
|
||||
# 消息防抖:同一客户连续发消息时,等待 N 秒后合并处理
|
||||
self._DEBOUNCE_SECONDS = MESSAGE_DEBOUNCE_SECONDS if isinstance(MESSAGE_DEBOUNCE_SECONDS, int) else 8
|
||||
self._adaptive_debounce_enabled = os.getenv("ADAPTIVE_DEBOUNCE_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
self._debounce_tasks: dict = {} # customer_key -> asyncio.Task
|
||||
self._pending_msgs: dict = {} # customer_key -> list[data]
|
||||
self._image_enabled = IMAGE_MODULE_ENABLED
|
||||
|
||||
# 同客户消息串行:保证「发图→这个高清」等顺序,避免误判
|
||||
self._customer_locks: dict = {} # customer_key -> asyncio.Lock
|
||||
# agent_reply 并发上限,防止 API 打满
|
||||
self._agent_semaphore = asyncio.Semaphore(8)
|
||||
self._pending_images: dict = {}
|
||||
self._pending_image_tasks: dict = {}
|
||||
self._auto_quote_tasks: dict = {} # customer_key -> asyncio.Task
|
||||
self._auto_quote_done_sig: dict = {} # customer_key -> signature(同一批内容仅自动触发一次)
|
||||
# 旧版“看图即报价”快速链路(默认关闭,避免与 Agent 批量收集逻辑并发打架)
|
||||
self._legacy_fast_quote_enabled = os.getenv("LEGACY_FAST_IMAGE_QUOTE", "false").lower() in ("1", "true", "yes")
|
||||
self._system_inquiry_rules = self._load_system_inquiry_rules()
|
||||
self._last_reply_sent_at: dict = {} # customer_key -> monotonic ts
|
||||
self._outbound_semantic_seen: dict = {} # customer_key -> {semantic_key: ts}
|
||||
self._outbound_class_seen: dict = {} # customer_key -> {reply_class: ts}
|
||||
self._outbound_template_seen: dict = {} # customer_key -> {template_family: ts}
|
||||
self._unreplied_followup_sent: dict = {} # customer_key -> monotonic ts(补偿消息节流)
|
||||
self._inbound_log_seen: dict = {} # signature -> monotonic ts(防重复写入)
|
||||
self._outbound_log_seen: dict = {} # signature -> monotonic ts(防重复写入)
|
||||
self._tianwang_callback_url = (
|
||||
os.getenv("TIANWANG_CALLBACK_URL", "").strip()
|
||||
or "http://139.199.3.75:18789/api/callback"
|
||||
)
|
||||
self._tianwang_agent_name = os.getenv("TIANWANG_AGENT_NAME", "终结者").strip() or "终结者"
|
||||
self._reply_guard_enabled = os.getenv("AI_REPLY_GUARD_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
self._reply_guard_verbose = os.getenv("AI_REPLY_GUARD_VERBOSE", "false").lower() in ("1", "true", "yes")
|
||||
self._force_ai_generate_reply = os.getenv("FORCE_AI_GENERATE_ALL_REPLIES", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
# 延迟加载任务模块(避免循环导入)
|
||||
self.task_scheduler = None
|
||||
self.task_manager = None
|
||||
self.trigger_engine = None
|
||||
|
||||
# 多进程分片支持
|
||||
self.shard_keys: set = set() # 本进程负责的客户 key 集合
|
||||
self.worker_id = int(os.getenv('AI_CS_WORKER_ID', '0'))
|
||||
self.worker_count = max(1, int(os.getenv('AI_CS_WORKER_COUNT', '1')))
|
||||
|
||||
# 初始化 Agent
|
||||
if self.enable_agent:
|
||||
try:
|
||||
self.agent = CustomerServiceAgent()
|
||||
logger.info(f"[{self.get_time()}] Agent 初始化成功")
|
||||
except Exception as e:
|
||||
logger.info(f"[{self.get_time()}] Agent 初始化失败: {e}")
|
||||
self.enable_agent = False
|
||||
|
||||
# 注册 workflow 消息发送回调(供图片AI完成后推送消息用)
|
||||
if workflow:
|
||||
workflow.register_send_callback(self._workflow_send)
|
||||
workflow.register_agent_notify_callback(self._workflow_agent_notify)
|
||||
|
||||
def _activity_log(self, event: str, **kwargs):
|
||||
"""统一活动日志,便于按 event 检索完整链路。"""
|
||||
emit_activity(
|
||||
logger,
|
||||
event=event,
|
||||
trace_id=str(kwargs.pop("trace_id", "")),
|
||||
customer_id=str(kwargs.pop("customer_id", "")),
|
||||
result=str(kwargs.pop("result", "ok")),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _post_tianwang_callback(self, event: str, data: dict, extra: Optional[Dict[str, Any]] = None):
|
||||
await post_tianwang_callback_flow(self, event, data, extra=extra)
|
||||
|
||||
|
||||
async def connect(self):
|
||||
await connect_flow(self)
|
||||
|
||||
def _customer_key(self, data: dict) -> str:
|
||||
"""同一店铺+客户 = 同一会话"""
|
||||
return f"{data.get('acc_id','')}:{data.get('from_id','')}"
|
||||
|
||||
def _get_customer_lock(self, key: str) -> asyncio.Lock:
|
||||
if key not in self._customer_locks:
|
||||
self._customer_locks[key] = asyncio.Lock()
|
||||
return self._customer_locks[key]
|
||||
|
||||
def _is_owned_by_this_worker(self, customer_key: str) -> bool:
|
||||
"""
|
||||
多进程兜底路由:
|
||||
- 若显式分片存在,用显式分片;
|
||||
- 否则按 customer_key 哈希到固定 worker,避免多进程重复处理同一消息。
|
||||
"""
|
||||
if self.shard_keys:
|
||||
return customer_key in self.shard_keys
|
||||
if self.worker_count <= 1:
|
||||
return True
|
||||
try:
|
||||
h = int(hashlib.md5(customer_key.encode("utf-8")).hexdigest()[:8], 16)
|
||||
return (h % self.worker_count) == self.worker_id
|
||||
except Exception:
|
||||
return self.worker_id == 0
|
||||
|
||||
async def _agent_reply_serialized(self, data: dict):
|
||||
"""同客户串行 + 全局并发限制,再执行 agent_reply"""
|
||||
key = self._customer_key(data)
|
||||
async with self._get_customer_lock(key):
|
||||
async with self._agent_semaphore:
|
||||
await self.agent_reply(data)
|
||||
|
||||
def _fire_and_forget(self, coro):
|
||||
fire_and_forget(self, coro)
|
||||
|
||||
@staticmethod
|
||||
def _prune_seen(seen: dict, now_mono: float, ttl_sec: float = 8.0):
|
||||
prune_seen(seen, now_mono, ttl_sec=ttl_sec)
|
||||
|
||||
def _log_inbound_once(self, data: dict):
|
||||
log_inbound_once(self, data, _chat_log)
|
||||
|
||||
def _log_outbound_once(self, original_msg: dict, reply_content: str):
|
||||
log_outbound_once(self, original_msg, reply_content, _chat_log)
|
||||
|
||||
def _build_customer_message(self, data: dict) -> CustomerMessage:
|
||||
return build_customer_message(self, data, CustomerMessage)
|
||||
|
||||
def _touch_customer_last_contact(self, customer_id: str):
|
||||
touch_customer_last_contact(self, customer_id, db)
|
||||
|
||||
def _push_chat_to_wechat_safe(
|
||||
self,
|
||||
*,
|
||||
data: dict,
|
||||
customer_msg: str,
|
||||
reply_msg: str,
|
||||
tag: str,
|
||||
goods_name: str = "",
|
||||
) -> None:
|
||||
push_chat_to_wechat_safe(
|
||||
self,
|
||||
data=data,
|
||||
customer_msg=customer_msg,
|
||||
reply_msg=reply_msg,
|
||||
tag=tag,
|
||||
goods_name=goods_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_reply_semantic_key(text: str) -> str:
|
||||
return normalize_reply_semantic_key(text)
|
||||
|
||||
@staticmethod
|
||||
def _classify_outbound_reply(text: str) -> str:
|
||||
return classify_outbound_reply(text)
|
||||
|
||||
@staticmethod
|
||||
def _template_family(reply: str) -> str:
|
||||
return template_family(reply)
|
||||
|
||||
def _outbound_arbiter(self, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]:
|
||||
return outbound_arbiter(self, original_msg, reply_content, trace_id)
|
||||
|
||||
async def _unreplied_followup_loop(self):
|
||||
await unreplied_followup_loop(self)
|
||||
|
||||
async def _scan_and_send_unreplied_followups(self):
|
||||
await scan_and_send_unreplied_followups(self)
|
||||
|
||||
async def _compose_ai_scene_reply(
|
||||
self,
|
||||
*,
|
||||
original_msg: dict,
|
||||
scene: str,
|
||||
intent_hint: str,
|
||||
fallback: str,
|
||||
) -> str:
|
||||
return await compose_ai_scene_reply(
|
||||
self,
|
||||
original_msg=original_msg,
|
||||
scene=scene,
|
||||
intent_hint=intent_hint,
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
async def receive_messages(self):
|
||||
await receive_messages_flow(self)
|
||||
|
||||
async def handle_message(self, message):
|
||||
await handle_message_flow(self, message, shop_type_resolver=_get_shop_type)
|
||||
|
||||
async def _debounce_agent_reply(self, data: dict):
|
||||
await debounce_agent_reply(self, data)
|
||||
|
||||
@staticmethod
|
||||
def _rand_between(low: float, high: float) -> float:
|
||||
return rand_between(low, high)
|
||||
|
||||
def _guess_intent_for_debounce(self, msg: str) -> str:
|
||||
return guess_intent_for_debounce(self, msg)
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_requirement_text(msg: str) -> bool:
|
||||
return looks_like_requirement_text(msg)
|
||||
|
||||
def _pick_debounce_seconds(self, data: dict, msg: str) -> float:
|
||||
return pick_debounce_seconds(self, data, msg)
|
||||
|
||||
def _msg_has_image_url(self, msg: str) -> bool:
|
||||
return msg_has_image_url(msg)
|
||||
|
||||
def _msg_refers_images(self, msg: str) -> bool:
|
||||
return msg_refers_images(msg)
|
||||
|
||||
def _extract_image_urls(self, msg: str) -> list:
|
||||
return extract_image_urls(msg)
|
||||
|
||||
def _collect_recent_image_urls(self, customer_id: str, acc_id: str, max_count: int = 6) -> list:
|
||||
return collect_recent_image_urls(self, customer_id, acc_id, max_count=max_count)
|
||||
|
||||
def _msg_is_requirement(self, msg: str) -> bool:
|
||||
if not msg:
|
||||
return False
|
||||
kws = (
|
||||
"要", "抓到", "放到", "合成", "替换", "抠", "修", "高清", "尺寸", "横", "竖", "颜色", "去背景", "排版", "一样", "类似", "同款",
|
||||
"能不能做", "能做吗", "可以做吗", "做不做", "这个能做吗", "这个能不能做",
|
||||
)
|
||||
return any(k in msg for k in kws)
|
||||
|
||||
def _add_pending_images(self, key: str, urls: list, limit: int = 12):
|
||||
if not urls:
|
||||
return
|
||||
cur = self._pending_images.get(key) or []
|
||||
for u in urls:
|
||||
if u not in cur:
|
||||
cur.append(u)
|
||||
if len(cur) >= limit:
|
||||
break
|
||||
self._pending_images[key] = cur
|
||||
|
||||
async def _flush_pending_images(self, key: str, data: dict):
|
||||
urls = self._pending_images.get(key) or []
|
||||
if not urls:
|
||||
return
|
||||
self._pending_images[key] = []
|
||||
if len(urls) == 1:
|
||||
await self._analyze_single_and_reply(data, urls[0])
|
||||
else:
|
||||
await self._analyze_multi_and_reply(data, urls)
|
||||
|
||||
def _msg_is_price_inquiry(self, msg: str) -> bool:
|
||||
return msg_is_price_inquiry(msg)
|
||||
|
||||
def _detect_order_status(self, msg: str) -> str:
|
||||
return detect_order_status(msg)
|
||||
|
||||
async def _analyze_single_and_reply(self, data: dict, url: str):
|
||||
await handle_single_image_quote(self, data, url)
|
||||
|
||||
async def agent_reply(self, data: dict):
|
||||
"""使用 Agent 处理消息并回复"""
|
||||
await handle_agent_reply_flow(
|
||||
self,
|
||||
data,
|
||||
workflow=workflow,
|
||||
shop_type_resolver=_get_shop_type,
|
||||
)
|
||||
|
||||
def _cancel_auto_quote_task(self, key: str, reason: str = ""):
|
||||
cancel_auto_quote_task(self, key, reason=reason)
|
||||
|
||||
@staticmethod
|
||||
def _build_auto_quote_signature(state: Any) -> str:
|
||||
return build_auto_quote_signature(state)
|
||||
|
||||
async def _maybe_schedule_auto_quote(self, data: dict):
|
||||
await schedule_auto_quote(self, data, shop_type_resolver=_get_shop_type)
|
||||
|
||||
async def _analyze_multi_and_reply(self, data: dict, urls: list):
|
||||
await handle_multi_image_quote(self, data, urls)
|
||||
def _msg_requests_external_contact(self, msg: str) -> bool:
|
||||
return msg_requests_external_contact(msg)
|
||||
|
||||
@staticmethod
|
||||
def _extract_size_pairs_m(msg: str) -> list[tuple[float, float]]:
|
||||
return extract_size_pairs_m(msg)
|
||||
|
||||
def _oversize_reply_if_needed(self, msg: str) -> str:
|
||||
return oversize_reply_if_needed(msg)
|
||||
def _is_transfer_msg(self, data: dict) -> bool:
|
||||
return is_transfer_msg(self, data)
|
||||
|
||||
def _pick_transfer_greeting(self) -> str:
|
||||
return pick_transfer_greeting()
|
||||
|
||||
def _is_shop_card(self, data: dict) -> bool:
|
||||
return is_shop_card(self, data)
|
||||
|
||||
def _extract_customer_text_from_shop_card_msg(self, msg: str) -> str:
|
||||
return extract_customer_text_from_shop_card_msg(self, msg)
|
||||
|
||||
def _has_chat_history(self, customer_id: str, acc_id: str = "") -> bool:
|
||||
return has_chat_history(customer_id, acc_id=acc_id)
|
||||
|
||||
def _load_system_inquiry_rules(self) -> Dict[str, Any]:
|
||||
return load_system_inquiry_rules()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_kw_list(v: Any) -> List[str]:
|
||||
return normalize_kw_list(v)
|
||||
|
||||
def _resolve_system_inquiry_policy(self, acc_id: str) -> Dict[str, Any]:
|
||||
return resolve_system_inquiry_policy(self, acc_id)
|
||||
|
||||
def _match_system_inquiry(self, data: dict, policy: Dict[str, Any]) -> bool:
|
||||
return match_system_inquiry(self, data, policy)
|
||||
|
||||
async def _handle_system_inquiry(self, data: dict) -> bool:
|
||||
return await handle_system_inquiry(self, data)
|
||||
|
||||
def _should_ignore(self, data: dict) -> bool:
|
||||
return should_ignore(self, data)
|
||||
|
||||
def get_msg_type_name(self, msg_type):
|
||||
return get_msg_type_name(msg_type)
|
||||
|
||||
def _extract_and_save_customer_info(self, message: str, customer_id: str):
|
||||
extract_and_save_customer_info_flow(self, message, customer_id, db)
|
||||
|
||||
def to_chinese(self, text):
|
||||
return to_chinese_text(text)
|
||||
|
||||
async def handle_image_message(self, data: dict):
|
||||
await handle_image_message_flow(self, data)
|
||||
|
||||
async def _dispatch_assign_once(self) -> Dict[str, Any]:
|
||||
return await dispatch_assign_once_flow(self)
|
||||
|
||||
async def transfer_to_human(self, data: dict, transfer_msg: str = ""):
|
||||
await transfer_to_human_flow(
|
||||
self,
|
||||
data,
|
||||
transfer_msg=transfer_msg,
|
||||
transfer_group_resolver=_get_transfer_group,
|
||||
)
|
||||
|
||||
async def _save_conversation_summary(self, customer_id: str, buyer_msg: str, agent_reply: str):
|
||||
await save_conversation_summary_flow(self, customer_id, buyer_msg, agent_reply)
|
||||
|
||||
async def _workflow_agent_notify(
|
||||
self,
|
||||
customer_id: str,
|
||||
acc_id: str,
|
||||
acc_type: str,
|
||||
system_hint: str,
|
||||
):
|
||||
await workflow_agent_notify_flow(self, customer_id, acc_id, acc_type, system_hint)
|
||||
|
||||
async def _workflow_send(
|
||||
self,
|
||||
customer_id: str,
|
||||
acc_id: str,
|
||||
acc_type: str,
|
||||
content: str,
|
||||
msg_type: int = 0
|
||||
):
|
||||
await workflow_send_flow(self, customer_id, acc_id, acc_type, content, msg_type=msg_type)
|
||||
|
||||
async def send_reply(self, original_msg, reply_content):
|
||||
await send_reply_flow(self, original_msg, reply_content)
|
||||
|
||||
async def _ai_generate_outbound_reply(self, original_msg: dict, reply_content: str) -> str:
|
||||
return await ai_generate_outbound_reply(self, original_msg, reply_content)
|
||||
|
||||
def _colloquialize_outbound_reply(self, text: Any) -> Any:
|
||||
return colloquialize_outbound_reply(text)
|
||||
|
||||
async def _ai_guard_outbound_reply(self, original_msg: dict, reply_content: str) -> tuple[bool, str, str]:
|
||||
return await ai_guard_outbound_reply(self, original_msg, reply_content)
|
||||
|
||||
async def send_text(self, cy_id, acc_type, content):
|
||||
await send_text_flow(self, cy_id, acc_type, content)
|
||||
|
||||
async def send_image(self, cy_id, acc_type, image_path):
|
||||
await send_image_flow(self, cy_id, acc_type, image_path)
|
||||
|
||||
async def send_message(self, message):
|
||||
await send_message_flow(self, message)
|
||||
|
||||
async def auto_reply(self, data):
|
||||
"""自动回复示例(已弃用,使用 agent_reply 替代)"""
|
||||
pass
|
||||
|
||||
async def command_handler(self):
|
||||
await command_handler_flow(self)
|
||||
|
||||
def get_time(self):
|
||||
"""获取当前时间字符串"""
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
async def run(self):
|
||||
await run_client_flow(self)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# 检查是否有 --no-agent 参数
|
||||
enable_agent = "--no-agent" not in sys.argv
|
||||
|
||||
client = QingjianAPIClient(enable_agent=enable_agent)
|
||||
try:
|
||||
asyncio.run(client.run())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("\n已停止")
|
||||
|
||||
81
core/websocket_client_v2.py
Normal file
81
core/websocket_client_v2.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from core.orchestrator import init_orchestrator
|
||||
from core.websocket_connection_flow import connect_flow, receive_messages_flow
|
||||
from core.websocket_send_flow import send_message_flow
|
||||
from utils.observability import emit_activity
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
class QingjianAPIClient:
|
||||
"""
|
||||
重构后的轻简API客户端 (协议全复刻版)
|
||||
"""
|
||||
|
||||
def __init__(self, uri=None, enable_agent: bool = True):
|
||||
from config.config import QINGJIAN_WS_URI
|
||||
self.uri = uri or QINGJIAN_WS_URI
|
||||
self.websocket = None
|
||||
self.running = True
|
||||
self.logger = logger
|
||||
self.enable_agent = enable_agent
|
||||
|
||||
# 初始化新架构总指挥部
|
||||
self.orchestrator = init_orchestrator(ws_client=self)
|
||||
logger.info("[WebSocket] 新架构 Orchestrator 已就绪。")
|
||||
|
||||
def _activity_log(self, event: str, **kwargs):
|
||||
emit_activity(logger, event=event, **kwargs)
|
||||
|
||||
async def connect(self):
|
||||
await connect_flow(self)
|
||||
|
||||
async def receive_messages(self):
|
||||
await receive_messages_flow(self)
|
||||
|
||||
async def handle_message(self, message):
|
||||
"""收到消息处理"""
|
||||
try:
|
||||
data = json.loads(message)
|
||||
await self.orchestrator.on_raw_message_received(platform="qianniu", raw_data=data)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebSocket] 处理消息异常: {e}")
|
||||
|
||||
async def send(self, customer_id: str, acc_id: str, acc_type: str, content: str, msg_type: int = 0):
|
||||
"""
|
||||
【协议全复刻】严格按照 legacy/websocket_outbound_flow.py 的结构
|
||||
"""
|
||||
# 注意:在这里 from_id 竟然填的是 customer_id,这是逆向接口的特殊要求
|
||||
msg_payload = {
|
||||
"msg_id": "",
|
||||
"acc_id": acc_id,
|
||||
"msg": content,
|
||||
"from_id": customer_id,
|
||||
"from_name": "",
|
||||
"cy_id": customer_id,
|
||||
"acc_type": acc_type,
|
||||
"msg_type": msg_type,
|
||||
"cy_name": "",
|
||||
}
|
||||
await self.send_message(msg_payload)
|
||||
|
||||
async def send_message(self, message_dict: dict):
|
||||
"""底层的 WebSocket 发送"""
|
||||
await send_message_flow(self, message_dict)
|
||||
|
||||
def get_time(self):
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
async def run(self):
|
||||
await self.connect()
|
||||
await self.receive_messages()
|
||||
|
||||
if __name__ == "__main__":
|
||||
client = QingjianAPIClient()
|
||||
try:
|
||||
asyncio.run(client.run())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("已停止")
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import websockets
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
async def connect_flow(client):
|
||||
"""连接 WebSocket 服务器并自动重连。"""
|
||||
@@ -9,49 +11,22 @@ async def connect_flow(client):
|
||||
client.logger.info(f"[{client.get_time()}] 正在连接轻简API {client.uri}...")
|
||||
async with websockets.connect(client.uri) as websocket:
|
||||
client.websocket = websocket
|
||||
from utils.health_check import set_qingjian_connected
|
||||
set_qingjian_connected(True)
|
||||
client.logger.info(f"[{client.get_time()}] 连接成功!")
|
||||
if client.enable_agent:
|
||||
client.logger.info(f"[{client.get_time()}] AI Agent 已启用,将自动处理消息")
|
||||
client.logger.info(f"[{client.get_time()}] 等待接收消息...")
|
||||
|
||||
await client.receive_messages()
|
||||
|
||||
except ConnectionRefusedError:
|
||||
from utils.health_check import set_qingjian_connected
|
||||
set_qingjian_connected(False)
|
||||
client.logger.info(f"[{client.get_time()}] 连接被拒绝,请检查轻简软件是否已启动")
|
||||
except websockets.exceptions.InvalidURI:
|
||||
from utils.health_check import set_qingjian_connected
|
||||
set_qingjian_connected(False)
|
||||
client.logger.info(f"[{client.get_time()}] URI格式错误")
|
||||
except Exception as e:
|
||||
from utils.health_check import set_qingjian_connected
|
||||
set_qingjian_connected(False)
|
||||
client.logger.info(f"[{client.get_time()}] 连接错误: {e}")
|
||||
# 统一捕获异常,避免因为不同版本的 websockets 导致属性错误
|
||||
client.logger.info(f"[{client.get_time()}] 连接或运行错误: {e}")
|
||||
|
||||
if client.running:
|
||||
client.logger.info(f"[{client.get_time()}] 5秒后尝试重连...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def receive_messages_flow(client):
|
||||
"""持续接收消息。"""
|
||||
try:
|
||||
async for message in client.websocket:
|
||||
await client.handle_message(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
from utils.health_check import set_qingjian_connected
|
||||
set_qingjian_connected(False)
|
||||
client.logger.info(f"[{client.get_time()}] 连接已关闭")
|
||||
except Exception as e:
|
||||
from utils.health_check import set_qingjian_connected
|
||||
set_qingjian_connected(False)
|
||||
client.logger.info(f"[{client.get_time()}] 接收消息错误: {e}")
|
||||
|
||||
|
||||
async def handle_message_flow(client, message, *, shop_type_resolver):
|
||||
from core.websocket_inbound_flow import handle_incoming_message
|
||||
|
||||
await handle_incoming_message(client, message, shop_type_resolver=shop_type_resolver)
|
||||
client.logger.info(f"[{client.get_time()}] 接收消息中断: {e}")
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def extract_and_save_customer_info_flow(client, message: str, customer_id: str, db):
|
||||
"""从消息中提取客户信息并保存。"""
|
||||
if not message or not customer_id:
|
||||
return
|
||||
|
||||
email_pattern = r"[\w\.-]+@[\w\.-]+\.\w+"
|
||||
email_match = re.search(email_pattern, message)
|
||||
if email_match:
|
||||
db.update_email(customer_id, email_match.group())
|
||||
|
||||
phone_pattern = r"1[3-9]\d{9}"
|
||||
phone_match = re.search(phone_pattern, message)
|
||||
if phone_match:
|
||||
db.update_phone(customer_id, phone_match.group())
|
||||
|
||||
wechat_pattern = r"[Vv微信]+号[::]?\s*([\w-]+)"
|
||||
wechat_match = re.search(wechat_pattern, message)
|
||||
if wechat_match:
|
||||
db.update_wechat(customer_id, wechat_match.group(1))
|
||||
|
||||
budget_keywords = ["预算", "不超过", "最多", "便宜点", "便宜"]
|
||||
for keyword in budget_keywords:
|
||||
if keyword in message:
|
||||
db.add_personality_tag(customer_id, "关注价格")
|
||||
break
|
||||
|
||||
personality_keywords = {
|
||||
"爽快": "爽快",
|
||||
"干脆": "爽快",
|
||||
"纠结": "纠结",
|
||||
"墨迹": "纠结",
|
||||
"砍价": "砍价",
|
||||
"贵": "砍价",
|
||||
}
|
||||
for keyword, tag in personality_keywords.items():
|
||||
if keyword in message:
|
||||
db.add_personality_tag(customer_id, tag)
|
||||
|
||||
profile = db.get_customer(customer_id)
|
||||
profile.last_contact = datetime.now().isoformat()
|
||||
db.save_customer(profile)
|
||||
@@ -1,265 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def debounce_agent_reply(client, data: dict):
|
||||
"""
|
||||
消息防抖:同一客户在 _DEBOUNCE_SECONDS 内的连续消息合并后再处理。
|
||||
订单通知、付款相关消息不走防抖,立即处理。
|
||||
"""
|
||||
msg_body = data.get("msg", "")
|
||||
key = f"{data.get('acc_id','')}:{data.get('from_id','')}"
|
||||
client._cancel_auto_quote_task(key, reason="new_inbound")
|
||||
|
||||
# 以下情况跳过防抖,立即处理(后台执行,不阻塞接收循环)
|
||||
immediate_keywords = ["买家已付款", "已付款", "[系统订单信息]"]
|
||||
if any(kw in msg_body for kw in immediate_keywords):
|
||||
client._activity_log(
|
||||
"debounce_bypass_immediate",
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("from_id", ""),
|
||||
reason="payment_or_order",
|
||||
msg=msg_body,
|
||||
)
|
||||
client._fire_and_forget(client._agent_reply_serialized(data))
|
||||
return
|
||||
|
||||
# 积攒消息
|
||||
if key not in client._pending_msgs:
|
||||
client._pending_msgs[key] = []
|
||||
client._pending_msgs[key].append(msg_body)
|
||||
client._activity_log(
|
||||
"debounce_enqueue",
|
||||
key=key,
|
||||
queue_size=len(client._pending_msgs[key]),
|
||||
msg=msg_body,
|
||||
)
|
||||
|
||||
# 取消上一个等待任务(如果有)
|
||||
old_task = client._debounce_tasks.get(key)
|
||||
if old_task and not old_task.done():
|
||||
old_task.cancel()
|
||||
|
||||
debounce_seconds = pick_debounce_seconds(client, data, msg_body)
|
||||
|
||||
# 创建新的延迟处理任务
|
||||
async def _delayed(capture_key, capture_data, wait_s: float):
|
||||
await asyncio.sleep(wait_s)
|
||||
msgs = client._pending_msgs.pop(capture_key, [])
|
||||
if not msgs:
|
||||
return
|
||||
if len(msgs) == 1:
|
||||
merged_msg = msgs[0]
|
||||
else:
|
||||
merged_msg = "、".join(m for m in msgs if m.strip())
|
||||
logger.info(f"[{client.get_time()}] 防抖合并 {len(msgs)} 条消息: {merged_msg[:60]}")
|
||||
client._activity_log(
|
||||
"debounce_flush",
|
||||
key=capture_key,
|
||||
merged_count=len(msgs),
|
||||
merged_msg=merged_msg,
|
||||
)
|
||||
merged_data = dict(capture_data)
|
||||
merged_data["msg"] = merged_msg
|
||||
await client._agent_reply_serialized(merged_data)
|
||||
|
||||
task = asyncio.create_task(_delayed(key, data, debounce_seconds))
|
||||
client._debounce_tasks[key] = task
|
||||
|
||||
|
||||
def rand_between(low: float, high: float) -> float:
|
||||
if high <= low:
|
||||
return float(low)
|
||||
# 使用 secrets 增强随机性,避免固定周期导致机械感
|
||||
span = high - low
|
||||
return round(low + span * (secrets.randbelow(1000) / 1000.0), 2)
|
||||
|
||||
|
||||
def guess_intent_for_debounce(client, msg: str) -> str:
|
||||
text = (msg or "").strip()
|
||||
if not text:
|
||||
return "unknown"
|
||||
if msg_has_image_url(text):
|
||||
return "image"
|
||||
try:
|
||||
from utils.intent_analyzer import detect_intent
|
||||
|
||||
decision = detect_intent(text)
|
||||
intent = decision.intent
|
||||
if intent:
|
||||
client._activity_log(
|
||||
"debounce_intent_detected",
|
||||
intent=intent,
|
||||
source=decision.source,
|
||||
score=round(float(decision.score or 0.0), 4),
|
||||
msg=text[:120],
|
||||
)
|
||||
except Exception:
|
||||
intent = ""
|
||||
if intent:
|
||||
return intent
|
||||
lower = text.lower()
|
||||
if any(k in lower for k in ["报价", "多少钱", "价格", "贵", "优惠", "收费", "怎么收费", "咋收费"]):
|
||||
return "询价"
|
||||
if any(k in lower for k in ["做一下", "改一下", "需求", "门头", "上面的字", "处理"]):
|
||||
return "修改"
|
||||
if any(k in lower for k in ["在吗", "你好", "有人"]):
|
||||
return "打招呼"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def looks_like_requirement_text(msg: str) -> bool:
|
||||
text = (msg or "").strip().lower()
|
||||
if not text:
|
||||
return False
|
||||
req_kw = (
|
||||
"做一下",
|
||||
"改一下",
|
||||
"处理一下",
|
||||
"这个字",
|
||||
"上面的字",
|
||||
"门头",
|
||||
"去背景",
|
||||
"抠图",
|
||||
"换色",
|
||||
"调色",
|
||||
"清晰",
|
||||
"高清",
|
||||
"尺寸",
|
||||
"比例",
|
||||
"横版",
|
||||
"竖版",
|
||||
"排版",
|
||||
"改字",
|
||||
"按这个做",
|
||||
"照这个做",
|
||||
"就这张",
|
||||
"看看做",
|
||||
"弄一下",
|
||||
)
|
||||
return any(k in text for k in req_kw)
|
||||
|
||||
|
||||
def pick_debounce_seconds(client, data: dict, msg: str) -> float:
|
||||
"""意图驱动防抖:不同意图不同等待区间,并引入轻微随机。"""
|
||||
base = max(1.0, float(client._DEBOUNCE_SECONDS))
|
||||
if not client._adaptive_debounce_enabled:
|
||||
return base
|
||||
|
||||
intent = guess_intent_for_debounce(client, msg)
|
||||
is_req = looks_like_requirement_text(msg)
|
||||
has_img = msg_has_image_url(msg)
|
||||
|
||||
# 区间策略:越明确、越短消息,等待越短;需求描述类稍长
|
||||
if intent == "打招呼":
|
||||
low, high = 1.0, min(3.0, base)
|
||||
elif intent in ("询价", "砍价"):
|
||||
# 询价先略等一会,给客户补发图片/需求的窗口,减少机械两连回
|
||||
low, high = 4.0, min(7.0, max(base, 7.0))
|
||||
elif intent == "image":
|
||||
# 文本里直接贴图链接:短等合并上下文,避免和上一条询价并发
|
||||
low, high = 2.2, 4.2
|
||||
elif intent in ("修改", "批量"):
|
||||
low, high = max(3.0, base * 0.65), min(18.0, base + 2.0)
|
||||
elif intent == "转接":
|
||||
low, high = 1.0, 2.5
|
||||
else:
|
||||
low, high = max(2.0, base * 0.5), base
|
||||
|
||||
# 发图后的需求描述,优先“多等一点”收集完整需求,减少半句回复
|
||||
# 约束到 12-14s,避免等待过长。
|
||||
if is_req and not has_img:
|
||||
low = max(low, 12.0)
|
||||
high = min(14.0, max(high, 12.6))
|
||||
|
||||
# 短句更快,长句稍慢,避免把连续半句拆开
|
||||
text_len = len((msg or "").strip())
|
||||
if text_len <= 4:
|
||||
high = min(high, max(low + 0.2, 2.5))
|
||||
elif text_len >= 18:
|
||||
low = min(high, low + 0.6)
|
||||
|
||||
wait_s = rand_between(low, high)
|
||||
logger.info(f"防抖等待 {wait_s}s | intent={intent} | len={text_len}")
|
||||
return wait_s
|
||||
|
||||
|
||||
def msg_has_image_url(msg: str) -> bool:
|
||||
"""判断文本消息里是否包含图片URL(客户粘贴了图片链接,可能带前缀文字如 有吗#*#https://...)"""
|
||||
if not msg:
|
||||
return False
|
||||
lower = msg.lower()
|
||||
image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
|
||||
image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com")
|
||||
if "http://" in lower or "https://" in lower:
|
||||
if any(ext in lower for ext in image_exts) or any(h in lower for h in image_hosts):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def msg_refers_images(msg: str) -> bool:
|
||||
"""判断文本是否指代之前的图片(图一/图二/这张/那张/上面那张等)"""
|
||||
if not msg:
|
||||
return False
|
||||
refs = (
|
||||
"图一",
|
||||
"图二",
|
||||
"第一张",
|
||||
"第二张",
|
||||
"这张",
|
||||
"那张",
|
||||
"这图",
|
||||
"那个图",
|
||||
"这个",
|
||||
"这个呢",
|
||||
"上面那张",
|
||||
"下面那张",
|
||||
"刚才那张",
|
||||
"上一张",
|
||||
"下一张",
|
||||
)
|
||||
return any(r in msg for r in refs)
|
||||
|
||||
|
||||
def extract_image_urls(msg: str) -> list:
|
||||
if not msg:
|
||||
return []
|
||||
parts = [p.strip() for p in msg.split("#*#") if p.strip()]
|
||||
urls = []
|
||||
for p in parts:
|
||||
if p.startswith("http://") or p.startswith("https://"):
|
||||
urls.append(p)
|
||||
if not urls and ("http://" in msg or "https://" in msg):
|
||||
tokens = re.findall(r"(https?://\S+)", msg)
|
||||
for t in tokens:
|
||||
if any(ext in t.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]):
|
||||
urls.append(t)
|
||||
return urls[:8]
|
||||
|
||||
|
||||
def collect_recent_image_urls(client, customer_id: str, acc_id: str, max_count: int = 6) -> list:
|
||||
"""从最近对话中回溯收集图片URL(优先买家消息),用于慢发或引用图片的场景"""
|
||||
urls, seen = [], set()
|
||||
try:
|
||||
from db.chat_log_db import get_recent_conversation
|
||||
|
||||
recent = get_recent_conversation(customer_id=customer_id, acc_id=acc_id, limit=20)
|
||||
# 从最近到更早遍历,收集买家(in)消息中的图片链接
|
||||
for item in reversed(recent):
|
||||
if item.get("direction") != "in":
|
||||
continue
|
||||
message = item.get("message") or ""
|
||||
found = extract_image_urls(message)
|
||||
for u in found:
|
||||
if u not in seen:
|
||||
seen.add(u)
|
||||
urls.append(u)
|
||||
if len(urls) >= max_count:
|
||||
return urls
|
||||
except Exception:
|
||||
logger.debug("收集近期图片URL失败", exc_info=True)
|
||||
return urls
|
||||
@@ -1,36 +0,0 @@
|
||||
import os
|
||||
|
||||
|
||||
async def dispatch_assign_once_flow(client):
|
||||
"""
|
||||
调用新的一键派单接口:
|
||||
GET {DISPATCH_BASE_URL}/assign
|
||||
Header: X-API-Key
|
||||
"""
|
||||
base_url = os.getenv("DISPATCH_BASE_URL", "http://1.12.50.92:8006").strip().rstrip("/")
|
||||
api_key = os.getenv("DISPATCH_API_KEY", "tuhui_dispatch_key_2026").strip()
|
||||
timeout_s = float(os.getenv("DISPATCH_TIMEOUT_SECONDS", "5"))
|
||||
if not base_url or not api_key:
|
||||
return {"success": False, "reason": "dispatch config missing"}
|
||||
try:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout_s) as http_client:
|
||||
resp = await http_client.get(
|
||||
f"{base_url}/assign",
|
||||
headers={"X-API-Key": api_key},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "reason": f"http {resp.status_code}"}
|
||||
data = resp.json() if resp.content else {}
|
||||
ok = bool((data or {}).get("success", False))
|
||||
return {
|
||||
"success": ok,
|
||||
"task_id": str((data or {}).get("task_id", "") or ""),
|
||||
"assigned_to": str((data or {}).get("assigned_to", "") or ""),
|
||||
"online_count": int((data or {}).get("online_count", 0) or 0),
|
||||
"notification_sent": bool((data or {}).get("notification_sent", False)),
|
||||
"raw": data,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "reason": str(e)}
|
||||
@@ -1,181 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def unreplied_followup_loop(client):
|
||||
"""定时补偿:对“最后一条是客户消息且长时间未回复”的会话,补发一次自然跟进。"""
|
||||
if not client.enable_agent or not client.agent:
|
||||
return
|
||||
while client.running:
|
||||
try:
|
||||
await asyncio.sleep(max(30, int(os.getenv("UNREPLIED_FOLLOWUP_SCAN_SECONDS", "90"))))
|
||||
await scan_and_send_unreplied_followups(client)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
client._activity_log("unreplied_followup_loop_error", error=str(e))
|
||||
|
||||
|
||||
async def scan_and_send_unreplied_followups(client):
|
||||
from db import chat_log_db as cdb
|
||||
|
||||
try:
|
||||
idle_minutes = max(5, int(os.getenv("UNREPLIED_FOLLOWUP_IDLE_MINUTES", "12")))
|
||||
max_age_minutes = max(idle_minutes, int(os.getenv("UNREPLIED_FOLLOWUP_MAX_AGE_MINUTES", "180")))
|
||||
followup_cd = max(300, int(os.getenv("UNREPLIED_FOLLOWUP_COOLDOWN_SECONDS", "3600")))
|
||||
limit = max(10, int(os.getenv("UNREPLIED_FOLLOWUP_LIMIT", "40")))
|
||||
except Exception:
|
||||
idle_minutes, max_age_minutes, followup_cd, limit = 12, 180, 3600, 40
|
||||
|
||||
now = datetime.now()
|
||||
window_start = (now - timedelta(minutes=max_age_minutes)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
conn = None
|
||||
try:
|
||||
conn = cdb._get_conn()
|
||||
rows = conn.execute(
|
||||
cdb._sql(
|
||||
"""
|
||||
SELECT acc_id, customer_id, MAX(id) AS last_id
|
||||
FROM chat_logs
|
||||
WHERE timestamp >= ?
|
||||
GROUP BY acc_id, customer_id
|
||||
ORDER BY MAX(id) DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
),
|
||||
(window_start, limit * 6),
|
||||
).fetchall()
|
||||
sessions = [dict(r) for r in rows]
|
||||
sent = 0
|
||||
for s in sessions:
|
||||
if sent >= limit:
|
||||
break
|
||||
acc_id = str(s.get("acc_id", "") or "")
|
||||
cid = str(s.get("customer_id", "") or "")
|
||||
if not acc_id or not cid:
|
||||
continue
|
||||
ckey = f"{acc_id}:{cid}"
|
||||
if not client._is_owned_by_this_worker(ckey):
|
||||
continue
|
||||
last = conn.execute(
|
||||
cdb._sql(
|
||||
"""
|
||||
SELECT id, direction, message, timestamp, customer_name, acc_id, platform
|
||||
FROM chat_logs
|
||||
WHERE acc_id = ? AND customer_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
(acc_id, cid),
|
||||
).fetchone()
|
||||
if not last:
|
||||
continue
|
||||
last = dict(last)
|
||||
if str(last.get("direction", "")) != "in":
|
||||
continue
|
||||
last_ts = last.get("timestamp")
|
||||
if isinstance(last_ts, datetime):
|
||||
last_dt = last_ts
|
||||
else:
|
||||
last_dt = datetime.strptime(str(last_ts)[:19], "%Y-%m-%d %H:%M:%S")
|
||||
idle_s = (now - last_dt).total_seconds()
|
||||
if idle_s < idle_minutes * 60 or idle_s > max_age_minutes * 60:
|
||||
continue
|
||||
now_mono = time.monotonic()
|
||||
if (now_mono - client._unreplied_followup_sent.get(ckey, 0.0)) < followup_cd:
|
||||
continue
|
||||
|
||||
last_msg = str(last.get("message", "") or "").strip().lower()
|
||||
if last_msg in {"好的", "好", "ok", "收到", "嗯", "哦"}:
|
||||
continue
|
||||
|
||||
followup = await compose_ai_scene_reply(
|
||||
client,
|
||||
original_msg={
|
||||
"acc_id": acc_id,
|
||||
"from_id": cid,
|
||||
"from_name": client.to_chinese(last.get("customer_name", "") or cid),
|
||||
"acc_type": str(last.get("platform", "") or "AliWorkbench"),
|
||||
"msg": str(last.get("message", "") or ""),
|
||||
},
|
||||
scene="unreplied_followup",
|
||||
intent_hint="客户上一条消息还没接上,先自然承接并请对方补一句当前要处理的图或要求。",
|
||||
fallback="刚看到你消息了,我在的。你把要处理的图或要求再发我一下,我马上接着看。",
|
||||
)
|
||||
fake = {
|
||||
"acc_id": acc_id,
|
||||
"from_id": cid,
|
||||
"from_name": client.to_chinese(last.get("customer_name", "") or cid),
|
||||
"cy_id": cid,
|
||||
"cy_name": client.to_chinese(last.get("customer_name", "") or cid),
|
||||
"acc_type": str(last.get("platform", "") or "AliWorkbench"),
|
||||
"msg": str(last.get("message", "") or ""),
|
||||
"msg_type": 0,
|
||||
}
|
||||
await client.send_reply(fake, followup)
|
||||
client._unreplied_followup_sent[ckey] = now_mono
|
||||
sent += 1
|
||||
client._activity_log(
|
||||
"unreplied_followup_sent",
|
||||
acc_id=acc_id,
|
||||
customer_id=cid,
|
||||
idle_seconds=int(idle_s),
|
||||
last_msg=str(last.get("message", "") or "")[:120],
|
||||
reply=followup,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
if conn:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.debug("关闭数据库连接失败", exc_info=True)
|
||||
|
||||
|
||||
async def compose_ai_scene_reply(client, *, original_msg: dict, scene: str, intent_hint: str, fallback: str) -> str:
|
||||
"""场景化 AI 直接生成回复(不依赖固定模板)。"""
|
||||
if not client.enable_agent or not client.agent or not client.AgentDeps:
|
||||
return fallback
|
||||
try:
|
||||
deps = client.AgentDeps(
|
||||
msg_id=str(original_msg.get("msg_id", "") or f"{scene}_gen"),
|
||||
acc_id=str(original_msg.get("acc_id", "") or ""),
|
||||
from_id=str(original_msg.get("from_id", "") or ""),
|
||||
platform=str(original_msg.get("acc_type", "") or ""),
|
||||
)
|
||||
customer_msg = client.to_chinese(str(original_msg.get("msg", "") or ""))
|
||||
prompt = (
|
||||
"你是淘宝客服,直接生成一条发给客户的话。\n"
|
||||
f"场景: {scene}\n"
|
||||
f"意图: {intent_hint}\n"
|
||||
f"客户原话: {customer_msg}\n"
|
||||
"要求: 1-2句,自然口语,不要模板腔,不要新增价格/承诺;只输出最终回复。\n"
|
||||
)
|
||||
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
|
||||
out = str(getattr(result, "output", "") or "").strip()
|
||||
if not out:
|
||||
return fallback
|
||||
if out.startswith("话术|") or "[转移会话]" in out or "TRANSFER_REQUESTED" in out:
|
||||
return fallback
|
||||
client._activity_log(
|
||||
"ai_scene_reply_generated",
|
||||
acc_id=str(original_msg.get("acc_id", "") or ""),
|
||||
customer_id=str(original_msg.get("from_id", "") or ""),
|
||||
scene=scene,
|
||||
generated=out[:160],
|
||||
)
|
||||
return out
|
||||
except Exception as e:
|
||||
client._activity_log(
|
||||
"ai_scene_reply_error",
|
||||
acc_id=str(original_msg.get("acc_id", "") or ""),
|
||||
customer_id=str(original_msg.get("from_id", "") or ""),
|
||||
scene=scene,
|
||||
error=str(e),
|
||||
)
|
||||
return fallback
|
||||
@@ -1,128 +0,0 @@
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def fire_and_forget(client, coro):
|
||||
"""后台执行协程,不阻塞接收循环;异常会记录到日志。"""
|
||||
task = asyncio.create_task(coro)
|
||||
|
||||
def _done(t):
|
||||
if t.cancelled():
|
||||
return
|
||||
exc = t.exception()
|
||||
if exc:
|
||||
client.logger.exception(f"后台任务异常: {exc}")
|
||||
|
||||
task.add_done_callback(_done)
|
||||
|
||||
|
||||
def prune_seen(seen: dict, now_mono: float, ttl_sec: float = 8.0):
|
||||
if len(seen) <= 2000:
|
||||
return
|
||||
stale = [k for k, t in seen.items() if (now_mono - t) > ttl_sec]
|
||||
for k in stale:
|
||||
seen.pop(k, None)
|
||||
|
||||
|
||||
def log_inbound_once(client, data: dict, chat_log_fn):
|
||||
"""统一记录入站消息,短窗口去重,避免多分支重复写库。"""
|
||||
try:
|
||||
cid = data.get("from_id", "")
|
||||
if not cid:
|
||||
return
|
||||
msg = client.to_chinese(data.get("msg", "") or "")
|
||||
acc_id = data.get("acc_id", "")
|
||||
mtype = int(data.get("msg_type", 0) or 0)
|
||||
now_mono = time.monotonic()
|
||||
sig = f"{acc_id}|{cid}|{mtype}|{msg}"
|
||||
last = client._inbound_log_seen.get(sig, 0.0)
|
||||
if (now_mono - last) < 2.0:
|
||||
return
|
||||
client._inbound_log_seen[sig] = now_mono
|
||||
prune_seen(client._inbound_log_seen, now_mono, ttl_sec=8.0)
|
||||
chat_log_fn(
|
||||
cid,
|
||||
msg,
|
||||
"in",
|
||||
customer_name=client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")),
|
||||
acc_id=acc_id,
|
||||
platform=data.get("acc_type", ""),
|
||||
msg_type=mtype,
|
||||
)
|
||||
except Exception:
|
||||
client.logger.debug("入站消息写库失败", exc_info=True)
|
||||
|
||||
|
||||
def log_outbound_once(client, original_msg: dict, reply_content: str, chat_log_fn):
|
||||
"""统一记录出站消息,短窗口去重,避免重复写库。"""
|
||||
try:
|
||||
cid = original_msg.get("from_id", "")
|
||||
if not cid:
|
||||
return
|
||||
msg = reply_content or ""
|
||||
acc_id = original_msg.get("acc_id", "")
|
||||
now_mono = time.monotonic()
|
||||
sig = f"{acc_id}|{cid}|0|{msg}"
|
||||
last = client._outbound_log_seen.get(sig, 0.0)
|
||||
if (now_mono - last) < 2.0:
|
||||
return
|
||||
client._outbound_log_seen[sig] = now_mono
|
||||
prune_seen(client._outbound_log_seen, now_mono, ttl_sec=8.0)
|
||||
chat_log_fn(
|
||||
cid,
|
||||
msg,
|
||||
"out",
|
||||
customer_name=client.to_chinese(original_msg.get("from_name", "") or original_msg.get("cy_name", "")),
|
||||
acc_id=acc_id,
|
||||
platform=original_msg.get("acc_type", ""),
|
||||
msg_type=0,
|
||||
)
|
||||
except Exception:
|
||||
client.logger.debug("出站消息写库失败", exc_info=True)
|
||||
|
||||
|
||||
def build_customer_message(client, data: dict, customer_message_cls):
|
||||
"""把原始消息字典转换为 Agent 输入模型。"""
|
||||
return customer_message_cls(
|
||||
msg_id=data.get("msg_id", ""),
|
||||
acc_id=data.get("acc_id", ""),
|
||||
msg=client.to_chinese(data.get("msg", "")),
|
||||
from_id=data.get("from_id", ""),
|
||||
from_name=client.to_chinese(data.get("from_name", "")),
|
||||
cy_id=data.get("cy_id", ""),
|
||||
acc_type=data.get("acc_type", ""),
|
||||
msg_type=data.get("msg_type", 0),
|
||||
cy_name=client.to_chinese(data.get("cy_name", "")),
|
||||
goods_name=client.to_chinese(data.get("goods_name", "")) if data.get("goods_name") else None,
|
||||
goods_order=client.to_chinese(data.get("goods_order", "")) if data.get("goods_order") else None,
|
||||
)
|
||||
|
||||
|
||||
def touch_customer_last_contact(client, customer_id: str, db):
|
||||
"""兜底更新客户最后联系时间。"""
|
||||
if not customer_id:
|
||||
return
|
||||
try:
|
||||
profile = db.get_customer(customer_id)
|
||||
profile.last_contact = datetime.now().isoformat()
|
||||
db.save_customer(profile)
|
||||
except Exception:
|
||||
client.logger.debug("更新客户最后联系时间失败: customer_id=%s", customer_id, exc_info=True)
|
||||
|
||||
|
||||
def push_chat_to_wechat_safe(client, *, data: dict, customer_msg: str, reply_msg: str, tag: str, goods_name: str = ""):
|
||||
"""异步推送企微聊天日志,失败不影响主流程。"""
|
||||
try:
|
||||
from utils.wechat_chat_log import push_chat_to_wechat
|
||||
|
||||
asyncio.create_task(push_chat_to_wechat(
|
||||
customer_name=client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")),
|
||||
customer_id=data.get("from_id", ""),
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_msg=client.to_chinese(customer_msg or ""),
|
||||
reply_msg=reply_msg or "",
|
||||
goods_name=goods_name or client.to_chinese(data.get("goods_name", "") or ""),
|
||||
))
|
||||
except Exception:
|
||||
client.logger.debug("推送企微聊天日志失败(%s)", tag, exc_info=True)
|
||||
@@ -1,11 +0,0 @@
|
||||
async def handle_image_message_flow(client, data: dict):
|
||||
"""
|
||||
处理图片消息。
|
||||
先回复"我找找",然后把图片URL作为消息内容交给 Agent(后台执行)。
|
||||
"""
|
||||
await client.send_reply(data, "我找找")
|
||||
|
||||
image_data = dict(data)
|
||||
image_data["msg"] = f"[客户发来图片] {data.get('msg', '')}"
|
||||
image_data["msg_type"] = 0
|
||||
client._fire_and_forget(client._agent_reply_serialized(image_data))
|
||||
@@ -1,123 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def handle_incoming_message(client, message: str, *, shop_type_resolver):
|
||||
"""处理单条入站消息(从 websocket_client.py 拆出)。"""
|
||||
timestamp = client.get_time()
|
||||
try:
|
||||
data = json.loads(message)
|
||||
|
||||
# 多进程分片检查:确保同一客户只由一个 worker 处理
|
||||
customer_key = client._customer_key(data)
|
||||
if not client._is_owned_by_this_worker(customer_key):
|
||||
return
|
||||
|
||||
timestamp = client.get_time()
|
||||
|
||||
# 保存最后一条消息用于回复
|
||||
client.last_msg = data
|
||||
|
||||
# 打印格式化的消息
|
||||
logger.info(f"\n{'='*50}")
|
||||
logger.info(f"[{timestamp}] 收到新消息:")
|
||||
logger.info(f"{'='*50}")
|
||||
logger.info(f" 消息ID: {data.get('msg_id', 'N/A')}")
|
||||
logger.info(f" 账号ID: {client.to_chinese(data.get('acc_id', 'N/A'))}")
|
||||
logger.info(f" 发送者ID: {client.to_chinese(data.get('from_id', 'N/A'))}")
|
||||
logger.info(f" 发送者名称: {client.to_chinese(data.get('from_name', 'N/A'))}")
|
||||
logger.info(f" 会话ID: {client.to_chinese(data.get('cy_id', 'N/A'))}")
|
||||
logger.info(f" 平台类型: {data.get('acc_type', 'N/A')}")
|
||||
logger.info(f" 消息类型: {client.get_msg_type_name(data.get('msg_type', 0))}")
|
||||
logger.info(f" 消息内容: {client.to_chinese(data.get('msg', 'N/A'))}")
|
||||
|
||||
# 显示商品信息(如果有)
|
||||
if data.get('goods_name'):
|
||||
logger.info(f" 商品名称: {client.to_chinese(data.get('goods_name', ''))}")
|
||||
if data.get('goods_order'):
|
||||
logger.info(f" 订单信息: {client.to_chinese(data.get('goods_order', ''))}")
|
||||
|
||||
logger.info(f"{'='*50}\n")
|
||||
|
||||
# 消息去重:同一条消息不重复处理
|
||||
msg_id = data.get('msg_id', '')
|
||||
if msg_id and msg_id in client._replied_msg_ids:
|
||||
logger.info(f"重复消息,跳过: {msg_id}")
|
||||
return
|
||||
if msg_id:
|
||||
client._replied_msg_ids.append(msg_id) # deque 自动淘汰最旧的
|
||||
|
||||
# 空消息/无效消息过滤(N/A 或关键字段全为空)
|
||||
from_id = data.get('from_id', '')
|
||||
acc_id = data.get('acc_id', '')
|
||||
if not from_id or from_id == 'N/A' or not acc_id or acc_id == 'N/A':
|
||||
logger.info(f"[{client.get_time()}] 空消息跳过(from_id={from_id!r} acc_id={acc_id!r})")
|
||||
return
|
||||
client._log_inbound_once(data)
|
||||
client._fire_and_forget(client._post_tianwang_callback("message_received", data))
|
||||
|
||||
# Gemini 店铺:不回复,直接跳过
|
||||
goods_name = client.to_chinese(data.get('goods_name', '') or '')
|
||||
if shop_type_resolver(acc_id, goods_name) == "gemini_api":
|
||||
logger.info(f"[{client.get_time()}] Gemini 店铺消息,跳过")
|
||||
client._push_chat_to_wechat_safe(
|
||||
data=data,
|
||||
customer_msg=data.get('msg', ''),
|
||||
reply_msg="",
|
||||
goods_name=goods_name,
|
||||
tag="gemini店铺跳过",
|
||||
)
|
||||
return
|
||||
|
||||
# 使用 Agent 自动回复(仅处理文本消息)
|
||||
if client.enable_agent:
|
||||
msg_type = data.get('msg_type', 0)
|
||||
if msg_type == 0:
|
||||
if client._is_transfer_msg(data):
|
||||
# 会话转交 → 主动打招呼
|
||||
logger.info(f"[{client.get_time()}] 收到转交消息,发送问候")
|
||||
greeting = client._pick_transfer_greeting()
|
||||
await client.send_reply(data, greeting)
|
||||
client._push_chat_to_wechat_safe(
|
||||
data=data,
|
||||
customer_msg=data.get('msg', ''),
|
||||
reply_msg=greeting,
|
||||
tag="转交问候",
|
||||
)
|
||||
elif client._is_shop_card(data):
|
||||
# 进店卡片:有历史对话就不回复,没有才打招呼(Gemini 已在上面统一跳过)
|
||||
cid = data.get('from_id', '')
|
||||
acc_id = data.get('acc_id', '')
|
||||
residual_text = client._extract_customer_text_from_shop_card_msg(data.get('msg', ''))
|
||||
if residual_text:
|
||||
logger.info(f"[{client.get_time()}] 进店卡片携带客户文本,转普通消息处理: {residual_text}")
|
||||
patched = dict(data)
|
||||
patched['msg'] = residual_text
|
||||
await client._debounce_agent_reply(patched)
|
||||
elif client._has_chat_history(cid, acc_id=acc_id):
|
||||
logger.info(f"[{client.get_time()}] 进店卡片(已有记录),跳过")
|
||||
else:
|
||||
logger.info(f"[{client.get_time()}] 进店卡片(新客户),发送问候")
|
||||
greeting = "在呢,发图来我看看"
|
||||
await client.send_reply(data, greeting)
|
||||
client._push_chat_to_wechat_safe(
|
||||
data=data,
|
||||
customer_msg=data.get('msg', ''),
|
||||
reply_msg=greeting,
|
||||
goods_name=goods_name,
|
||||
tag="进店卡片问候",
|
||||
)
|
||||
elif await client._handle_system_inquiry(data):
|
||||
logger.info(f"[{client.get_time()}] 系统客服询单消息,已按规则处理")
|
||||
elif client._should_ignore(data):
|
||||
logger.info(f"[{client.get_time()}] 系统通知,跳过回复")
|
||||
else:
|
||||
await client._debounce_agent_reply(data)
|
||||
elif msg_type == 1:
|
||||
# 图片消息直接处理,不走防抖(图片不会连续多发)
|
||||
await client.handle_image_message(data)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.info(f"[{timestamp}] 收到非JSON消息: {message}")
|
||||
@@ -1,98 +0,0 @@
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
|
||||
|
||||
def to_chinese_text(text):
|
||||
"""处理文本,安全地转换 unicode 转义。"""
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
if "\\u" not in text:
|
||||
return text
|
||||
try:
|
||||
return json.loads(f'"{text}"')
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def is_transfer_msg(client, data: dict) -> bool:
|
||||
msg = to_chinese_text(data.get("msg", ""))
|
||||
return "转交给" in msg or "转接给" in msg
|
||||
|
||||
|
||||
def pick_transfer_greeting() -> str:
|
||||
choices = [
|
||||
"在的亲,发图我看下",
|
||||
"在呢亲,有需求直接说",
|
||||
"我在的,您把要求发我",
|
||||
"在的哈,你说我这边看着处理",
|
||||
"在呢,图和需求发来我看看",
|
||||
]
|
||||
return random.choice(choices)
|
||||
|
||||
|
||||
def is_shop_card(client, data: dict) -> bool:
|
||||
msg = to_chinese_text(data.get("msg", ""))
|
||||
return msg.startswith("[进店卡片]") or "我想咨询你们店的这个商品" in msg
|
||||
|
||||
|
||||
def extract_customer_text_from_shop_card_msg(client, msg: str) -> str:
|
||||
text = to_chinese_text(msg or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
parts = [p.strip() for p in text.split("#*#") if p and p.strip()]
|
||||
kept = []
|
||||
for part in parts:
|
||||
if part.startswith("[进店卡片]") or "我想咨询你们店的这个商品" in part:
|
||||
continue
|
||||
kept.append(part)
|
||||
if kept:
|
||||
return " ".join(kept).strip()
|
||||
stripped = re.sub(r"\[进店卡片\][^\n\r]*", "", text).strip()
|
||||
stripped = stripped.replace("我想咨询你们店的这个商品", "").strip(",。,#* ")
|
||||
return stripped
|
||||
|
||||
|
||||
def has_chat_history(customer_id: str, acc_id: str = "") -> bool:
|
||||
if not customer_id:
|
||||
return False
|
||||
try:
|
||||
from db.chat_log_db import get_recent_conversation
|
||||
|
||||
msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=1)
|
||||
return len(msgs) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def should_ignore(client, data: dict) -> bool:
|
||||
msg = to_chinese_text(data.get("msg", ""))
|
||||
|
||||
ignore_patterns = [
|
||||
"已转接",
|
||||
"接入会话",
|
||||
"结束会话",
|
||||
"会话已",
|
||||
"[系统消息]",
|
||||
"[系统通知]",
|
||||
]
|
||||
for pattern in ignore_patterns:
|
||||
if pattern in msg:
|
||||
return True
|
||||
|
||||
acc_id = data.get("acc_id", "")
|
||||
from_id = data.get("from_id", "")
|
||||
if acc_id and from_id and acc_id == from_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_msg_type_name(msg_type):
|
||||
types = {
|
||||
0: "文本",
|
||||
1: "图片",
|
||||
2: "视频",
|
||||
3: "文件",
|
||||
}
|
||||
return types.get(msg_type, f"未知({msg_type})")
|
||||
@@ -1,84 +0,0 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def msg_is_price_inquiry(msg: str) -> bool:
|
||||
if not msg:
|
||||
return False
|
||||
patterns = ("多少钱", "多少一张", "一张多少钱", "画图多少", "报价", "给个价", "几块", "多少钱")
|
||||
return any(p in msg for p in patterns)
|
||||
|
||||
|
||||
def detect_order_status(msg: str) -> str:
|
||||
if not msg:
|
||||
return ""
|
||||
s = msg
|
||||
if "买家已付款" in s or "已付款" in s:
|
||||
return "paid"
|
||||
if "[系统订单信息]" in s:
|
||||
if "等待买家付款" in s or "未付款" in s:
|
||||
return "waiting"
|
||||
return "order"
|
||||
return ""
|
||||
|
||||
|
||||
def msg_requests_external_contact(msg: str) -> bool:
|
||||
if not msg:
|
||||
return False
|
||||
lower = msg.lower()
|
||||
kws = ("加qq", "qq号", "vx", "微信", "加v", "联系方式", "私聊", "加一下", "加个", "手机号", "电话", "加群", "q q", "v 信")
|
||||
return any(k in lower for k in kws)
|
||||
|
||||
|
||||
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(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 = 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 build_auto_quote_signature(state: Any) -> str:
|
||||
from core.websocket_auto_quote_flow import build_auto_quote_signature as _build
|
||||
|
||||
return _build(state)
|
||||
@@ -1,130 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
|
||||
def normalize_reply_semantic_key(text: str) -> str:
|
||||
s = (text or "").strip().lower()
|
||||
if not s:
|
||||
return ""
|
||||
for w in ("哈", "呀", "哦", "呢", "啦", "咯", "亲"):
|
||||
s = s.replace(w, "")
|
||||
s = re.sub(r"[,。!?、,.!?::;\s~\-—_]+", "", s)
|
||||
return s[:200]
|
||||
|
||||
|
||||
def classify_outbound_reply(text: str) -> str:
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return "empty"
|
||||
if any(k in s for k in ("报价", "总价", "多少钱", "多少", "马上给你报价", "先给你报")):
|
||||
return "quote"
|
||||
if any(k in s for k in ("继续发图", "发完", "发图", "把图发", "先看图")):
|
||||
return "collect"
|
||||
if any(k in s for k in ("在吗", "你好", "在的", "在呢")):
|
||||
return "greeting"
|
||||
if any(k in s for k in ("转人工", "转接", "转给")):
|
||||
return "transfer"
|
||||
if any(k in s for k in ("稍等", "我先看", "看一下", "看下")):
|
||||
return "ack"
|
||||
return "general"
|
||||
|
||||
|
||||
def template_family(reply: str) -> str:
|
||||
s = (reply or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
if "需求我记上了" in s and "继续发图" in s:
|
||||
return "collect_remind"
|
||||
if ("这批图过一遍" in s or "收齐了" in s or "收好了" in s) and ("总价" in s or "报价" in s):
|
||||
return "quote_defer"
|
||||
if "图片收到了" in s and "继续发" in s:
|
||||
return "collect_ack"
|
||||
if "好嘞,你稍等下,我这边看一下" in s:
|
||||
return "fallback_ack"
|
||||
return ""
|
||||
|
||||
|
||||
def outbound_arbiter(client, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
统一出站裁决层:
|
||||
1) 语义去重(相同语义短窗口不重复);
|
||||
2) 同类回复节流(同类话术短窗口不重复)。
|
||||
"""
|
||||
key = f"{original_msg.get('acc_id', '')}:{original_msg.get('from_id', '')}"
|
||||
now_mono = time.monotonic()
|
||||
sem_key = normalize_reply_semantic_key(reply_content)
|
||||
reply_class = classify_outbound_reply(reply_content)
|
||||
try:
|
||||
sem_window = max(30, int(os.getenv("AI_OUTBOUND_SEMANTIC_DEDUPE_SECONDS", "180")))
|
||||
except Exception:
|
||||
sem_window = 180
|
||||
try:
|
||||
class_window = max(20, int(os.getenv("AI_OUTBOUND_CLASS_DEDUPE_SECONDS", "90")))
|
||||
except Exception:
|
||||
class_window = 90
|
||||
try:
|
||||
template_window = max(120, int(os.getenv("AI_OUTBOUND_TEMPLATE_FATIGUE_SECONDS", "600")))
|
||||
except Exception:
|
||||
template_window = 600
|
||||
|
||||
sem_bucket = client._outbound_semantic_seen.setdefault(key, {})
|
||||
cls_bucket = client._outbound_class_seen.setdefault(key, {})
|
||||
tpl_bucket = client._outbound_template_seen.setdefault(key, {})
|
||||
client._prune_seen(sem_bucket, now_mono, ttl_sec=max(sem_window * 2, 240))
|
||||
client._prune_seen(cls_bucket, now_mono, ttl_sec=max(class_window * 2, 180))
|
||||
client._prune_seen(tpl_bucket, now_mono, ttl_sec=max(template_window * 2, 1200))
|
||||
|
||||
if sem_key and (now_mono - sem_bucket.get(sem_key, 0.0)) < sem_window:
|
||||
client._activity_log(
|
||||
"outbound_arbiter_block",
|
||||
trace_id=trace_id,
|
||||
acc_id=original_msg.get("acc_id", ""),
|
||||
customer_id=original_msg.get("from_id", ""),
|
||||
reason="semantic_duplicate",
|
||||
semantic_key=sem_key[:80],
|
||||
reply_class=reply_class,
|
||||
msg=reply_content,
|
||||
)
|
||||
return False, "semantic_duplicate"
|
||||
|
||||
family = template_family(reply_content)
|
||||
if family and (now_mono - tpl_bucket.get(family, 0.0)) < template_window:
|
||||
client._activity_log(
|
||||
"outbound_arbiter_block",
|
||||
trace_id=trace_id,
|
||||
acc_id=original_msg.get("acc_id", ""),
|
||||
customer_id=original_msg.get("from_id", ""),
|
||||
reason="template_fatigue",
|
||||
template_family=family,
|
||||
msg=reply_content,
|
||||
)
|
||||
return False, "template_fatigue"
|
||||
|
||||
if reply_class in {"quote", "collect", "ack"} and (now_mono - cls_bucket.get(reply_class, 0.0)) < class_window:
|
||||
client._activity_log(
|
||||
"outbound_arbiter_block",
|
||||
trace_id=trace_id,
|
||||
acc_id=original_msg.get("acc_id", ""),
|
||||
customer_id=original_msg.get("from_id", ""),
|
||||
reason="class_duplicate",
|
||||
reply_class=reply_class,
|
||||
msg=reply_content,
|
||||
)
|
||||
return False, "class_duplicate"
|
||||
|
||||
if sem_key:
|
||||
sem_bucket[sem_key] = now_mono
|
||||
cls_bucket[reply_class] = now_mono
|
||||
if family:
|
||||
tpl_bucket[family] = now_mono
|
||||
client._activity_log(
|
||||
"outbound_arbiter_pass",
|
||||
trace_id=trace_id,
|
||||
acc_id=original_msg.get("acc_id", ""),
|
||||
customer_id=original_msg.get("from_id", ""),
|
||||
reply_class=reply_class,
|
||||
template_family=family,
|
||||
semantic_key=sem_key[:80] if sem_key else "",
|
||||
)
|
||||
return True, "pass"
|
||||
@@ -1,285 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def send_reply_flow(client, original_msg: dict, reply_content: str):
|
||||
"""
|
||||
发送回复消息(从 websocket_client.py 拆出)。
|
||||
|
||||
Args:
|
||||
original_msg: 收到的原始消息字典
|
||||
reply_content: 回复内容(文本或本地文件路径/http地址)
|
||||
"""
|
||||
trace_id = original_msg.get("_trace_id", "")
|
||||
if not client.websocket:
|
||||
client._activity_log(
|
||||
"send_reply_skipped",
|
||||
trace_id=trace_id,
|
||||
reason="websocket_not_connected",
|
||||
acc_id=original_msg.get("acc_id", ""),
|
||||
customer_id=original_msg.get("from_id", ""),
|
||||
)
|
||||
return
|
||||
|
||||
reply_content = colloquialize_outbound_reply(reply_content)
|
||||
reply_content = await ai_generate_outbound_reply(
|
||||
client=client,
|
||||
original_msg=original_msg,
|
||||
reply_content=str(reply_content or ""),
|
||||
)
|
||||
|
||||
# 同一客户外发限流: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 = client._last_reply_sent_at.get(ckey, 0.0)
|
||||
if (now_mono - last) < cooldown:
|
||||
client._activity_log(
|
||||
"send_reply_throttled",
|
||||
trace_id=trace_id,
|
||||
key=ckey,
|
||||
cooldown_s=cooldown,
|
||||
msg=str(reply_content),
|
||||
)
|
||||
return
|
||||
client._last_reply_sent_at[ckey] = now_mono
|
||||
|
||||
shop_id = original_msg.get("acc_id", "")
|
||||
|
||||
# 根据轻简API文档:
|
||||
# from_id = 客户ID(收消息方)
|
||||
# cy_id = 非群聊时与 from_id 相同
|
||||
customer_id = original_msg.get("from_id", "")
|
||||
customer_name = original_msg.get("from_name", "")
|
||||
|
||||
allow_send, checked_reply, guard_reason = await ai_guard_outbound_reply(
|
||||
client=client,
|
||||
original_msg=original_msg,
|
||||
reply_content=str(reply_content),
|
||||
)
|
||||
client._activity_log(
|
||||
"reply_guard_decision",
|
||||
trace_id=trace_id,
|
||||
acc_id=shop_id,
|
||||
customer_id=customer_id,
|
||||
result="ok" if allow_send else "blocked",
|
||||
reason=guard_reason,
|
||||
original_reply=str(reply_content),
|
||||
final_reply=str(checked_reply or ""),
|
||||
)
|
||||
if not allow_send:
|
||||
return
|
||||
|
||||
reply_content = checked_reply or str(reply_content)
|
||||
pass_send, _ = client._outbound_arbiter(
|
||||
original_msg=original_msg,
|
||||
reply_content=reply_content,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
if not pass_send:
|
||||
return
|
||||
|
||||
reply = {
|
||||
"msg_id": "",
|
||||
"acc_id": shop_id,
|
||||
"msg": reply_content,
|
||||
"from_id": customer_id,
|
||||
"from_name": customer_name,
|
||||
"cy_id": customer_id,
|
||||
"acc_type": original_msg.get("acc_type", ""),
|
||||
"msg_type": 0,
|
||||
"cy_name": customer_name,
|
||||
}
|
||||
client._log_outbound_once(original_msg, str(reply_content))
|
||||
client._activity_log(
|
||||
"send_reply_attempt",
|
||||
trace_id=trace_id,
|
||||
acc_id=shop_id,
|
||||
customer_id=customer_id,
|
||||
msg=str(reply_content),
|
||||
)
|
||||
reply["_trace_id"] = trace_id
|
||||
await client.send_message(reply)
|
||||
|
||||
|
||||
async def ai_generate_outbound_reply(client, original_msg: dict, reply_content: str) -> str:
|
||||
"""
|
||||
强制全量 AI 出站生成层:
|
||||
- 所有普通文本外发先由 AI 生成最终话术;
|
||||
- 控制命令/纯链接/转接指令直接绕过。
|
||||
"""
|
||||
text = (reply_content or "").strip()
|
||||
if not text:
|
||||
return text
|
||||
if text.startswith("话术|") or "[转移会话]" in text or "TRANSFER_REQUESTED" in text:
|
||||
return text
|
||||
if re.fullmatch(r"https?://\S+", text):
|
||||
return text
|
||||
if not client._force_ai_generate_reply or not client.enable_agent or not client.agent or not client.AgentDeps:
|
||||
return text
|
||||
try:
|
||||
deps = client.AgentDeps(
|
||||
msg_id=str(original_msg.get("msg_id", "") or "outbound_generate"),
|
||||
acc_id=str(original_msg.get("acc_id", "") or ""),
|
||||
from_id=str(original_msg.get("from_id", "") or ""),
|
||||
platform=str(original_msg.get("acc_type", "") or ""),
|
||||
)
|
||||
customer_msg = client.to_chinese(str(original_msg.get("msg", "") or ""))
|
||||
prompt = (
|
||||
"你是淘宝客服外发文案生成器。请根据“回复意图草稿”生成最终发给客户的话。\n"
|
||||
"要求:\n"
|
||||
"1) 保留原意,不新增价格/承诺/流程;\n"
|
||||
"2) 自然像真人聊天,不用固定模板句;\n"
|
||||
"3) 1-2句;\n"
|
||||
"4) 只输出最终回复文本。\n\n"
|
||||
f"客户原话: {customer_msg}\n"
|
||||
f"回复意图草稿: {text}\n"
|
||||
)
|
||||
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
|
||||
out = str(getattr(result, "output", "") or "").strip()
|
||||
if not out:
|
||||
return text
|
||||
if out.startswith("话术|") or "[转移会话]" in out:
|
||||
return text
|
||||
client._activity_log(
|
||||
"ai_generate_reply",
|
||||
acc_id=str(original_msg.get("acc_id", "") or ""),
|
||||
customer_id=str(original_msg.get("from_id", "") or ""),
|
||||
draft=text[:160],
|
||||
generated=out[:160],
|
||||
)
|
||||
return out
|
||||
except Exception as e:
|
||||
client._activity_log(
|
||||
"ai_generate_reply_error",
|
||||
acc_id=str(original_msg.get("acc_id", "") or ""),
|
||||
customer_id=str(original_msg.get("from_id", "") or ""),
|
||||
error=str(e),
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def colloquialize_outbound_reply(text: Any) -> Any:
|
||||
"""统一外发口语化处理,避免机械话术。"""
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
raw = text.strip()
|
||||
if not raw:
|
||||
return text
|
||||
# 控制指令/转接命令不得改写
|
||||
if raw.startswith("话术|") or "[转移会话]" in raw:
|
||||
return text
|
||||
# 纯链接不改
|
||||
if re.fullmatch(r"https?://\S+", raw):
|
||||
return text
|
||||
|
||||
out = raw
|
||||
replacements = {
|
||||
"我这边": "我这边",
|
||||
"请您": "你",
|
||||
"您好": "你好",
|
||||
"稍后": "一会儿",
|
||||
"可以的话": "可以的话",
|
||||
"请稍等": "稍等哈",
|
||||
"先不乱报价": "先不急着给你乱报",
|
||||
"建议转人工评估更稳": "建议转人工看会更稳",
|
||||
"统一报价": "一起报价",
|
||||
"马上安排": "马上给你安排",
|
||||
"确认我就安排": "你点头我就开做",
|
||||
"收到,我看看哈": "收到,我先看下",
|
||||
"收到,我找找刚才那几张": "收到,我把刚才那几张一起看下",
|
||||
"这组图我这边暂时识别不稳定": "这组图我这边识别得不太稳",
|
||||
"这组图我这边暂时识别异常": "这组图我这边刚才识别有点异常",
|
||||
"你可以换一张更清晰的,我再给你准报价。": "你换张更清晰的发我,我再给你报准点。",
|
||||
"你可以换清晰图再发我。": "你换张清晰点的再发我哈。",
|
||||
"你可以稍后再发我。": "你晚点再发我也行。",
|
||||
"收到付款,我马上安排处理,有需要第一时间联系您": "收到付款啦,我马上安排处理,有进展第一时间告诉你",
|
||||
"亲,正在为您转接人工客服,请稍等~": "我这就给你转人工,稍等哈~",
|
||||
}
|
||||
for k, v in replacements.items():
|
||||
out = out.replace(k, v)
|
||||
return out
|
||||
|
||||
|
||||
async def ai_guard_outbound_reply(client, original_msg: dict, reply_content: str) -> tuple[bool, str, str]:
|
||||
"""
|
||||
专用AI质检:发送前判断“这句是否该发”,可拦截或改写。
|
||||
读取当前客户在当前店铺的完整对话上下文。
|
||||
"""
|
||||
text = (reply_content or "").strip()
|
||||
if not text:
|
||||
return False, "", "empty_reply"
|
||||
if text.startswith("话术|") or "[转移会话]" in text:
|
||||
return True, text, "command_bypass"
|
||||
if not client._reply_guard_enabled or not client.enable_agent or not client.agent or not client.AgentDeps:
|
||||
return True, text, "guard_disabled"
|
||||
try:
|
||||
from db.chat_log_db import get_conversation
|
||||
import json as _json
|
||||
import re as _re
|
||||
|
||||
acc_id = str(original_msg.get("acc_id", "") or "")
|
||||
customer_id = str(original_msg.get("from_id", "") or "")
|
||||
if not customer_id:
|
||||
return True, text, "no_customer_id"
|
||||
|
||||
# 默认读取较大窗口,尽量覆盖完整上下文;可用环境变量继续放大。
|
||||
try:
|
||||
max_rows = max(50, int(os.getenv("AI_REPLY_GUARD_CONTEXT_ROWS", "500")))
|
||||
except Exception:
|
||||
max_rows = 500
|
||||
rows = get_conversation(customer_id=customer_id, limit=max_rows) or []
|
||||
shop_rows = [r for r in rows if str(r.get("acc_id", "") or "") == acc_id] if acc_id else rows
|
||||
|
||||
context_lines = []
|
||||
for r in shop_rows:
|
||||
role = "客" if (r.get("direction") == "in") else "服"
|
||||
msg = client.to_chinese((r.get("message") or "").strip())
|
||||
if msg:
|
||||
context_lines.append(f"{role}:{msg}")
|
||||
context_text = "\n".join(context_lines) if context_lines else "无历史"
|
||||
|
||||
deps = client.AgentDeps(
|
||||
msg_id=str(original_msg.get("msg_id", "") or "reply_guard"),
|
||||
acc_id=acc_id,
|
||||
from_id=customer_id,
|
||||
platform=str(original_msg.get("acc_type", "") or ""),
|
||||
)
|
||||
prompt = (
|
||||
"你是淘宝客服回复质检器。目标:判断候选回复是否和上下文一致,是否会造成重复触发式答复。\n"
|
||||
"必须检查:\n"
|
||||
"1) 是否答非所问;\n"
|
||||
"2) 是否重复说“马上报价/继续发图”但当前上下文不需要;\n"
|
||||
"3) 是否与历史状态冲突;\n"
|
||||
"4) 语气是否自然可直接发给客户。\n"
|
||||
"若不合适,给可直接发送的一句改写。\n"
|
||||
"只输出 JSON:{\"allow\":true/false,\"rewrite\":\"...\",\"reason\":\"...\"}\n\n"
|
||||
f"完整上下文(当前店铺):\n{context_text}\n\n"
|
||||
f"客户当前消息:{client.to_chinese(original_msg.get('msg', '') or '')}\n"
|
||||
f"候选回复:{text}\n"
|
||||
)
|
||||
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
|
||||
raw = str(getattr(result, "output", "") or "").strip()
|
||||
if not raw:
|
||||
return True, text, "guard_empty_output"
|
||||
m = _re.search(r"\{[\s\S]*\}", raw)
|
||||
if not m:
|
||||
return True, text, "guard_non_json"
|
||||
obj = _json.loads(m.group(0))
|
||||
allow = bool(obj.get("allow", True))
|
||||
rewrite = str(obj.get("rewrite", "") or "").strip()
|
||||
reason = str(obj.get("reason", "") or "").strip() or "guard_decision"
|
||||
if allow:
|
||||
return True, (rewrite or text), reason
|
||||
if rewrite:
|
||||
return True, rewrite, reason
|
||||
return False, "", reason
|
||||
except Exception as e:
|
||||
return True, text, f"guard_error:{e}"
|
||||
@@ -1,128 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def handle_single_image_quote(client, data: dict, url: str):
|
||||
try:
|
||||
from image.image_analyzer import image_analyzer
|
||||
|
||||
result = await image_analyzer.analyze(url)
|
||||
if isinstance(result, dict) and result.get("success", False):
|
||||
if result.get("feasibility") == "no" or result.get("risk") == "high":
|
||||
note = str(result.get("note", "") or "")
|
||||
if "文字内容过于密集" in note or "密集文字" in note:
|
||||
reply = "这类文字太密的图我们这边不接单,抱歉哈。你要是简化后再发我可以继续看。"
|
||||
else:
|
||||
reply = "这张处理风险比较高,我这边先不直接接,建议转人工评估更稳。"
|
||||
await client.send_reply(data, reply)
|
||||
return
|
||||
|
||||
from config.config import MIN_PRICE_FLOOR
|
||||
price = result.get("price_suggest", 20)
|
||||
floor_dyn = result.get("price_min", MIN_PRICE_FLOOR)
|
||||
floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR)
|
||||
price = max(floor, round(price / 5) * 5)
|
||||
try:
|
||||
from db.customer_db import db as _db
|
||||
_db.update_last_min_price(data.get('from_id', ''), floor)
|
||||
except Exception:
|
||||
logger.debug("更新单图最低价失败", exc_info=True)
|
||||
reply = f"这张按{price}元,满意再拍"
|
||||
else:
|
||||
# 识别失败时不做兜底报价,避免把未识别图片误判为可做
|
||||
reply = "这张我这边暂时识别不稳定,先不乱报价。你可以换一张更清晰的,我再给你准报价。"
|
||||
await client.send_reply(data, reply)
|
||||
except Exception:
|
||||
logger.exception("单图分析流程失败")
|
||||
|
||||
|
||||
async def handle_multi_image_quote(client, data: dict, urls: list):
|
||||
try:
|
||||
from image.image_analyzer import image_analyzer
|
||||
|
||||
def _detect_composite_request() -> bool:
|
||||
try:
|
||||
from db.chat_log_db import get_recent_conversation
|
||||
recent = get_recent_conversation(
|
||||
customer_id=data.get('from_id', ''),
|
||||
acc_id=data.get('acc_id', ''),
|
||||
limit=8,
|
||||
)
|
||||
keywords = ("抓到", "放到", "合成", "融合", "嵌到", "换到", "替换", "P到", "抠出来放到")
|
||||
for item in recent:
|
||||
msg = (item.get("message") or "")
|
||||
if any(k in msg for k in keywords):
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("检测合成需求失败,按非合成处理", exc_info=True)
|
||||
return False
|
||||
|
||||
tasks = [image_analyzer.analyze(u) for u in urls]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# 先做风险分流:多图中只要出现不可做/高风险,不进入报价
|
||||
unsafe = []
|
||||
dense_text_reject = []
|
||||
for i, result in enumerate(results, 1):
|
||||
if isinstance(result, dict) and result.get("success", False):
|
||||
if result.get("feasibility") == "no" or result.get("risk") == "high":
|
||||
unsafe.append(f"图{i}")
|
||||
note = str(result.get("note", "") or "")
|
||||
if "文字内容过于密集" in note or "密集文字" in note:
|
||||
dense_text_reject.append(f"图{i}")
|
||||
|
||||
if unsafe:
|
||||
if dense_text_reject and len(dense_text_reject) == len(unsafe):
|
||||
reply = "这类文字太密的图我们这边不接单,抱歉哈。你要是简化后再发我可以继续看。"
|
||||
else:
|
||||
reply = f"这批里{'、'.join(unsafe)}处理风险较高,我这边先不直接接,建议转人工评估更稳。"
|
||||
await client.send_reply(data, reply)
|
||||
return
|
||||
|
||||
pairs = []
|
||||
for u, result in zip(urls, results):
|
||||
if isinstance(result, dict) and result.get("success", False):
|
||||
from config.config import MIN_PRICE_FLOOR
|
||||
floor_dyn = result.get("price_min", MIN_PRICE_FLOOR)
|
||||
floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR)
|
||||
price = max(floor, round(result.get("price_suggest", 20) / 5) * 5)
|
||||
pairs.append((u, price, result.get("category", ""), result.get("megapixels", 0.0)))
|
||||
try:
|
||||
if pairs:
|
||||
floors = []
|
||||
for _u, result in zip(urls, results):
|
||||
if isinstance(result, dict) and result.get("success", False):
|
||||
from config.config import MIN_PRICE_FLOOR
|
||||
floor_dyn = result.get("price_min", MIN_PRICE_FLOOR)
|
||||
floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR)
|
||||
floors.append(floor)
|
||||
if floors:
|
||||
from db.customer_db import db as _db
|
||||
_db.update_last_min_price(data.get('from_id', ''), min(floors))
|
||||
except Exception:
|
||||
logger.debug("更新多图最低价失败", exc_info=True)
|
||||
|
||||
if not pairs:
|
||||
await client.send_reply(data, "这组图我这边暂时识别不稳定,先不乱报价。你可以换清晰图再发我。")
|
||||
return
|
||||
|
||||
composite = _detect_composite_request()
|
||||
composite_fee = 5 if composite else 0
|
||||
avg_raw = sum(p for _, p, _, _ in pairs) / len(pairs)
|
||||
from config.config import MIN_PRICE_FLOOR
|
||||
avg_price = max(MIN_PRICE_FLOOR, round((avg_raw + composite_fee) / 5) * 5)
|
||||
top_price = max(MIN_PRICE_FLOOR, max(pairs, key=lambda x: x[1])[1] + composite_fee)
|
||||
count = len(pairs)
|
||||
if composite:
|
||||
reply = f"这组{count}张我看了,按{avg_price}元一张;合成那张{top_price}元,满意再拍"
|
||||
else:
|
||||
reply = f"这组{count}张我看了,按{avg_price}元一张;复杂那张{top_price}元,满意再拍"
|
||||
await client.send_reply(data, reply)
|
||||
except Exception as e:
|
||||
logger.error("多图分析失败: %s", e)
|
||||
try:
|
||||
await client.send_reply(data, "这组图我这边暂时识别异常,先不乱报价。你可以稍后再发我。")
|
||||
except Exception:
|
||||
logger.debug("多图分析失败后的兜底回复发送失败", exc_info=True)
|
||||
@@ -42,13 +42,14 @@ async def send_message_flow(client, message):
|
||||
await client.websocket.send(msg_json)
|
||||
pretty = json.dumps(message, ensure_ascii=False, indent=2)
|
||||
client.logger.info(f"[{client.get_time()}] 发送成功:\n{pretty}")
|
||||
data = message.get("data", {}) if isinstance(message, dict) else {}
|
||||
client._activity_log(
|
||||
"send_message_success",
|
||||
trace_id=message.get("_trace_id", ""),
|
||||
acc_id=message.get("acc_id", ""),
|
||||
customer_id=message.get("from_id", ""),
|
||||
msg_type=message.get("msg_type", 0),
|
||||
msg=message.get("msg", ""),
|
||||
trace_id=message.get("_trace_id", "") if isinstance(message, dict) else "",
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("cy_id", ""),
|
||||
msg_type=data.get("msg_type", 0),
|
||||
msg=data.get("msg", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
client.logger.info(f"[{client.get_time()}] 发送失败: {e}")
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
async def save_conversation_summary_flow(client, customer_id: str, buyer_msg: str, agent_reply: str):
|
||||
"""用 AI 生成一句话对话摘要并持久化。"""
|
||||
try:
|
||||
from db.customer_db import db
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
api_client = AsyncOpenAI(
|
||||
api_key=client.agent.api_key if client.agent else None,
|
||||
base_url=client.agent.base_url if client.agent else None,
|
||||
)
|
||||
resp = await api_client.chat.completions.create(
|
||||
model=client.agent.model_name if client.agent else "gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "用一句话(15字以内)总结这段对话的核心内容,只输出摘要文字。"},
|
||||
{"role": "user", "content": f"买家:{buyer_msg}\n客服:{agent_reply}"},
|
||||
],
|
||||
max_tokens=30,
|
||||
temperature=0.3,
|
||||
)
|
||||
summary = resp.choices[0].message.content.strip()
|
||||
db.save_conversation_summary(customer_id, summary)
|
||||
except Exception:
|
||||
client.logger.debug("保存对话摘要失败(不影响主流程)", exc_info=True)
|
||||
@@ -1,143 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from utils.metrics_tracker import emit as metrics_emit
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
def load_system_inquiry_rules() -> 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("系统询单规则加载失败,使用默认规则: %s", e)
|
||||
return defaults
|
||||
|
||||
|
||||
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(client, acc_id: str) -> Dict[str, Any]:
|
||||
"""根据店铺合并系统询单策略。"""
|
||||
from config.config import SYSTEM_INQUIRY_SHOPS
|
||||
|
||||
rules = client._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": normalize_kw_list(rules.get("sender_keywords")),
|
||||
"message_keywords": 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"] = normalize_kw_list(shop_cfg.get("sender_keywords"))
|
||||
if isinstance(shop_cfg.get("message_keywords"), list):
|
||||
policy["message_keywords"] = 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(client, data: dict, policy: Dict[str, Any]) -> bool:
|
||||
"""识别是否为系统客服询单消息。"""
|
||||
if not policy.get("enabled", False):
|
||||
return False
|
||||
|
||||
from_name = client.to_chinese(data.get("from_name", "") or "").lower()
|
||||
from_id = str(data.get("from_id", "") or "").lower()
|
||||
msg = client.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(client, data: dict) -> bool:
|
||||
"""命中系统询单后按策略处理。"""
|
||||
acc_id = data.get("acc_id", "")
|
||||
policy = resolve_system_inquiry_policy(client, acc_id)
|
||||
if not match_system_inquiry(client, 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("系统询单命中 | 店铺:%s | 客户:%s | action:%s", acc_id, customer_id, action)
|
||||
|
||||
if action == "reply":
|
||||
reply = await client._compose_ai_scene_reply(
|
||||
original_msg=data,
|
||||
scene="system_inquiry_reply",
|
||||
intent_hint="这是系统客服询单消息,简短确认已收到并说明会跟进即可。",
|
||||
fallback=(policy.get("reply") or "您好,这边已收到询单消息,稍后由人工客服跟进处理。"),
|
||||
)
|
||||
await client.send_reply(data, reply)
|
||||
metrics_emit("system_inquiry_auto_reply", customer_id=customer_id, acc_id=acc_id)
|
||||
return True
|
||||
if action == "transfer":
|
||||
await client.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
|
||||
@@ -1,83 +0,0 @@
|
||||
import logging
|
||||
|
||||
from utils.metrics_tracker import emit as metrics_emit
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def transfer_to_human_flow(client, data: dict, transfer_msg: str = "", *, transfer_group_resolver=None):
|
||||
"""
|
||||
转接人工客服。
|
||||
1. 优先调用 dispatch 服务 GET /assign 一键派单
|
||||
2. 派单失败时,回退旧版 designer_roster 派单
|
||||
3. 无人在线或未配置时,回退到 config/transfer_groups.json
|
||||
设计师在线状态:仅在转人工时按需查询,不轮询。
|
||||
"""
|
||||
if not client.websocket:
|
||||
logger.info("[%s] 错误: 未连接到服务器", client.get_time())
|
||||
return
|
||||
|
||||
acc_id = data.get("acc_id", "")
|
||||
group_id = None
|
||||
assigned_to = ""
|
||||
dispatch_res = await client._dispatch_assign_once()
|
||||
if dispatch_res.get("success"):
|
||||
assigned_to = str(dispatch_res.get("assigned_to", "") or "").strip()
|
||||
logger.info(
|
||||
"一键派单成功 | task_id=%s | assigned_to=%s | online_count=%s",
|
||||
dispatch_res.get("task_id", ""),
|
||||
assigned_to or "未知",
|
||||
dispatch_res.get("online_count", 0),
|
||||
)
|
||||
metrics_emit(
|
||||
"dispatch_assign_success",
|
||||
acc_id=acc_id,
|
||||
assigned_to=assigned_to,
|
||||
online_count=dispatch_res.get("online_count", 0),
|
||||
)
|
||||
else:
|
||||
logger.warning("一键派单失败,回退旧派单逻辑: %s", dispatch_res.get("reason", "unknown"))
|
||||
metrics_emit("dispatch_assign_failed", acc_id=acc_id)
|
||||
|
||||
# 2. 派单失败时,回退旧版 designer_roster
|
||||
if not dispatch_res.get("success"):
|
||||
try:
|
||||
from utils.designer_roster import poll_and_update_roster
|
||||
from db.designer_roster_db import get_transfer_group_for_shop
|
||||
await poll_and_update_roster()
|
||||
group_id = get_transfer_group_for_shop(acc_id)
|
||||
except Exception as e:
|
||||
logger.debug("设计师派单未启用或异常: %s", e)
|
||||
|
||||
# 3. 无人在线时企微提醒(新旧两套都没拿到在线结果时)
|
||||
online_count = int(dispatch_res.get("online_count", 0) or 0)
|
||||
if online_count <= 0 and not group_id:
|
||||
try:
|
||||
from config.config import WECHAT_WEBHOOK
|
||||
if WECHAT_WEBHOOK:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
resp = await c.post(WECHAT_WEBHOOK, json={
|
||||
"msgtype": "text",
|
||||
"text": {"content": "谁在线啊"},
|
||||
})
|
||||
if resp.status_code != 200:
|
||||
logger.warning("企微提醒发送失败: %s %s", resp.status_code, resp.text)
|
||||
else:
|
||||
logger.debug("未配置 WECHAT_WEBHOOK,跳过企微提醒")
|
||||
except Exception as e:
|
||||
logger.warning("企微提醒发送异常: %s", e)
|
||||
|
||||
# 4. 构造转接命令:有 assigned_to 用人名,否则回退分组
|
||||
if assigned_to:
|
||||
cmd = f"正在为你转接人工|[转移会话],{assigned_to},无原因"
|
||||
await client.send_reply(data, cmd)
|
||||
logger.info("[%s] 已发送转接请求 (店铺:%s -> 设计师:%s)", client.get_time(), acc_id or "未知", assigned_to)
|
||||
return
|
||||
|
||||
if not group_id:
|
||||
group_id = transfer_group_resolver(acc_id) if transfer_group_resolver else "20252916034"
|
||||
cmd = f"话术|[转移会话],分组{group_id},无原因"
|
||||
await client.send_reply(data, cmd)
|
||||
logger.info("[%s] 已发送转接请求 (店铺:%s -> 分组:%s)", client.get_time(), acc_id or "未知", group_id)
|
||||
@@ -1,64 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
async def workflow_agent_notify_flow(client, customer_id: str, acc_id: str, acc_type: str, system_hint: str):
|
||||
"""图片处理完成后,让客服 AI 生成自然话术发给客户。"""
|
||||
if not client.enable_agent or not client.agent:
|
||||
return
|
||||
try:
|
||||
from core.pydantic_ai_agent import CustomerMessage
|
||||
|
||||
notify_msg = CustomerMessage(
|
||||
msg_id="workflow_notify",
|
||||
acc_id=acc_id,
|
||||
msg=system_hint,
|
||||
from_id=customer_id,
|
||||
from_name="",
|
||||
cy_id=customer_id,
|
||||
acc_type=acc_type,
|
||||
msg_type=0,
|
||||
cy_name="",
|
||||
)
|
||||
response = await client.agent.process_message(notify_msg)
|
||||
if response.should_reply and response.reply:
|
||||
nonsense_patterns = [
|
||||
"无需", "流程已完成", "不需要回复", "无需额外", "已完成",
|
||||
"无需回复", "不需要额外", "已经完成", "无需再", "操作已完成",
|
||||
"任务完成", "流程完成", "记录完成", "报价已",
|
||||
]
|
||||
if not any(p in response.reply for p in nonsense_patterns):
|
||||
fake_data = {
|
||||
"acc_id": acc_id,
|
||||
"from_id": customer_id,
|
||||
"from_name": "",
|
||||
"cy_id": customer_id,
|
||||
"acc_type": acc_type,
|
||||
}
|
||||
await asyncio.sleep(0.5)
|
||||
await client.send_reply(fake_data, response.reply)
|
||||
client.logger.info(f"[Workflow] AI 通知已发送: {response.reply}")
|
||||
except Exception as e:
|
||||
client.logger.error(f"[Workflow] AI 通知生成失败: {e}")
|
||||
|
||||
|
||||
async def workflow_send_flow(
|
||||
client,
|
||||
customer_id: str,
|
||||
acc_id: str,
|
||||
acc_type: str,
|
||||
content: str,
|
||||
msg_type: int = 0,
|
||||
):
|
||||
"""workflow 回调:图片AI完成后用此方法推送消息给客户。"""
|
||||
msg = {
|
||||
"msg_id": "",
|
||||
"acc_id": acc_id,
|
||||
"msg": content,
|
||||
"from_id": customer_id,
|
||||
"from_name": customer_id,
|
||||
"cy_id": customer_id,
|
||||
"acc_type": acc_type,
|
||||
"msg_type": msg_type,
|
||||
"cy_name": customer_id,
|
||||
}
|
||||
await client.send_message(msg)
|
||||
974
core/workflow.py
974
core/workflow.py
@@ -1,974 +0,0 @@
|
||||
"""
|
||||
客服工作流 + 图片任务状态机
|
||||
|
||||
架构说明:
|
||||
- CustomerServiceWorkflow 负责管理图片处理任务的完整生命周期
|
||||
- 图片AI接入点:调用 workflow.image_ai_submit_result(task_id, result_url)
|
||||
- 消息回调接口:通过 register_send_callback 注入发送函数
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Callable, Awaitable, Any, List
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
_WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
async def _wechat_notify(content: str):
|
||||
"""workflow 内部异常推送企业微信"""
|
||||
if not _WECHAT_WEBHOOK:
|
||||
return
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(_WECHAT_WEBHOOK, json={
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": content}
|
||||
})
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
logger.info(f"[Workflow通知] 企业微信推送成功 ✓")
|
||||
else:
|
||||
logger.info(f"[Workflow通知] 企业微信推送失败: {data}")
|
||||
except Exception as e:
|
||||
logger.info(f"[Workflow通知] 推送异常: {e}")
|
||||
|
||||
from db.customer_db import db
|
||||
|
||||
|
||||
# ========== 任务状态 ==========
|
||||
|
||||
class TaskStatus(Enum):
|
||||
PENDING = "待处理" # 任务已创建,等待图片AI处理
|
||||
PROCESSING = "处理中" # 图片AI正在处理
|
||||
AWAITING_CONFIRM = "等待客户确认" # 结果已发给客户,等待确认
|
||||
REVISION = "修改中" # 客户要求修改,重新处理
|
||||
COMPLETED = "已完成" # 客户确认,邮件已发
|
||||
FAILED = "失败" # 处理失败
|
||||
|
||||
|
||||
# ========== 任务数据结构 ==========
|
||||
|
||||
@dataclass
|
||||
class ImageTask:
|
||||
task_id: str
|
||||
customer_id: str
|
||||
customer_name: str
|
||||
original_image: str # 原图路径或URL
|
||||
operation: str # 处理操作类型
|
||||
requirements: str = "" # 客户原始需求描述
|
||||
result_url: str = "" # 处理结果URL
|
||||
email: str = "" # 客户邮箱
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
revision_count: int = 0 # 修改次数
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
def update_status(self, status: TaskStatus):
|
||||
self.status = status
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
|
||||
|
||||
# ========== 工作流 ==========
|
||||
|
||||
class CustomerServiceWorkflow:
|
||||
"""
|
||||
客服工作流
|
||||
|
||||
图片AI对接方式:
|
||||
1. 调用 create_image_task() 创建任务,获取 task_id
|
||||
2. 图片AI处理完成后调用 image_ai_submit_result(task_id, result_url)
|
||||
3. 工作流自动发图给客户确认,并等待客户回复
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks: Dict[str, ImageTask] = {} # task_id -> ImageTask
|
||||
self.customer_active_task: Dict[str, str] = {} # customer_id -> 最新 task_id
|
||||
self._send_message: Optional[Callable] = None # 注入的消息发送函数
|
||||
self._agent_notify: Optional[Callable] = None # 注入的 AI 通知函数
|
||||
self._pending_analysis: Dict[str, dict] = {} # 待报价的识别结果
|
||||
|
||||
# ========== 回调注册(由 websocket_client 调用)==========
|
||||
|
||||
def register_agent_notify_callback(self, callback: Callable):
|
||||
"""
|
||||
注册 AI 通知回调,图片处理完成时调用 AI 生成消息发给客户。
|
||||
|
||||
callback 签名:
|
||||
async def notify(customer_id, acc_id, acc_type, system_prompt)
|
||||
"""
|
||||
self._agent_notify = callback
|
||||
|
||||
def register_send_callback(self, callback: Callable[[str, str, str, int], Awaitable[None]]):
|
||||
"""
|
||||
注册消息发送回调函数
|
||||
|
||||
callback 签名:
|
||||
async def send(customer_id, acc_id, acc_type, content, msg_type=0)
|
||||
"""
|
||||
self._send_message = callback
|
||||
|
||||
# ========== 任务管理 ==========
|
||||
|
||||
def create_image_task(
|
||||
self,
|
||||
customer_id: str,
|
||||
customer_name: str,
|
||||
original_image: str,
|
||||
operation: str,
|
||||
requirements: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
创建图片处理任务,返回 task_id
|
||||
|
||||
图片AI收到此 task_id 后开始处理,完成后调用 image_ai_submit_result
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
task = ImageTask(
|
||||
task_id=task_id,
|
||||
customer_id=customer_id,
|
||||
customer_name=customer_name,
|
||||
original_image=original_image,
|
||||
operation=operation,
|
||||
requirements=requirements,
|
||||
)
|
||||
self.tasks[task_id] = task
|
||||
self.customer_active_task[customer_id] = task_id
|
||||
|
||||
# 记录需求到客户画像
|
||||
if requirements:
|
||||
db.add_requirement(customer_id, requirements)
|
||||
|
||||
logger.info(f"[Workflow] 创建任务 {task_id} | 客户: {customer_name} | 操作: {operation}")
|
||||
return task_id
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[ImageTask]:
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
def get_customer_active_task(self, customer_id: str) -> Optional[ImageTask]:
|
||||
task_id = self.customer_active_task.get(customer_id)
|
||||
return self.tasks.get(task_id) if task_id else None
|
||||
|
||||
# ========== 图片识别AI接入点(报价用)==========
|
||||
|
||||
async def image_analysis_result(
|
||||
self,
|
||||
customer_id: str,
|
||||
image_url: str,
|
||||
complexity: str,
|
||||
acc_id: str = "",
|
||||
acc_type: str = "AliWorkbench",
|
||||
gemini_prompt: str = "",
|
||||
aspect_ratio: str = "1:1",
|
||||
perspective: str = "no",
|
||||
proc_type: str = "",
|
||||
subject: str = "",
|
||||
quality: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
【图片识别AI专用接口】分析完成后调用此方法,触发客服AI报价
|
||||
|
||||
Args:
|
||||
customer_id: 客户ID
|
||||
image_url: 图片URL(原图)
|
||||
complexity: 复杂度评估结果,枚举值:
|
||||
"simple" → 10-20元
|
||||
"normal" → 20-30元
|
||||
"complex" → 30元
|
||||
"hard" → 40元
|
||||
acc_id: 店铺账号ID
|
||||
acc_type: 平台类型
|
||||
|
||||
Returns:
|
||||
True = 成功触发报价,False = 客户不存在
|
||||
"""
|
||||
price_map = {
|
||||
"simple": "10-15元,这张比较简单",
|
||||
"normal": "15-20元",
|
||||
"complex": "20-25元",
|
||||
"hard": "25-30元",
|
||||
}
|
||||
price_hint = price_map.get(complexity, "20元")
|
||||
|
||||
# 把所有分析字段存入任务
|
||||
requirements = f"complexity:{complexity}"
|
||||
if gemini_prompt:
|
||||
requirements += f"|prompt:{gemini_prompt}"
|
||||
if aspect_ratio:
|
||||
requirements += f"|ratio:{aspect_ratio}"
|
||||
if perspective and perspective != "no":
|
||||
requirements += f"|perspective:{perspective}"
|
||||
if proc_type:
|
||||
requirements += f"|proc_type:{proc_type}"
|
||||
if subject:
|
||||
requirements += f"|subject:{subject}"
|
||||
if quality:
|
||||
requirements += f"|quality:{quality}"
|
||||
|
||||
task_id = self.create_image_task(
|
||||
customer_id=customer_id,
|
||||
customer_name=customer_id,
|
||||
original_image=image_url,
|
||||
operation="enhance",
|
||||
requirements=requirements,
|
||||
)
|
||||
|
||||
logger.info(f"[Workflow] 图片识别完成 | 客户:{customer_id} | 复杂度:{complexity} | 建议报价:{price_hint}")
|
||||
|
||||
# 通知客服AI报价(把识别结果注入消息,让AI根据结果报价)
|
||||
if self._send_message:
|
||||
# 这里不直接发价格,而是触发 agent 重新处理一条带识别结果的内部消息
|
||||
# 实际报价由客服AI根据 complexity 生成,保持口吻一致
|
||||
self._pending_analysis[customer_id] = {
|
||||
"task_id": task_id,
|
||||
"complexity": complexity,
|
||||
"price_hint": price_hint,
|
||||
"image_url": image_url,
|
||||
}
|
||||
return True
|
||||
|
||||
def get_pending_analysis(self, customer_id: str) -> dict:
|
||||
"""
|
||||
客服AI处理消息时调用,检查该客户是否有待报价的识别结果
|
||||
取出后自动清除(一次性)
|
||||
"""
|
||||
return self._pending_analysis.pop(customer_id, None)
|
||||
|
||||
# ========== 付款后触发 Gemini 作图 ==========
|
||||
|
||||
async def trigger_processing_on_payment(
|
||||
self,
|
||||
customer_id: str,
|
||||
acc_id: str = "",
|
||||
acc_type: str = "AliWorkbench"
|
||||
) -> bool:
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
await _wechat_notify(
|
||||
f"ℹ️ **付款触发但已暂停自动作图**\n客户:{customer_id}\n店铺:{acc_id}\n请人工安排处理"
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
"""
|
||||
客户付款后调用此方法,找到该客户待处理的任务并启动 Gemini 作图。
|
||||
由 pydantic_ai_agent 在识别到"已付款"订单通知时调用。
|
||||
也可作为 tool 由 AI 主动触发。
|
||||
|
||||
Returns:
|
||||
True=已启动处理, False=无待处理任务
|
||||
"""
|
||||
task = self.get_customer_active_task(customer_id)
|
||||
|
||||
if not task:
|
||||
# 内存任务丢失(重启场景)→ 从客户档案重建
|
||||
logger.info(f"[Workflow] 付款触发:内存无任务,尝试从客户档案重建 | 客户: {customer_id}")
|
||||
task = await self._rebuild_task_from_profile(customer_id, acc_id, acc_type)
|
||||
if not task:
|
||||
logger.info(f"[Workflow] 付款触发:客户 {customer_id} 无图片记录,无法重建任务,跳过")
|
||||
await _wechat_notify(
|
||||
f"⚠️ **付款但无图片**\n"
|
||||
f"客户:{customer_id}\n"
|
||||
f"店铺:{acc_id}\n"
|
||||
f"已付款但找不到待处理图片,请人工发图处理"
|
||||
)
|
||||
return False
|
||||
|
||||
if task.status not in (TaskStatus.PENDING,):
|
||||
logger.info(f"[Workflow] 付款触发:任务 {task.task_id[:8]}... 状态={task.status.value},跳过")
|
||||
return False
|
||||
|
||||
task.operation = task.operation or "enhance"
|
||||
logger.info(f"[Workflow] 付款确认,启动 Gemini 处理 | 客户: {customer_id} | 任务: {task.task_id[:8]}...")
|
||||
asyncio.create_task(self._auto_process(task.task_id, acc_id=acc_id, acc_type=acc_type))
|
||||
return True
|
||||
|
||||
async def _rebuild_task_from_profile(
|
||||
self, customer_id: str, acc_id: str, acc_type: str
|
||||
) -> Optional["ImageTask"]:
|
||||
"""
|
||||
重启后任务丢失时,从客户档案里读取 last_image_url 重建一个 PENDING 任务。
|
||||
"""
|
||||
try:
|
||||
from db.customer_db import db
|
||||
profile = db.get_customer(customer_id)
|
||||
image_url = profile.last_image_url
|
||||
if not image_url:
|
||||
return None
|
||||
|
||||
complexity = profile.complexity_history[-1] if profile.complexity_history else ""
|
||||
gemini_prompt = getattr(profile, "last_gemini_prompt", "")
|
||||
aspect_ratio = getattr(profile, "last_aspect_ratio", "1:1")
|
||||
perspective = getattr(profile, "last_perspective", "no")
|
||||
|
||||
requirements = f"complexity:{complexity}" if complexity else ""
|
||||
if gemini_prompt:
|
||||
requirements += f"|prompt:{gemini_prompt}"
|
||||
if aspect_ratio:
|
||||
requirements += f"|ratio:{aspect_ratio}"
|
||||
if perspective and perspective != "no":
|
||||
requirements += f"|perspective:{perspective}"
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
task = ImageTask(
|
||||
task_id=task_id,
|
||||
customer_id=customer_id,
|
||||
customer_name=profile.name or customer_id,
|
||||
original_image=image_url,
|
||||
operation="enhance",
|
||||
requirements=requirements,
|
||||
status=TaskStatus.PENDING,
|
||||
)
|
||||
self.tasks[task_id] = task
|
||||
self.customer_active_task[customer_id] = task_id
|
||||
logger.info(f"[Workflow] 任务已重建 | 客户: {customer_id} | 图片: {image_url[:60]}...")
|
||||
return task
|
||||
except Exception as e:
|
||||
logger.info(f"[Workflow] 任务重建失败: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_requirements(requirements: str) -> dict:
|
||||
"""从 requirements 字符串解析各字段,格式: complexity:xxx|prompt:xxx|ratio:xxx"""
|
||||
parsed = {}
|
||||
for part in (requirements or "").split("|"):
|
||||
part = part.strip()
|
||||
if ":" in part:
|
||||
k, v = part.split(":", 1)
|
||||
parsed[k.strip()] = v.strip()
|
||||
return parsed
|
||||
|
||||
async def _auto_process(self, task_id: str, acc_id: str = "", acc_type: str = "AliWorkbench"):
|
||||
"""付款确认后自动调用 Gemini 处理图片,完成后通知客户"""
|
||||
try:
|
||||
from config.config import IMAGE_MODULE_ENABLED
|
||||
if not IMAGE_MODULE_ENABLED:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
task = self.tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
task.update_status(TaskStatus.PROCESSING)
|
||||
|
||||
req = self._parse_requirements(task.requirements)
|
||||
gemini_prompt = req.get("prompt", "")
|
||||
aspect_ratio = req.get("ratio", "1:1")
|
||||
perspective = req.get("perspective", "no")
|
||||
proc_type = req.get("proc_type", "")
|
||||
subject = req.get("subject", "")
|
||||
quality = req.get("quality", "")
|
||||
revision_note = req.get("revision", "")
|
||||
# 客户修改意见追加到 prompt 末尾
|
||||
if revision_note:
|
||||
gemini_prompt = (gemini_prompt or "") + f"\n【客户修改要求】{revision_note}"
|
||||
|
||||
logger.info(f"[Workflow] Gemini 开始处理 | 任务: {task_id[:8]}... | 比例: {aspect_ratio} | 透视: {perspective} | 图片: {task.original_image}")
|
||||
try:
|
||||
from image.image_processor import image_processor
|
||||
from utils.image_queue import run_with_queue
|
||||
result = await run_with_queue(image_processor.process_image(
|
||||
task.original_image,
|
||||
task.operation,
|
||||
requirements=task.requirements,
|
||||
gemini_prompt=gemini_prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
perspective=perspective,
|
||||
proc_type=proc_type,
|
||||
subject=subject,
|
||||
quality=quality,
|
||||
))
|
||||
if result["success"]:
|
||||
attempts = result.get("attempts", 1)
|
||||
qa_score = result.get("qa_score", 0)
|
||||
qa_pass = result.get("qa_pass", True)
|
||||
qa_issue = result.get("qa_issue", "")
|
||||
logger.info(f"[Workflow] Gemini 处理完成 | 任务: {task_id[:8]}... | 质检: {qa_score}分 | 尝试: {attempts}次")
|
||||
|
||||
# 质检未通过(已达重试上限,保留结果但人工跟进)
|
||||
if not qa_pass:
|
||||
await _wechat_notify(
|
||||
f"⚠️ **图片质检未通过,请人工核查**\n"
|
||||
f"客户:{task.customer_id}\n"
|
||||
f"店铺:{acc_id}\n"
|
||||
f"质检得分:{qa_score}/100\n"
|
||||
f"问题:{qa_issue}\n"
|
||||
f"已处理 {attempts} 次,结果已发出,请人工确认质量"
|
||||
)
|
||||
|
||||
await self.image_ai_submit_result(
|
||||
task_id=task_id,
|
||||
result_url=result["result_path"],
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
)
|
||||
else:
|
||||
err_msg = result['message']
|
||||
logger.info(f"[Workflow] Gemini 处理失败: {err_msg}")
|
||||
task.update_status(TaskStatus.FAILED)
|
||||
# 企业微信预警
|
||||
await _wechat_notify(
|
||||
f"⚠️ **Gemini作图失败**\n"
|
||||
f"客户:{task.customer_id}\n"
|
||||
f"店铺:{acc_id}\n"
|
||||
f"原因:{err_msg[:200]}\n"
|
||||
f"请人工跟进"
|
||||
)
|
||||
# 通知客户稍等,并告知转人工
|
||||
if self._send_message:
|
||||
await self._send_message(
|
||||
customer_id=task.customer_id,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
content="您好,图片处理遇到点问题,已帮您转接人工客服处理,请稍候",
|
||||
msg_type=0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(f"[Workflow] 自动处理异常: {e}")
|
||||
task.update_status(TaskStatus.FAILED)
|
||||
await _wechat_notify(
|
||||
f"⚠️ **Workflow处理异常**\n"
|
||||
f"客户:{task.customer_id}\n"
|
||||
f"错误:{str(e)[:200]}"
|
||||
)
|
||||
|
||||
# ========== 图片AI接入点(作图用)==========
|
||||
|
||||
async def image_ai_submit_result(
|
||||
self,
|
||||
task_id: str,
|
||||
result_url: str,
|
||||
acc_id: str = "",
|
||||
acc_type: str = "AliWorkbench"
|
||||
) -> bool:
|
||||
"""
|
||||
【图片AI专用接口】处理完成后调用此方法
|
||||
|
||||
Args:
|
||||
task_id: create_image_task 返回的任务ID
|
||||
result_url: 处理后的图片URL或本地路径
|
||||
acc_id: 店铺账号ID(发消息用)
|
||||
acc_type: 平台类型
|
||||
|
||||
Returns:
|
||||
True = 成功,False = 任务不存在
|
||||
"""
|
||||
task = self.tasks.get(task_id)
|
||||
if not task:
|
||||
logger.info(f"[Workflow] 任务不存在: {task_id}")
|
||||
return False
|
||||
|
||||
task.result_url = result_url
|
||||
task.update_status(TaskStatus.AWAITING_CONFIRM)
|
||||
|
||||
logger.info(f"[Workflow] 任务 {task_id} 处理完成,发送给客户确认")
|
||||
|
||||
# 先发结果图片
|
||||
if self._send_message:
|
||||
await self._send_message(
|
||||
customer_id=task.customer_id,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
content=result_url,
|
||||
msg_type=1 # 图片
|
||||
)
|
||||
|
||||
# 让客服 AI 生成完成通知话术(自然口吻,询问邮箱)
|
||||
if self._agent_notify:
|
||||
await self._agent_notify(
|
||||
customer_id=task.customer_id,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
system_hint="【图片已处理完成并发给客户】请用自然口吻告诉客户图发好了,让他看一下效果,没问题把邮箱发过来,你来发给他。不超过1句话。",
|
||||
)
|
||||
elif self._send_message:
|
||||
# 兜底:AI 不可用时用固定话术
|
||||
await self._send_message(
|
||||
customer_id=task.customer_id,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
content="好了,你看一下效果,没问题把邮箱发我",
|
||||
msg_type=0,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
# ========== 客户回复处理 ==========
|
||||
|
||||
async def handle_customer_reply(
|
||||
self,
|
||||
customer_id: str,
|
||||
message: str,
|
||||
acc_id: str = "",
|
||||
acc_type: str = "AliWorkbench"
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
处理正在等待确认的客户回复
|
||||
|
||||
Returns:
|
||||
需要回复客户的文本,None 表示不是确认相关消息
|
||||
"""
|
||||
task = self.get_customer_active_task(customer_id)
|
||||
if not task or task.status != TaskStatus.AWAITING_CONFIRM:
|
||||
return None
|
||||
|
||||
msg = message.strip()
|
||||
|
||||
# 提取邮箱
|
||||
import re
|
||||
email_match = re.search(r'[\w\.-]+@[\w\.-]+\.\w+', msg)
|
||||
if email_match:
|
||||
email = email_match.group()
|
||||
task.email = email
|
||||
db.update_email(customer_id, email)
|
||||
# 发送邮件(调用 email_sender)
|
||||
result = await self._send_email(task)
|
||||
if result:
|
||||
task.update_status(TaskStatus.COMPLETED)
|
||||
db.update_email_status(task.customer_id, "sent")
|
||||
db.complete_order(task.customer_id, had_revision=task.revision_count > 0)
|
||||
db.auto_compute_tags(task.customer_id)
|
||||
return "发到您邮箱了,注意查收哈"
|
||||
else:
|
||||
db.update_email_status(task.customer_id, "failed")
|
||||
return "邮件发送失败了,您再发一次邮箱试试"
|
||||
|
||||
# 客户说不满意/要改
|
||||
negative_keywords = ["不好", "不对", "不满意", "重做", "改一下", "差太多", "不行", "效果不好", "颜色不对"]
|
||||
if any(kw in msg for kw in negative_keywords):
|
||||
task.revision_count += 1
|
||||
task.update_status(TaskStatus.REVISION)
|
||||
db.record_revision(task.customer_id)
|
||||
# 把客户的修改意见追加进 requirements,下次重做时 Gemini 能看到
|
||||
if msg:
|
||||
task.requirements += f"|revision:{msg[:100]}"
|
||||
return "好,你说一下哪里要改,或者发图告诉我"
|
||||
|
||||
# 客户提供了修改说明(处于 REVISION 状态时)
|
||||
if task.status == TaskStatus.REVISION and msg:
|
||||
task.requirements += f"|revision:{msg[:100]}"
|
||||
task.update_status(TaskStatus.PENDING)
|
||||
# 重新触发处理
|
||||
asyncio.create_task(
|
||||
self._auto_process(task.task_id, acc_id=acc_id, acc_type=acc_type)
|
||||
)
|
||||
return "好的,重新给你做"
|
||||
|
||||
return None
|
||||
|
||||
async def _send_email(self, task: ImageTask) -> bool:
|
||||
"""发送完成作品邮件"""
|
||||
try:
|
||||
from mail.email_sender import email_sender
|
||||
profile = db.get_customer(task.customer_id)
|
||||
result = email_sender.send_completed_work(
|
||||
to_email=task.email,
|
||||
customer_name=profile.name or task.customer_name,
|
||||
image_description=task.requirements or task.operation,
|
||||
result_images=[task.result_url]
|
||||
)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
logger.info(f"[Workflow] 邮件发送失败: {e}")
|
||||
await _wechat_notify(
|
||||
f"⚠️ **邮件发送失败**\n"
|
||||
f"客户:{task.customer_id}\n"
|
||||
f"邮箱:{task.email}\n"
|
||||
f"错误:{str(e)[:200]}"
|
||||
)
|
||||
return False
|
||||
|
||||
# ========== 工具方法 ==========
|
||||
|
||||
def detect_operation(self, message: str) -> str:
|
||||
"""根据客户描述识别处理操作"""
|
||||
msg = message.lower()
|
||||
if any(kw in msg for kw in ["模糊", "清晰", "高清", "变清"]):
|
||||
return "enhance"
|
||||
elif any(kw in msg for kw in ["背景", "去背", "抠图", "透明"]):
|
||||
return "remove_bg"
|
||||
elif any(kw in msg for kw in ["尺寸", "大小", "缩放", "分辨率"]):
|
||||
return "resize"
|
||||
elif any(kw in msg for kw in ["老照片", "修复", "发黄", "破损"]):
|
||||
return "fix_old_photo"
|
||||
elif any(kw in msg for kw in ["分层", "psd"]):
|
||||
return "layered"
|
||||
else:
|
||||
return "enhance"
|
||||
|
||||
def get_task_summary(self) -> str:
|
||||
"""获取当前所有任务摘要(调试用)"""
|
||||
if not self.tasks:
|
||||
return "暂无任务"
|
||||
lines = []
|
||||
for tid, task in self.tasks.items():
|
||||
lines.append(
|
||||
f" [{task.status.value}] {task.customer_name} | {task.operation} | {tid[:8]}..."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ========== 客户需求变更 ==========
|
||||
|
||||
async def add_customer_requirement(self, task_id: str, customer_id: str,
|
||||
requirement: str, changed_by: str = 'customer') -> bool:
|
||||
# 检查任务是否存在
|
||||
task = self.get_task(task_id)
|
||||
if not task:
|
||||
# 尝试从数据库加载
|
||||
db_task = self.db.get_task(task_id)
|
||||
if db_task:
|
||||
logger.info(f"[Workflow] 从数据库加载任务:{task_id[:8]}...")
|
||||
# 可以在这里重建内存任务
|
||||
else:
|
||||
logger.info(f"[Workflow] 任务不存在:{task_id}")
|
||||
return False
|
||||
|
||||
# 添加到数据库
|
||||
success = self.db.add_customer_note(task_id, requirement, changed_by)
|
||||
|
||||
if success:
|
||||
logger.info(f"[Workflow] 客户添加需求:{task_id[:8]}... | {requirement}")
|
||||
|
||||
# 如果任务还在待处理状态,通知 AI 客服
|
||||
if task and task.status.value == 'pending':
|
||||
if self._send_message:
|
||||
await self._send_message(
|
||||
customer_id=customer_id,
|
||||
acc_id=task.acc_id,
|
||||
acc_type=task.acc_type,
|
||||
content=f"好的,已记录您的需求:{requirement},处理时会注意的",
|
||||
msg_type=0,
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
async def modify_operation(self, task_id: str, customer_id: str,
|
||||
new_operation: str, changed_by: str = 'customer') -> bool:
|
||||
"""
|
||||
客户修改操作类型
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
customer_id: 客户 ID
|
||||
new_operation: 新操作(enhance/remove_bg/vectorize 等)
|
||||
changed_by: 修改者
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
task = self.get_task(task_id)
|
||||
if not task:
|
||||
db_task = self.db.get_task(task_id)
|
||||
if not db_task:
|
||||
logger.info(f"[Workflow] 任务不存在:{task_id}")
|
||||
return False
|
||||
|
||||
# 检查状态,已处理完成的不允许修改
|
||||
if task and task.status.value in ['completed', 'processing']:
|
||||
logger.info(f"[Workflow] 任务已开始处理,不允许修改操作:{task_id}")
|
||||
if self._send_message:
|
||||
await self._send_message(
|
||||
customer_id=customer_id,
|
||||
acc_id=task.acc_id,
|
||||
acc_type=task.acc_type,
|
||||
content="抱歉,图片已经开始处理了,无法修改操作类型",
|
||||
msg_type=0,
|
||||
)
|
||||
return False
|
||||
|
||||
# 修改数据库
|
||||
success = self.db.modify_operation(task_id, new_operation, changed_by)
|
||||
|
||||
if success and task:
|
||||
task.operation = new_operation
|
||||
logger.info(f"[Workflow] 修改操作类型:{task_id[:8]}... -> {new_operation}")
|
||||
|
||||
if self._send_message:
|
||||
await self._send_message(
|
||||
customer_id=customer_id,
|
||||
acc_id=task.acc_id,
|
||||
acc_type=task.acc_type,
|
||||
content=f"好的,已为您修改为{new_operation}操作",
|
||||
msg_type=0,
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
def get_task_requirement_history(self, task_id: str) -> List[dict]:
|
||||
"""获取任务需求变更历史"""
|
||||
return self.db.get_requirement_history(task_id)
|
||||
|
||||
# ========== 三种工作流 ==========
|
||||
|
||||
async def find_image_workflow(self, customer_id: str, image_url: str,
|
||||
acc_id: str = "", acc_type: str = "AliWorkbench") -> bool:
|
||||
"""
|
||||
工作流 1:查找图片
|
||||
客户说"找一下这个图" → 自己处理 → 上传到图绘 → 返回 URL
|
||||
|
||||
Args:
|
||||
customer_id: 客户 ID
|
||||
image_url: 图片 URL
|
||||
acc_id: 店铺 ID
|
||||
acc_type: 平台类型
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[Workflow] 启动查找图片工作流 | 客户:{customer_id}")
|
||||
|
||||
# 1. 创建任务
|
||||
task_id = self.create_image_task(
|
||||
customer_id=customer_id,
|
||||
customer_name=customer_id,
|
||||
original_image=image_url,
|
||||
operation="find", # 查找操作
|
||||
requirements="type:find",
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type
|
||||
)
|
||||
|
||||
# 2. 这里调用图绘 API 上传图片
|
||||
# TODO: 调用图绘上传 API
|
||||
# tuhui_url = await self._upload_to_tuhui(image_url)
|
||||
|
||||
# 临时模拟
|
||||
tuhui_url = f"http://tuhui.cloud/works/123"
|
||||
|
||||
# 3. 更新任务结果
|
||||
self.db.update_result(task_id, tuhui_url)
|
||||
self.db.update_status(task_id, DBTaskStatus.COMPLETED)
|
||||
|
||||
# 4. 回复客户
|
||||
if self._send_message:
|
||||
await self._send_message(
|
||||
customer_id=customer_id,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
content=f"找到了!图片在这里:{tuhui_url}",
|
||||
msg_type=0,
|
||||
)
|
||||
|
||||
logger.info(f"[Workflow] 查找图片完成 | 客户:{customer_id} | URL: {tuhui_url}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查找图片工作流失败:{e}")
|
||||
return False
|
||||
|
||||
async def process_image_workflow(self, customer_id: str, image_url: str,
|
||||
acc_id: str = "", acc_type: str = "AliWorkbench") -> bool:
|
||||
"""
|
||||
工作流 2:处理图片
|
||||
客户说"做一下" → 评估图片 → 稍等做
|
||||
|
||||
Args:
|
||||
customer_id: 客户 ID
|
||||
image_url: 图片 URL
|
||||
acc_id: 店铺 ID
|
||||
acc_type: 平台类型
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[Workflow] 启动处理图片工作流 | 客户:{customer_id}")
|
||||
|
||||
# 1. 创建任务
|
||||
task_id = self.create_image_task(
|
||||
customer_id=customer_id,
|
||||
customer_name=customer_id,
|
||||
original_image=image_url,
|
||||
operation="enhance",
|
||||
requirements="type:process",
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type
|
||||
)
|
||||
|
||||
# 2. 回复客户稍等
|
||||
if self._send_message:
|
||||
await self._send_message(
|
||||
customer_id=customer_id,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
content="稍等,我看看...好的,可以做,马上处理",
|
||||
msg_type=0,
|
||||
)
|
||||
|
||||
# 3. 启动处理
|
||||
await self.trigger_processing_on_payment(customer_id, acc_id, acc_type)
|
||||
|
||||
logger.info(f"[Workflow] 处理图片已启动 | 客户:{customer_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理图片工作流失败:{e}")
|
||||
return False
|
||||
|
||||
async def transfer_to_designer_workflow(self, customer_id: str, image_url: str,
|
||||
acc_id: str = "", acc_type: str = "AliWorkbench",
|
||||
reason: str = "做不了") -> bool:
|
||||
"""
|
||||
工作流 3:转人工派单
|
||||
做不了 → 查询企业微信在线设计师 → 派单
|
||||
|
||||
Args:
|
||||
customer_id: 客户 ID
|
||||
image_url: 图片 URL
|
||||
acc_id: 店铺 ID
|
||||
acc_type: 平台类型
|
||||
reason: 转接原因
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[Workflow] 启动转人工派单工作流 | 客户:{customer_id} | 原因:{reason}")
|
||||
|
||||
# 1. 创建任务
|
||||
task_id = self.create_image_task(
|
||||
customer_id=customer_id,
|
||||
customer_name=customer_id,
|
||||
original_image=image_url,
|
||||
operation="manual",
|
||||
requirements=f"type:transfer|reason:{reason}",
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type
|
||||
)
|
||||
|
||||
# 2. 查询企业微信在线设计师
|
||||
online_designers = await self._get_online_designers()
|
||||
|
||||
if not online_designers:
|
||||
# 无人在线,通知客户
|
||||
if self._send_message:
|
||||
await self._send_message(
|
||||
customer_id=customer_id,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
content="抱歉,现在设计师都不在线,稍后会有人联系您",
|
||||
msg_type=0,
|
||||
)
|
||||
|
||||
# 企业微信预警
|
||||
await _wechat_notify(
|
||||
f"⚠️ **人工派单但无人在线**\n"
|
||||
f"客户:{customer_id}\n"
|
||||
f"店铺:{acc_id}\n"
|
||||
f"原因:{reason}\n"
|
||||
f"请安排设计师上线"
|
||||
)
|
||||
|
||||
logger.info(f"[Workflow] 无人在线 | 客户:{customer_id}")
|
||||
return False
|
||||
|
||||
# 3. 派单给在线设计师
|
||||
designer_name = online_designers[0] # 取第一个在线的
|
||||
success = await self._dispatch_to_designer(task_id, designer_name, customer_id, image_url, reason)
|
||||
|
||||
if not success:
|
||||
logger.error("派单失败")
|
||||
return False
|
||||
|
||||
# 4. 回复客户
|
||||
if self._send_message:
|
||||
await self._send_message(
|
||||
customer_id=customer_id,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
content="好的,已帮您安排设计师处理,请稍候",
|
||||
msg_type=0,
|
||||
)
|
||||
|
||||
logger.info(f"[Workflow] 已派单给设计师:{designer} | 客户:{customer_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"转人工派单工作流失败:{e}")
|
||||
return False
|
||||
|
||||
async def _get_online_designers(self) -> list:
|
||||
"""
|
||||
查询在线设计师(使用图绘派单 API)
|
||||
|
||||
Returns:
|
||||
list: 在线设计师名单 ["橘子", "婷婷", ...]
|
||||
"""
|
||||
try:
|
||||
designers = await self.dispatch_client.get_online_designers()
|
||||
logger.info(f"[Workflow] 查询在线设计师:{len(designers)}人在线 | {designers}")
|
||||
return designers
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"查询在线设计师失败:{e}")
|
||||
return []
|
||||
|
||||
async def _dispatch_to_designer(self, task_id: str, designer_name: str,
|
||||
customer_id: str, image_url: str, reason: str) -> bool:
|
||||
"""
|
||||
派单给设计师(使用图绘派单 API)
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
designer_name: 设计师姓名
|
||||
customer_id: 客户 ID
|
||||
image_url: 图片 URL
|
||||
reason: 转接原因
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
try:
|
||||
# 1. 在派单系统创建任务
|
||||
dispatch_task_id = await self.dispatch_client.create_task(
|
||||
task_name=f"图片处理-{customer_id[-4:]}",
|
||||
description=f"{reason}\n客户:{customer_id}\n图片:{image_url}",
|
||||
task_type="image_process",
|
||||
priority=2,
|
||||
deadline=None
|
||||
)
|
||||
|
||||
if not dispatch_task_id:
|
||||
logger.error("创建派单任务失败")
|
||||
return False
|
||||
|
||||
# 2. 分配给设计师
|
||||
success = await self.dispatch_client.assign_task(
|
||||
task_id=dispatch_task_id,
|
||||
designer_name=designer_name,
|
||||
notes=f"AI 客服自动派单\n原因:{reason}\n客户:{customer_id}"
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"[Workflow] 派单成功:{dispatch_task_id} → {designer_name} | 客户:{customer_id}")
|
||||
|
||||
# 企业微信通知
|
||||
await _wechat_notify(
|
||||
f"📋 **新任务派单**\n"
|
||||
f"设计师:{designer_name}\n"
|
||||
f"任务 ID: {dispatch_task_id}\n"
|
||||
f"客户:{customer_id}\n"
|
||||
f"原因:{reason}\n"
|
||||
f"请及时处理"
|
||||
)
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error("分配任务失败")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"派单失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
# ========== 全局实例 ==========
|
||||
workflow = CustomerServiceWorkflow()
|
||||
|
||||
19
core/提示词.MD
Normal file
19
core/提示词.MD
Normal file
@@ -0,0 +1,19 @@
|
||||
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话高端、专业。\n\n"
|
||||
|
||||
"【统一称呼规范】\n"
|
||||
"1. 严禁使用'师傅'、'客服'、'专员'等词汇!\n"
|
||||
"2. 必须统一称呼为【设计师】。比如:'找设计师看下'、'设计师马上来'、'等设计师核价'。\n\n"
|
||||
|
||||
"【核心逻辑】\n"
|
||||
"1. 业务:只聊高清修复和找原图。引导发图 -> 问需求 -> 找设计师。\n"
|
||||
"2. 下线安抚:如果工具返回 'ERROR_NO_DESIGNER_ONLINE',说明设计师们【下班/下线】了。回:'亲亲,设计师现在下班啦,需求我先记下,明天第一时间回您哈!'。\n"
|
||||
"3. 正在转接中:如果看到系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'。\n\n"
|
||||
"4. 没转接时:引导发图 -> 问需求 -> 调工具转人工。\n\n
|
||||
"5. 语气:淘宝亲切风,多用'亲亲'、'铁子'。每句回复【严禁超过15字】!\n\n"
|
||||
|
||||
"【必杀令】\n"
|
||||
"1. 每句回复严禁超过15个字!\n"
|
||||
"2. 严禁报价,严禁复读图片已收到的情况。\n"
|
||||
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n\n"
|
||||
|
||||
f"业务参考:\n{all_skills}"
|
||||
Reference in New Issue
Block a user