This commit is contained in:
2026-03-06 13:23:32 +08:00
parent 4ba636e98c
commit afb2b78c15
29 changed files with 76 additions and 1521 deletions

View File

@@ -7,43 +7,18 @@ logger = logging.getLogger("cs_agent")
class BusinessEngine:
"""
业务逻辑中枢
1. 接收 StandardMessage
2. 决定由哪个 AI 工具或流程处理。
3. 返回 StandardResponse。
4. 对外广播异步事件。
业务逻辑中枢(备用引擎,主流程由 Orchestrator + Brain 处理)。
仅在 Orchestrator 不可用时作为降级方案
"""
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}
)
content = (msg.content or "")
logger.info(f"[Engine] 收到来自 {msg.platform} 的消息: {msg.user_id} -> {content[:50]}")
# 兜底回复
return StandardResponse(
reply_content="你好我是AI助手有什么可以帮你的",
reply_content="稍等哈,设计师马上来。",
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
)

View File

@@ -29,8 +29,11 @@ class AsyncEventBus:
tasks.append(asyncio.create_task(callback(**kwargs)))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[EventBus] 事件 {event_type} 已成功广播给 {len(tasks)} 个订阅者")
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, r in enumerate(results):
if isinstance(r, Exception):
logger.error(f"[EventBus] 事件 {event_type} 订阅者 {i} 异常: {r}")
logger.info(f"[EventBus] 事件 {event_type} 已广播给 {len(tasks)} 个订阅者")
# 全局单例,所有模块共用这一个广播台
bus = AsyncEventBus()

View File

@@ -68,11 +68,15 @@ class SystemOrchestrator:
# 店铺隔离:同一客户在不同店铺的对话独立处理
session_key = f"{user_id}@{std_msg.acc_id}"
# 订单消息处理:静默入库并更新状态,但不触发 AI 回复
if "[系统订单信息]" in (std_msg.content or ""):
# 订单消息 / 纯金额通知:静默入库,不触发 AI 回复
msg_text = std_msg.content or ""
is_order = "[系统订单信息]" in msg_text
is_price_only = bool(re.match(r'^[\s\n]*金?额?[:]?\s*[\d.]+\s*元', msg_text.strip()))
is_sku_only = bool(re.match(r'^[\s\n]*(备注[:]|数量[:]|款式[:])', msg_text.strip()))
if is_order or is_price_only or is_sku_only:
await self._handle_order_packet(platform, std_msg)
logger.info(f"[订单消息] user={user_id} acc={std_msg.acc_id} 已入库更新状态")
await repo.save_chat(platform, user_id, std_msg.content, "in", acc_id=std_msg.acc_id)
await repo.save_chat(platform, user_id, msg_text, "in", acc_id=std_msg.acc_id)
return
preview = (std_msg.content or "").replace("\n", "\\n")
@@ -84,7 +88,7 @@ class SystemOrchestrator:
)
# 过滤心跳
if not std_msg.content.strip() and not std_msg.image_urls: return
if not (std_msg.content or "").strip() and not std_msg.image_urls: return
# 如果是商家人工回复,静默入库
if direction == "out":
@@ -180,8 +184,15 @@ class SystemOrchestrator:
messages = self._pending_messages.pop(session_key, [])
if not messages: return
# A. 合并与元数据修复
combined_content = "\n".join([m.content for m in messages if m.content.strip()])
# A. 合并与元数据修复(去重:同一防抖窗口内完全相同的内容只保留一条)
seen_contents = set()
unique_parts = []
for m in messages:
c = (m.content or "").strip()
if c and c not in seen_contents:
seen_contents.add(c)
unique_parts.append(c)
combined_content = "\n".join(unique_parts)
all_image_urls = []
acc_id = messages[-1].acc_id
acc_type = messages[-1].acc_type
@@ -216,12 +227,15 @@ class SystemOrchestrator:
# D. 思考
history = await repo.get_chat_history(user_id, limit=10, acc_id=acc_id)
if history and history[-1]['content'] == db_content: history = history[:-1]
if history and history[-1].get('content') == db_content: history = history[:-1]
# 只在“明确又要转接”时注入冷却提示,普通问候/新需求不注入
transfer_intent = self._has_transfer_intent(combined_content)
if is_in_cooldown and transfer_intent:
final_msg.content = f"【系统:当前已向设计师发出转接请求,请勿再次调用转接工具】\n{final_msg.content}"
# 冷却期内:禁止再发转接指令,避免反复转接
if is_in_cooldown:
final_msg.content = (
"【系统:设计师已收到转接通知正在赶来,严禁再次调用转人工工具!"
"客户再问就回'设计师正在看了哈,稍等一下',换着说不要重复】\n"
+ final_msg.content
)
std_res = await self.brain.think_and_reply(final_msg, history=history)

View File

