feat: improve routing logs and tuhui integration

This commit is contained in:
2026-03-08 17:34:56 +08:00
parent 39de916b89
commit 3a78eb304a
4 changed files with 214 additions and 46 deletions

View File

@@ -39,6 +39,36 @@ _HISTORY_LEAK_PATTERNS = [
r'(状态|金额|数量)[:].*(状态|金额|数量)[:]', # 状态:xxx 金额:xxx 连续出现 r'(状态|金额|数量)[:].*(状态|金额|数量)[:]', # 状态:xxx 金额:xxx 连续出现
] ]
_FIND_ORIGINAL_INTENT_KEYWORDS = (
"找图",
"找原图",
"原图",
"素材",
"大图",
"源图",
)
_FIND_ORIGINAL_QUESTION_KEYWORDS = (
"有吗",
"有没",
"有没有",
"能找吗",
"找得到吗",
"能不能找到",
"能找到吗",
)
_REPAIR_INTENT_KEYWORDS = (
"修复",
"高清修复",
"高清",
"清晰",
"清楚",
"变清晰",
"修清楚",
"放大清晰",
)
def _clip(text: str, limit: int = 1200) -> str: def _clip(text: str, limit: int = 1200) -> str:
if text is None: if text is None:
@@ -93,6 +123,31 @@ def _sanitize_reply_text(reply_text: str) -> str:
return text.strip() return text.strip()
def _normalize_text(text: Any) -> str:
return str(text or "").strip().lower()
def _infer_image_intent(current_text: str, history: Optional[List[dict]] = None) -> str:
text = _normalize_text(current_text)
recent_user_text = "\n".join(
_normalize_text(h.get("content", ""))
for h in (history or [])[-6:]
if h.get("role") == "user"
)
combined = f"{recent_user_text}\n{text}"
if any(k in combined for k in _REPAIR_INTENT_KEYWORDS):
return "repair"
if any(k in combined for k in _FIND_ORIGINAL_INTENT_KEYWORDS):
return "find_original"
if any(k in text for k in _FIND_ORIGINAL_QUESTION_KEYWORDS):
return "find_original"
return ""
class CustomerServiceBrain: class CustomerServiceBrain:
""" """
重构后的单一 Agent 大脑: 重构后的单一 Agent 大脑:
@@ -147,9 +202,10 @@ class CustomerServiceBrain:
"3. **非业务问题**:如果客户问招聘、合作、闲聊等与做图无关的话题,礼貌拒绝。\n" "3. **非业务问题**:如果客户问招聘、合作、闲聊等与做图无关的话题,礼貌拒绝。\n"
"4. **客户说没有参考图**:直接转人工:'好的,我这就叫设计师帮您找哈'\n" "4. **客户说没有参考图**:直接转人工:'好的,我这就叫设计师帮您找哈'\n"
"5. **客户问尺寸/能否打印/退款**:直接转人工:'这个设计师帮您看下哈'\n" "5. **客户问尺寸/能否打印/退款**:直接转人工:'这个设计师帮您看下哈'\n"
"6. **转接时机(严格两步)**:必须同时满足【有图】+【客户明确说了要找原图/修复/具体要求】才能转接。\n" "6. **转接时机(严格两步)**:必须同时满足【有图】+【客户明确或可直接判断的需求】才能转接。\n"
" 客户只发了图但没说需求 → 必须先问'亲亲这张是找原图还是修复哈?'\n" " 客户只发了图但没说需求 → 先问'亲亲这张是找原图还是修复哈?'\n"
" 客户说了'有吗''能找吗' → 这不算明确需求,要追问'是要找原图还是高清修复呢?'\n" " 客户说了'有吗''能找吗''找图''找原图''有大图吗' → 直接按【找原图】意图处理,不要重复追问。\n"
" 客户说了'修复''高清''清晰点''放大清晰' → 直接按【高清修复】意图处理,不要重复追问。\n"
"7. **下线安抚**只有工具返回ERROR时才能提设计师不在。根据错误码区分\n" "7. **下线安抚**只有工具返回ERROR时才能提设计师不在。根据错误码区分\n"
" - ERROR_DESIGNER_NOT_STARTED → 说'还没上班,记下了上班马上处理'(严禁说下班)\n" " - ERROR_DESIGNER_NOT_STARTED → 说'还没上班,记下了上班马上处理'(严禁说下班)\n"
" - ERROR_DESIGNER_OFFLINE → 说'下班了,需求记下明天回'\n" " - ERROR_DESIGNER_OFFLINE → 说'下班了,需求记下明天回'\n"
@@ -200,11 +256,27 @@ class CustomerServiceBrain:
image_count = max(len(msg.image_urls), 1) image_count = max(len(msg.image_urls), 1)
if user_content.startswith("【系统:已收到图片消息"): if user_content.startswith("【系统:已收到图片消息"):
user_content = "" user_content = ""
user_content = ( inferred_intent = _infer_image_intent(user_content, history)
f"【系统通知:客户已发送 {image_count} 张图片,图已收到不要再让客户发图。" if inferred_intent == "find_original":
f"你现在必须先问客户:这张是找原图还是高清修复?有什么具体要求?" logger.info(f"[Brain] 已根据客户表述推断为找原图意图: user={msg.user_id}")
f"等客户明确回答后才能转接,严禁跳过问需求直接转接!】\n{user_content}" user_content = (
) f"【系统通知:客户已发送 {image_count} 张图片,图已收到不要再让客户发图。"
f"系统判断客户当前意图是【找原图】;像'有吗''能找吗''找图'都算找原图意图。"
f"不要再追问'找原图还是高清修复',直接按找原图流程继续;如果信息足够就直接转接。】\n{user_content}"
)
elif inferred_intent == "repair":
logger.info(f"[Brain] 已根据客户表述推断为高清修复意图: user={msg.user_id}")
user_content = (
f"【系统通知:客户已发送 {image_count} 张图片,图已收到不要再让客户发图。"
f"系统判断客户当前意图是【高清修复】;像'修复''高清''清晰点'都算修复意图。"
f"不要再追问'找原图还是高清修复',直接按高清修复流程继续;如果信息足够就直接转接。】\n{user_content}"
)
else:
user_content = (
f"【系统通知:客户已发送 {image_count} 张图片,图已收到不要再让客户发图。"
f"你现在必须先问客户:这张是找原图还是高清修复?有什么具体要求?"
f"等客户明确回答后才能转接,严禁跳过问需求直接转接!】\n{user_content}"
)
recent_context = "" recent_context = ""
if history: if history:

