From 3a78eb304af8d94e1446df4aa668835c2e360f93 Mon Sep 17 00:00:00 2001 From: jimi <1847930177@qq.com> Date: Sun, 8 Mar 2026 17:34:56 +0800 Subject: [PATCH] feat: improve routing logs and tuhui integration --- core/pydantic_ai_agent_v2.py | 88 ++++++++++++++++++++--- core/websocket_logger_setup.py | 53 +++++++++++++- scripts/multi_process_launcher.py | 6 +- services/service_tuhui_upload.py | 113 +++++++++++++++++++++--------- 4 files changed, 214 insertions(+), 46 deletions(-) diff --git a/core/pydantic_ai_agent_v2.py b/core/pydantic_ai_agent_v2.py index a08ee13..dbaad48 100644 --- a/core/pydantic_ai_agent_v2.py +++ b/core/pydantic_ai_agent_v2.py @@ -39,6 +39,36 @@ _HISTORY_LEAK_PATTERNS = [ r'(状态|金额|数量)[::].*(状态|金额|数量)[::]', # 状态:xxx 金额:xxx 连续出现 ] +_FIND_ORIGINAL_INTENT_KEYWORDS = ( + "找图", + "找原图", + "原图", + "素材", + "大图", + "源图", +) + +_FIND_ORIGINAL_QUESTION_KEYWORDS = ( + "有吗", + "有没", + "有没有", + "能找吗", + "找得到吗", + "能不能找到", + "能找到吗", +) + +_REPAIR_INTENT_KEYWORDS = ( + "修复", + "高清修复", + "高清", + "清晰", + "清楚", + "变清晰", + "修清楚", + "放大清晰", +) + def _clip(text: str, limit: int = 1200) -> str: if text is None: @@ -93,6 +123,31 @@ def _sanitize_reply_text(reply_text: str) -> str: 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: """ 重构后的单一 Agent 大脑: @@ -147,9 +202,10 @@ class CustomerServiceBrain: "3. **非业务问题**:如果客户问招聘、合作、闲聊等与做图无关的话题,礼貌拒绝。\n" "4. **客户说没有参考图**:直接转人工:'好的,我这就叫设计师帮您找哈'。\n" "5. **客户问尺寸/能否打印/退款**:直接转人工:'这个设计师帮您看下哈'。\n" - "6. **转接时机(严格两步)**:必须同时满足【有图】+【客户明确说了要找原图/修复/具体要求】才能转接。\n" - " 客户只发了图但没说需求 → 必须先问'亲亲这张是找原图还是修复哈?'\n" - " 客户说了'有吗'、'能找吗' → 这不算明确需求,要追问'是要找原图还是高清修复呢?'\n" + "6. **转接时机(严格两步)**:必须同时满足【有图】+【客户明确或可直接判断的需求】才能转接。\n" + " 客户只发了图但没说需求 → 先问'亲亲这张是找原图还是修复哈?'\n" + " 客户说了'有吗'、'能找吗'、'找图'、'找原图'、'有大图吗' → 直接按【找原图】意图处理,不要重复追问。\n" + " 客户说了'修复'、'高清'、'清晰点'、'放大清晰' → 直接按【高清修复】意图处理,不要重复追问。\n" "7. **下线安抚**:只有工具返回ERROR时才能提设计师不在。根据错误码区分:\n" " - ERROR_DESIGNER_NOT_STARTED → 说'还没上班,记下了上班马上处理'(严禁说下班)\n" " - ERROR_DESIGNER_OFFLINE → 说'下班了,需求记下明天回'\n" @@ -200,11 +256,27 @@ class CustomerServiceBrain: image_count = max(len(msg.image_urls), 1) if user_content.startswith("【系统:已收到图片消息"): user_content = "" - user_content = ( - f"【系统通知:客户已发送 {image_count} 张图片,图已收到不要再让客户发图。" - f"你现在必须先问客户:这张是找原图还是高清修复?有什么具体要求?" - f"等客户明确回答后才能转接,严禁跳过问需求直接转接!】\n{user_content}" - ) + inferred_intent = _infer_image_intent(user_content, history) + if inferred_intent == "find_original": + logger.info(f"[Brain] 已根据客户表述推断为找原图意图: user={msg.user_id}") + 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 = "" if history: diff --git a/core/websocket_logger_setup.py b/core/websocket_logger_setup.py index 9ff2aee..321029c 100644 --- a/core/websocket_logger_setup.py +++ b/core/websocket_logger_setup.py @@ -1,6 +1,8 @@ import logging import os +import subprocess from datetime import datetime +from pathlib import Path class _AnsiColorFormatter(logging.Formatter): @@ -56,20 +58,67 @@ class _AnsiColorFormatter(logging.Formatter): 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(): from logging.handlers import RotatingFileHandler from config.config import LOG_DIR, LOG_MAX_BYTES, LOG_BACKUP_COUNT + install_log_record_factory() logger = logging.getLogger("cs_agent") if getattr(logger, "_cs_logger_configured", False): return logger logger.setLevel(logging.INFO) 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")) 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) LOG_DIR.mkdir(exist_ok=True) diff --git a/scripts/multi_process_launcher.py b/scripts/multi_process_launcher.py index 93cab6e..f541617 100644 --- a/scripts/multi_process_launcher.py +++ b/scripts/multi_process_launcher.py @@ -14,9 +14,13 @@ import hashlib # 添加项目路径 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( 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__) diff --git a/services/service_tuhui_upload.py b/services/service_tuhui_upload.py index bd0af50..2c64315 100644 --- a/services/service_tuhui_upload.py +++ b/services/service_tuhui_upload.py @@ -6,55 +6,88 @@ import os import httpx import logging +import mimetypes from pathlib import Path from typing import Optional, Tuple from dotenv import load_dotenv 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_PASSWORD = os.getenv("TUHUI_PASSWORD", "zuowei1216") # 图绘账号密码 TUHUI_DEFAULT_PRICE = int(os.getenv("TUHUI_DEFAULT_PRICE", "20")) # 默认定价(元) +TUHUI_DEFAULT_CATEGORY = os.getenv("TUHUI_DEFAULT_CATEGORY", "设计素材") class TuhuiUploadService: """图绘平台上传服务""" 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.password = TUHUI_PASSWORD self.default_price = TUHUI_DEFAULT_PRICE self.access_token = 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: """登录图绘平台获取 token""" - try: - async with httpx.AsyncClient() as client: - response = await client.post( - f"{self.base_url}/api/auth/login", - json={ - "phone": self.phone, - "password": self.password - }, - timeout=10.0 - ) - - if response.status_code == 200: - data = response.json() - self.access_token = data.get("access_token") - user = data.get("user", {}) - self.user_id = user.get("id") - logger.info(f"图绘平台登录成功,用户 ID: {self.user_id}") - return True - else: - logger.error(f"图绘平台登录失败:{response.status_code} {response.text}") - return False - except Exception as e: - logger.error(f"图绘平台登录异常:{e}") - return False + last_error = "" + for base_url in self.base_urls: + try: + async with httpx.AsyncClient() as client: + response = await client.post( + self._build_api_url(base_url, "/auth/login"), + json={ + "phone": self.phone, + "password": self.password + }, + timeout=10.0 + ) + + if response.status_code == 200: + data = response.json() + self.access_token = data.get("access_token") + user = data.get("user", {}) + self.user_id = user.get("id") + self.base_url = base_url + logger.info(f"图绘平台登录成功,用户 ID: {self.user_id},base={self.base_url}") + return True + + last_error = f"{response.status_code} {response.text}" + 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( self, @@ -62,7 +95,8 @@ class TuhuiUploadService: title: str, description: str = "", price: Optional[int] = None, - category: str = "高清修复" + category: str = TUHUI_DEFAULT_CATEGORY, + tags: str = "", ) -> Tuple[bool, str, int]: """ 上传图片到图绘平台 @@ -94,17 +128,20 @@ class TuhuiUploadService: logger.error(f"图片文件不存在:{image_path}") return False, "文件不存在", 0 + filename, mime_type = self._guess_file_meta(image_path) with open(image_path, "rb") as f: files = { - "original_image": ("image.jpg", f, "image/jpeg") + "file": (filename, f, mime_type) } data = { "title": title, "description": description, "price": str(price), - "category": category + "category": category, } + if tags: + data["tags"] = tags headers = { "Authorization": f"Bearer {self.access_token}" @@ -112,7 +149,7 @@ class TuhuiUploadService: async with httpx.AsyncClient() as client: response = await client.post( - f"{self.base_url}/api/works", + self._api_url("/upload"), files=files, data=data, headers=headers, @@ -120,9 +157,13 @@ class TuhuiUploadService: ) if response.status_code in [200, 201]: - work_data = response.json() - work_id = work_data.get("id") - image_url = work_data.get("original_image", "") + payload = response.json() + if not payload.get("success", False): + 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}") return True, image_url, work_id else: @@ -160,7 +201,9 @@ async def upload_to_tuhui( image_path: str, title: str, description: str = "", - price: int = 20 + price: int = 20, + category: str = TUHUI_DEFAULT_CATEGORY, + tags: str = "", ) -> Tuple[bool, str, int]: """ 便捷函数:上传图片到图绘平台 @@ -169,4 +212,4 @@ async def upload_to_tuhui( (success, image_url, work_id) """ 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)