@@ -1,4 +1,6 @@
import os
import re
import hashlib
import logging
from typing import List, Optional, Any, Dict
from pydantic_ai import Agent, RunContext
@@ -85,19 +87,26 @@ class CustomerServiceBrain:
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:
async def think_and_reply(self, msg: StandardMessage, history: Optional[List[dict]] = None) -> StandardResponse:
if history is None:
history = []
try:
# 构造增强上下文
user_content = msg.content
user_content = msg.content or ""
# 客户已发图:在上下文中明确告知 AI避免再回"先发图"
if msg.image_urls:
user_content = f"【系统通知:收到客户 {len(msg.image_urls)} 张图】\n{user_content}"
user_content = (
f"【系统通知:客户已发送 {len(msg.image_urls)} 张图片,不要再让客户发图!"
f"请直接问客户需求(找原图还是修复),然后转接设计师】\n{user_content}"
)
recent_context = ""
if history:
lines = [
f"[{_fmt_time(h.get('timestamp'))}] {('客户' if h['role']=='user' else '')}{h['content']}"
for h in history[-6:]
]
lines = []
for h in history[-6:]:
role = "客户" if h.get("role") == "user" else ""
content = h.get("content", "")
lines.append(f"[{_fmt_time(h.get('timestamp'))}] {role}{content}")
recent_context = "【近期对话回顾】\n" + "\n".join(lines) + "\n----------------\n"
full_input = f"{recent_context}现在的对话:{user_content}"
@@ -132,15 +141,20 @@ class CustomerServiceBrain:
reply_text = found_magic
# ----------------------------------------
# 清理可能的乱码/代码标记
import re
reply_text = re.sub(r'\[\]<\|[^|]+\|>', '', reply_text) # 清理 []<|xxx|>
reply_text = re.sub(r'<\|[^|]+\|>', '', reply_text) # 清理 <|xxx|>
reply_text = re.sub(r'\[Function[^\]]*\]', '', reply_text) # 清理 [FunctionXxx]
reply_text = re.sub(r'<think[^>]*>.*', '', reply_text, flags=re.DOTALL) # 清理 <think_xxx>内部思考泄漏
reply_text = re.sub(r'</?think[^>]*>', '', reply_text) # 清理 think 标签
reply_text = re.sub(r'```[^`]*```', '', reply_text) # 清理代码块
reply_text = re.sub(r'\{["\'][^}]+\}', '', reply_text) # 清理 JSON
# 清理模型泄露的内部标记/乱码(覆盖所有已知格式)
reply_text = re.sub(r'\[\]<\|[^|]+\|>', '', reply_text)
reply_text = re.sub(r'<\|[^|]*\|>', '', reply_text)
reply_text = re.sub(r'\[Function[^\]]*\]', '', reply_text)
reply_text = re.sub(r'\[/?Tool[^\]]*\]', '', reply_text)
reply_text = re.sub(r'</?tool[_\-]?[^>]*>', '', reply_text, flags=re.IGNORECASE)
reply_text = re.sub(r'<think[^>]*>.*?</think[^>]*>', '', reply_text, flags=re.DOTALL)
reply_text = re.sub(r'<think[^>]*>.*', '', reply_text, flags=re.DOTALL)
reply_text = re.sub(r'</?think[^>]*>', '', reply_text)
reply_text = re.sub(r'```[^`]*```', '', reply_text)
reply_text = re.sub(r'\{["\'][^}]+\}', '', reply_text)
reply_text = re.sub(r'AgentRunResult\([^)]*\)', '', reply_text)
reply_text = re.sub(r'\[/?[A-Z][a-zA-Z]*(?:Call|End|Start|Result|Return)[^\]]*\]', '', reply_text)
reply_text = re.sub(r'[\[\]]{2,}', '', reply_text)
reply_text = reply_text.strip()
# 过滤"在呢铁子"

View File

@@ -10,7 +10,7 @@ class StandardMessage(BaseModel):
user_name: str = "" # 发送者昵称
content: str # 消息文本内容
msg_type: int = 0 # 消息类型0 文本, 1 图片, 2 语音等
image_urls: List[str] = [] # 提取出来的图片链接
image_urls: List[str] = Field(default_factory=list) # 提取出来的图片链接
acc_id: str = "" # 商家/店铺账号ID
acc_type: str = "" # 平台类型标识
timestamp: datetime = Field(default_factory=datetime.now)
@@ -27,4 +27,4 @@ class StandardResponse(BaseModel):
should_reply: bool = True # 是否需要发送
need_transfer: bool = False # 是否触发转人工
transfer_group: str = "" # 转人工的分组ID
metadata: dict = {} # 额外元数据(如埋点、调试信息)
metadata: dict = Field(default_factory=dict) # 额外元数据(如埋点、调试信息)

View File

@@ -14,7 +14,8 @@ class SkillManager:
3. 支持热加载(无需重启即可更新 AI 知识)。
"""
def __init__(self, skills_dir: str = "skills"):
self.skills_dir = Path(skills_dir)
given = Path(skills_dir)
self.skills_dir = given if given.is_absolute() else Path(__file__).resolve().parent.parent / skills_dir
self._skill_cache: Dict[str, str] = {}
self.reload_skills()

View File

@@ -1,4 +1,5 @@
import asyncio
import hashlib
import json
import logging
import os
@@ -51,8 +52,6 @@ class QingjianAPIClient:
if not customer_id:
return self.worker_id == 0
import hashlib
# 使用稳定的哈希算法分配客户
hash_val = int(hashlib.md5(str(customer_id).encode("utf-8")).hexdigest(), 16)
return (hash_val % self.worker_count) == self.worker_id