View File

@@ -1,6 +1,8 @@
import logging import logging
import os import os
import subprocess
from datetime import datetime from datetime import datetime
from pathlib import Path
class _AnsiColorFormatter(logging.Formatter): class _AnsiColorFormatter(logging.Formatter):
@@ -56,20 +58,67 @@ class _AnsiColorFormatter(logging.Formatter):
return f"{color}{msg}{self.RESET}" return f"{color}{msg}{self.RESET}"
_APP_VERSION = None
_LOG_RECORD_FACTORY_INSTALLED = False
def get_app_log_version() -> str:
global _APP_VERSION
if _APP_VERSION:
return _APP_VERSION
env_version = str(os.getenv("APP_VERSION", "")).strip()
if env_version:
_APP_VERSION = env_version
return _APP_VERSION
try:
repo_root = Path(__file__).resolve().parent.parent
git_version = subprocess.check_output(
["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"],
stderr=subprocess.DEVNULL,
text=True,
).strip()
except Exception:
git_version = ""
_APP_VERSION = git_version or "dev"
os.environ.setdefault("APP_VERSION", _APP_VERSION)
return _APP_VERSION
def install_log_record_factory():
global _LOG_RECORD_FACTORY_INSTALLED
if _LOG_RECORD_FACTORY_INSTALLED:
return
version = get_app_log_version()
old_factory = logging.getLogRecordFactory()
def record_factory(*args, **kwargs):
record = old_factory(*args, **kwargs)
record.app_version = getattr(record, "app_version", version)
return record
logging.setLogRecordFactory(record_factory)
_LOG_RECORD_FACTORY_INSTALLED = True
def setup_logger(): def setup_logger():
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from config.config import LOG_DIR, LOG_MAX_BYTES, LOG_BACKUP_COUNT from config.config import LOG_DIR, LOG_MAX_BYTES, LOG_BACKUP_COUNT
install_log_record_factory()
logger = logging.getLogger("cs_agent") logger = logging.getLogger("cs_agent")
if getattr(logger, "_cs_logger_configured", False): if getattr(logger, "_cs_logger_configured", False):
return logger return logger
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False logger.propagate = False
fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S") fmt = logging.Formatter("[v%(app_version)s][%(asctime)s] %(message)s", datefmt="%H:%M:%S")
use_color = (os.getenv("LOG_COLOR", "1").lower() in ("1", "true", "yes")) and not bool(os.getenv("NO_COLOR")) use_color = (os.getenv("LOG_COLOR", "1").lower() in ("1", "true", "yes")) and not bool(os.getenv("NO_COLOR"))
ch = logging.StreamHandler() ch = logging.StreamHandler()
ch.setFormatter(_AnsiColorFormatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S", use_color=use_color)) ch.setFormatter(_AnsiColorFormatter("[v%(app_version)s][%(asctime)s] %(message)s", datefmt="%H:%M:%S", use_color=use_color))
logger.addHandler(ch) logger.addHandler(ch)
LOG_DIR.mkdir(exist_ok=True) LOG_DIR.mkdir(exist_ok=True)

View File

@@ -14,9 +14,13 @@ import hashlib
# 添加项目路径 # 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.websocket_logger_setup import install_log_record_factory
install_log_record_factory()
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='[%(asctime)s] %(levelname)s: %(message)s' format='[v%(app_version)s][%(asctime)s] %(levelname)s: %(message)s'
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -6,55 +6,88 @@
import os import os
import httpx import httpx
import logging import logging
import mimetypes
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
from dotenv import load_dotenv from dotenv import load_dotenv
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
load_dotenv()
# 图绘平台配置 # 图绘平台配置
TUHUI_BASE_URL = os.getenv("TUHUI_BASE_URL", "http://127.0.0.1:8002") TUHUI_BASE_URL = os.getenv("TUHUI_BASE_URL", "https://tuhui.cloud")
TUHUI_FALLBACK_BASE_URL = "https://tuhui.cloud"
TUHUI_PHONE = os.getenv("TUHUI_PHONE", "17520145271") # 图绘账号手机号 TUHUI_PHONE = os.getenv("TUHUI_PHONE", "17520145271") # 图绘账号手机号
TUHUI_PASSWORD = os.getenv("TUHUI_PASSWORD", "zuowei1216") # 图绘账号密码 TUHUI_PASSWORD = os.getenv("TUHUI_PASSWORD", "zuowei1216") # 图绘账号密码
TUHUI_DEFAULT_PRICE = int(os.getenv("TUHUI_DEFAULT_PRICE", "20")) # 默认定价(元) TUHUI_DEFAULT_PRICE = int(os.getenv("TUHUI_DEFAULT_PRICE", "20")) # 默认定价(元)
TUHUI_DEFAULT_CATEGORY = os.getenv("TUHUI_DEFAULT_CATEGORY", "设计素材")
class TuhuiUploadService: class TuhuiUploadService:
"""图绘平台上传服务""" """图绘平台上传服务"""
def __init__(self): def __init__(self):
self.base_url = TUHUI_BASE_URL self.base_url = TUHUI_BASE_URL.rstrip("/")
self.base_urls = []
for candidate in (TUHUI_FALLBACK_BASE_URL.rstrip("/"), self.base_url):
if candidate and candidate not in self.base_urls:
self.base_urls.append(candidate)
if self.base_urls:
self.base_url = self.base_urls[0]
self.phone = TUHUI_PHONE self.phone = TUHUI_PHONE
self.password = TUHUI_PASSWORD self.password = TUHUI_PASSWORD
self.default_price = TUHUI_DEFAULT_PRICE self.default_price = TUHUI_DEFAULT_PRICE
self.access_token = None self.access_token = None
self.user_id = None self.user_id = None
@staticmethod
def _build_api_url(base_url: str, path: str) -> str:
normalized = path if path.startswith("/") else f"/{path}"
if base_url.endswith("/api"):
return f"{base_url}{normalized}"
return f"{base_url}/api{normalized}"
def _api_url(self, path: str) -> str:
return self._build_api_url(self.base_url, path)
@staticmethod
def _guess_file_meta(image_path: str) -> tuple[str, str]:
path = Path(image_path)
filename = path.name or "image.jpg"
mime_type, _ = mimetypes.guess_type(filename)
return filename, mime_type or "application/octet-stream"
async def login(self) -> bool: async def login(self) -> bool:
"""登录图绘平台获取 token""" """登录图绘平台获取 token"""
try: last_error = ""
async with httpx.AsyncClient() as client: for base_url in self.base_urls:
response = await client.post( try:
f"{self.base_url}/api/auth/login", async with httpx.AsyncClient() as client:
json={ response = await client.post(
"phone": self.phone, self._build_api_url(base_url, "/auth/login"),
"password": self.password json={
}, "phone": self.phone,
timeout=10.0 "password": self.password
) },
timeout=10.0
if response.status_code == 200: )
data = response.json()
self.access_token = data.get("access_token") if response.status_code == 200:
user = data.get("user", {}) data = response.json()
self.user_id = user.get("id") self.access_token = data.get("access_token")
logger.info(f"图绘平台登录成功,用户 ID: {self.user_id}") user = data.get("user", {})
return True self.user_id = user.get("id")
else: self.base_url = base_url
logger.error(f"图绘平台登录失败:{response.status_code} {response.text}") logger.info(f"图绘平台登录成功,用户 ID: {self.user_id}base={self.base_url}")
return False return True
except Exception as e:
logger.error(f"图绘平台登录异常:{e}") last_error = f"{response.status_code} {response.text}"
return False logger.warning(f"图绘平台登录失败base={base_url}{last_error}")
except Exception as e:
last_error = str(e)
logger.warning(f"图绘平台登录异常base={base_url}{type(e).__name__}: {e!r}")
logger.error(f"图绘平台登录失败:{last_error}")
return False
async def upload_image( async def upload_image(
self, self,
@@ -62,7 +95,8 @@ class TuhuiUploadService:
title: str, title: str,
description: str = "", description: str = "",
price: Optional[int] = None, price: Optional[int] = None,
category: str = "高清修复" category: str = TUHUI_DEFAULT_CATEGORY,
tags: str = "",
) -> Tuple[bool, str, int]: ) -> Tuple[bool, str, int]:
""" """
上传图片到图绘平台 上传图片到图绘平台
@@ -94,17 +128,20 @@ class TuhuiUploadService:
logger.error(f"图片文件不存在:{image_path}") logger.error(f"图片文件不存在:{image_path}")
return False, "文件不存在", 0 return False, "文件不存在", 0
filename, mime_type = self._guess_file_meta(image_path)
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
files = { files = {
"original_image": ("image.jpg", f, "image/jpeg") "file": (filename, f, mime_type)
} }
data = { data = {
"title": title, "title": title,
"description": description, "description": description,
"price": str(price), "price": str(price),
"category": category "category": category,
} }
if tags:
data["tags"] = tags
headers = { headers = {
"Authorization": f"Bearer {self.access_token}" "Authorization": f"Bearer {self.access_token}"
@@ -112,7 +149,7 @@ class TuhuiUploadService:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
f"{self.base_url}/api/works", self._api_url("/upload"),
files=files, files=files,
data=data, data=data,
headers=headers, headers=headers,
@@ -120,9 +157,13 @@ class TuhuiUploadService:
) )
if response.status_code in [200, 201]: if response.status_code in [200, 201]:
work_data = response.json() payload = response.json()
work_id = work_data.get("id") if not payload.get("success", False):
image_url = work_data.get("original_image", "") logger.error(f"图绘平台上传返回失败:{payload}")
return False, payload.get("message", "上传失败"), 0
work_id = int(payload.get("work_id") or payload.get("work", {}).get("id") or 0)
image_url = str(payload.get("image_url") or payload.get("work", {}).get("original_image") or "")
logger.info(f"图绘平台上传成功,作品 ID: {work_id}, URL: {image_url}") logger.info(f"图绘平台上传成功,作品 ID: {work_id}, URL: {image_url}")
return True, image_url, work_id return True, image_url, work_id
else: else:
@@ -160,7 +201,9 @@ async def upload_to_tuhui(
image_path: str, image_path: str,
title: str, title: str,
description: str = "", description: str = "",
price: int = 20 price: int = 20,
category: str = TUHUI_DEFAULT_CATEGORY,
tags: str = "",
) -> Tuple[bool, str, int]: ) -> Tuple[bool, str, int]:
""" """
便捷函数:上传图片到图绘平台 便捷函数:上传图片到图绘平台
@@ -169,4 +212,4 @@ async def upload_to_tuhui(
(success, image_url, work_id) (success, image_url, work_id)
""" """
service = get_tuhui_service() service = get_tuhui_service()
return await service.upload_image(image_path, title, description, price) return await service.upload_image(image_path, title, description, price, category, tags)