refactor: migrate workflow to v2 core and archive legacy modules

This commit is contained in:
2026-03-04 21:52:24 +08:00
parent e1ce17f2aa
commit fa61b11b02
156 changed files with 1781 additions and 2066 deletions

29
core/adapters/base.py Normal file
View 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

View 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)]

View File

@@ -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")

View File

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

View File

@@ -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] 工具箱已更新:称呼统一为“设计师”。")

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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 ""

View File

@@ -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
View 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
View 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()

View File

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

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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})

View File

@@ -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
View 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()

View File

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

View File

@@ -1,3 +0,0 @@
from .engine import Rule, RuleContext, RuleEngine, RuleResult
__all__ = ["Rule", "RuleContext", "RuleEngine", "RuleResult"]

View File

@@ -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
View 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
View 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()

View File

@@ -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),
)

View File

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

View File

@@ -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": "",
},
)
)

View File

@@ -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),
)

View File

@@ -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) # 已回复消息IDFIFO去重
# 消息防抖:同一客户连续发消息时,等待 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已停止")

View 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("已停止")

View File

@@ -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}")

View File

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

View File

@@ -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

View File

@@ -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)}

View File

@@ -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

View File

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

View File

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

View File

@@ -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}")

View File

@@ -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})")

View File

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

View File

@@ -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"

View File

@@ -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}"

View File

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

View File

@@ -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}")

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -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
View 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}"