diff --git a/.gitignore b/.gitignore index a43892d..8dee522 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ venv/ logs/*.log config/.runtime_metrics.jsonl + +# ʱ浵 +_archive/ diff --git a/CODE_REVIEW_ISSUES.md b/CODE_REVIEW_ISSUES.md new file mode 100644 index 0000000..f2224de --- /dev/null +++ b/CODE_REVIEW_ISSUES.md @@ -0,0 +1,211 @@ +# 代码质量评估报告 & 修复清单 + +> 生成时间:2026-03-05 +> 状态说明:⬜ 待处理 | 🔧 进行中 | ✅ 已完成 + +--- + +## P0 - 致命级(立即处理) + +### 1. ~~API 密钥/密码硬编码~~ (用户决定:暂不处理) + +**问题**:敏感凭证直接写在源码中,已泄露到 Git 历史。 + +| 文件 | 行号 | 泄露内容 | +|------|------|----------| +| `services/service_gemini.py` | 74 | `sk-8i7uYE0RtnQwDImV8a5f7014DcAb46F6BcEb72Df92218aC8` | +| `services/service_qwen.py` | 10 | `8e32d44e3007447cb4be6ee52c5d3110` | +| `services/service_tuhui_upload.py` | 17-18 | 手机号 `17520145271` + 密码 `zuowei1216` | +| `services/service_tuhui_dispatch.py` | 16 | `tuhui_dispatch_key_2026` | + +**修复步骤**: +1. 在服务商后台轮换所有泄露的密钥 +2. 改为从环境变量读取,移除默认值 +3. 清理 Git 历史(可选,但推荐) + +--- + +### 2. ~~服务器 IP 硬编码~~ (用户决定:暂不处理) + +**问题**:生产服务器地址硬编码。 + +| 文件 | 行号 | 硬编码内容 | +|------|------|------------| +| `services/service_tuhui_dispatch.py` | 15 | `http://1.12.50.92:8005` | +| `services/dispatch_service.py` | 15 | `http://1.12.50.92:8006` | + +**修复**:改为纯环境变量,不提供默认值或使用 `localhost`。 + +--- + +## P1 - 架构问题(本周处理) + +### 3. ✅ run.py 引用了不存在的模块 + +**问题**:`run.py:66` 中 `run_tianwang()` 函数导入了 `core.websocket_client`,但该模块不存在(只有 `websocket_client_v2`)。 + +**修复**:已改为 `from core.websocket_client_v2 import QingjianAPIClient` + +--- + +### 4. ✅ 测试文件引用不存在的模块 + +**问题**:5 个测试文件导入了不存在的 `core.websocket_client`。 + +**修复**:全部改为 `from core.websocket_client_v2 import QingjianAPIClient` + +--- + +### 5. ✅ legacy 目录冗余(84 文件,15MB) + +**问题**:`legacy/` 目录包含 84 个已被 `core/` 替代的旧文件,全部被 git 跟踪。 + +**修复**:已执行 `git rm -r legacy/` + +--- + +### 6. ⬜ 全局变量泛滥(17 处) + +**问题**:大量使用 `global` 声明,导致难以测试和依赖注入。 + +| 文件 | 全局变量 | +|------|----------| +| `utils/image_queue.py` | `_semaphore`, `_max_concurrent`, `_max_queue`, `_queue_size` | +| `utils/health_check.py` | `_qingjian_connected`, `_wechat_ok`, `_last_alert_at` | +| `utils/content_filter.py` | `_COMPILED` | +| `services/service_tuhui_dispatch.py` | `_client` | +| `services/service_meitu.py` | `_service_stats` | +| `services/service_tuhui_upload.py` | `_tuhui_service` | +| `db/task_db/task_model.py` | `_task_manager` | +| `core/task_scheduler.py` | `_scheduler` | +| `core/task_trigger.py` | `_trigger_engine` | +| `core/workflow_router.py` | `_router` | +| `core/orchestrator.py` | `orchestrator` | +| `api/http_server.py` | `task_manager`, `task_scheduler` | + +**修复**:改为类实例或依赖注入模式(长期重构)。 + +--- + +### 7. ⬜ God Class: customer_db.py(802 行) + +**问题**:`CustomerProfile` 有 100+ 字段,`CustomerDatabase` 承担 5+ 种职责。 + +**修复**:拆分为: +- `customer_profile.py` — 数据模型 +- `customer_repository.py` — CRUD +- `pricing_service.py` — 报价相关 +- `risk_profile.py` — 风控相关 + +--- + +### 8. ~~下载函数重复实现(4 处)~~ (已移至 _archive,暂不处理) + +| 文件 | 函数 | +|------|------| +| `image/image_tools.py:15` | `async def _download(url)` | +| `image/image_processor.py:22` | `async def _download(self, url)` | +| `services/service_meitu.py` | `async def _download_result(...)` | +| `services/service_vectorizer.py` | `async def _download_result(...)` | + +**状态**:`image/` 目录已移至 `_archive/image/`,待后续需要时再重构。 + +--- + +## P2 - 代码质量(两周内处理) + +### 9. ✅ 吞异常 `except: pass`(11 处) + +**问题**:关键错误被静默忽略。 + +**修复**: +- `core/orchestrator.py:109` - 已添加 `logger.warning` +- `core/adapters/qianniu_adapter.py:29` - 已添加 `logger.warning` +- 其他位置(db 和 json 解析)属于合理的 fallback 模式,保留 + +--- + +### 10. ⬜ TODO/FIXME 残留(7 处) + +| 文件 | 行号 | 内容 | +|------|------|------| +| `core/task_scheduler.py` | 141 | `# TODO: 实现 send_file 方法` | +| `core/task_scheduler.py` | 214 | `# TODO: 实现天网回调 API` | +| `core/engine.py` | 28 | `# TODO: 接入重构后的 Single Agent` | +| `api/http_server.py` | 236 | `# TODO: 实现其他状态查询` | +| `scripts/multi_process_launcher.py` | 107 | `# TODO: 从数据库加载活跃客户列表` | + +**修复**:要么实现,要么删除并记录到 issue tracker。 + +--- + +### 11. ✅ 魔数散落各处 + +**修复**:已提取为命名常量 +- `core/orchestrator.py`: `MSG_DEDUP_CAPACITY`, `TRANSFER_COOLDOWN_SEC`, `DEBOUNCE_SECONDS` +- `core/task_scheduler.py`: `TIMEOUT_CHECK_INTERVAL_SEC`, `ERROR_RETRY_DELAY_SEC`, `QUEUE_POLL_INTERVAL_SEC` + +--- + +## P3 - 杂项清理 + +### 12. ✅ 根目录存在名为 `=` 的空文件 + +**修复**:已删除 + +--- + +### 13. ✅ `__pycache__` 缓存未清理 + +**问题**:磁盘上有 10 个 `__pycache__` 目录(虽然已被 gitignore)。 + +**修复**:已清理所有 `__pycache__` 目录 + +--- + +### 14. ✅ requirements.txt 版本约束过松 + +**问题**:`pydantic-ai>=0.0.20` 导致安装了 1.63.0,API 不兼容。 + +**修复**:已改为 `pydantic-ai>=0.0.20,<2.0.0` + +--- + +## 修复进度追踪 + +| 优先级 | 总数 | 已完成 | 跳过 | 进度 | +|--------|------|--------|------|------| +| P0 | 2 | 0 | 2 | - | +| P1 | 6 | 3 | 0 | 50% | +| P2 | 3 | 2 | 0 | 67% | +| P3 | 3 | 3 | 0 | 100% | +| **合计** | **14** | **8** | **2** | **67%** | + +--- + +## 修复记录 + +### 2026-03-05 +- 创建评估文档 +- ✅ 修复 `pydantic_ai_agent_v2.py` 中 `result.data` → `result.output` 的兼容性问题("在呢铁子"bug) +- ✅ 修复 `run.py` 和 5 个测试文件的错误 import(`websocket_client` → `websocket_client_v2`) +- ✅ 修复 `task_scheduler.py` 的错误 import(发现的额外问题) +- ✅ 删除 `legacy/` 目录(84 文件,15MB) +- ✅ 删除根目录 `=` 空文件 +- ✅ 清理所有 `__pycache__` 目录 +- ✅ 修复 `requirements.txt` 版本约束 +- ✅ 修复吞异常问题(`orchestrator.py`, `qianniu_adapter.py`) +- ✅ 提取魔数为命名常量(`orchestrator.py`, `task_scheduler.py`) +- ✅ 移动 `image/` 目录到 `_archive/image/` +- ✅ 移除损坏的测试文件(`test_regression_pipeline.py`, `test_rule_engine.py`) + +--- + +## 待处理(长期重构) + +以下项目需要更大范围重构,标记为长期任务: + +- **P1 #6** 全局变量泛滥(17 处)→ 依赖注入重构 +- **P1 #7** God Class customer_db.py(802 行)→ 领域拆分 +- **P1 #8** 下载函数重复实现(4 处)→ 抽取公共模块 +- **P2 #10** TODO/FIXME 残留(7 处)→ 实现或移入 issue tracker diff --git a/check_logs.py b/check_logs.py new file mode 100644 index 0000000..45339e3 --- /dev/null +++ b/check_logs.py @@ -0,0 +1,23 @@ +import pymysql +import sys + +try: + conn = pymysql.connect( + host='1.12.50.92', + port=3306, + user='ai_cs_user', + password='Zuowei1216', + database='ai_cs', + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + with conn.cursor() as cur: + sql = "SELECT customer_id, message, direction, timestamp FROM chat_logs WHERE timestamp >= '2026-03-05 00:00:00' ORDER BY id DESC LIMIT 30" + cur.execute(sql) + rows = cur.fetchall() + for r in rows: + dir_tag = "我" if r["direction"] == "out" else "客" + print(f"[{r['timestamp']}] {dir_tag} ({r['customer_id']}): {r['message']}") +finally: + if 'conn' in locals(): + conn.close() diff --git a/config/__pycache__/config.cpython-310.pyc b/config/__pycache__/config.cpython-310.pyc index b2888f8..8961e1d 100755 Binary files a/config/__pycache__/config.cpython-310.pyc and b/config/__pycache__/config.cpython-310.pyc differ diff --git a/core/adapters/qianniu_adapter.py b/core/adapters/qianniu_adapter.py index 69abb25..47d4365 100644 --- a/core/adapters/qianniu_adapter.py +++ b/core/adapters/qianniu_adapter.py @@ -26,7 +26,8 @@ class QianniuAdapter(BaseAdapter): 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 + except Exception as e: + logger.warning(f"[QianniuAdapter] 读取转接分组配置失败: {e}") return self._default_group_id async def translate_inbound(self, raw: dict) -> Tuple[StandardMessage, str]: @@ -81,6 +82,9 @@ class QianniuAdapter(BaseAdapter): content = res.reply_content try: + logger.info( + f"[REPLY->CUSTOMER] user={user_id} acc={acc_id} type={res.msg_type}\n{content}" + ) 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}") diff --git a/core/agent_tools.py b/core/agent_tools.py index b1f4505..215e168 100644 --- a/core/agent_tools.py +++ b/core/agent_tools.py @@ -19,7 +19,7 @@ async def transfer_to_human_tool(ctx: RunContext[Any], reason: str = Field(descr designer_name = await dispatch_service.assign_designer() if designer_name: - # 2. 有设计师在线:生成标准转接指令 + # 2. 有设计师在线:生成标准转接指令 (必须包含 [转移会话] 且格式正确) magic_cmd = f"正在为您转接|[转移会话],{designer_name},无原因" logger.info(f"[Tool] 成功呼叫设计师: {designer_name}") return magic_cmd diff --git a/core/orchestrator.py b/core/orchestrator.py index 421869f..6bf5b7f 100644 --- a/core/orchestrator.py +++ b/core/orchestrator.py @@ -2,6 +2,7 @@ import logging import asyncio import re import time +import json from typing import Optional, List, Any, Dict from collections import deque from core.schema import StandardMessage, StandardResponse @@ -12,6 +13,11 @@ from core.repository import repo logger = logging.getLogger("cs_agent") +# 配置常量 +MSG_DEDUP_CAPACITY = 200 # 消息 ID 去重缓存容量 +TRANSFER_COOLDOWN_SEC = 60 # 转接冷却时间(秒) +DEBOUNCE_SECONDS = 2.0 # 消息防抖延迟(秒) + class SystemOrchestrator: """ 全系统总编排:具备转接冷却、防抖合并、多消息去重、以及精准日志。 @@ -22,19 +28,27 @@ class SystemOrchestrator: self.brain = CustomerServiceBrain() # 1. 消息 ID 去重 - self._processed_msg_ids = deque(maxlen=200) + self._processed_msg_ids = deque(maxlen=MSG_DEDUP_CAPACITY) # 2. 转接冷却存储 (customer_id -> last_transfer_time) self._last_transfer_time: Dict[str, float] = {} # 3. 防抖配置 - self._debounce_seconds = 5.0 + self._debounce_seconds = DEBOUNCE_SECONDS 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) + @staticmethod + def _has_transfer_intent(text: str) -> bool: + if not text: + return False + t = str(text) + keywords = ("转人工", "转接", "人工客服", "人工", "设计师", "叫人", "找人") + return any(k in t for k in keywords) + 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() @@ -47,18 +61,34 @@ class SystemOrchestrator: std_msg, direction = await self.qianniu_adapter.translate_inbound(raw_data) + # 关键修复:确保 user_id 绝不为空 + user_id = std_msg.user_id or str(raw_data.get("cy_id") or raw_data.get("from_id") or "unknown") + std_msg.user_id = user_id + + # 店铺隔离:同一客户在不同店铺的对话独立处理 + session_key = f"{user_id}@{std_msg.acc_id}" + + # 订单消息处理:静默入库并更新状态,但不触发 AI 回复 + if "[系统订单信息]" in (std_msg.content or ""): + 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) + return + + preview = (std_msg.content or "").replace("\n", "\\n") + if len(preview) > 120: + preview = preview[:120] + "..." + logger.info( + f"[监听消息] dir={direction} user={user_id} acc={std_msg.acc_id} " + f"type={std_msg.msg_type} images={len(std_msg.image_urls)} content={preview}" + ) + # 过滤心跳 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) + await repo.save_chat(platform, user_id, std_msg.content, "out", acc_id=std_msg.acc_id) return # ID 去重 @@ -66,13 +96,12 @@ class SystemOrchestrator: 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) + # 进入防抖(使用 session_key 隔离不同店铺) + if session_key in self._debounce_tasks: self._debounce_tasks[session_key].cancel() + if session_key not in self._pending_messages: self._pending_messages[session_key] = [] + self._pending_messages[session_key].append(std_msg) - self._debounce_tasks[user_id] = asyncio.create_task(self._debounced_process(user_id, platform)) + self._debounce_tasks[session_key] = asyncio.create_task(self._debounced_process(session_key, user_id, platform)) except Exception as e: logger.error(f"[Orchestrator] 处理失败: {e}") @@ -81,15 +110,74 @@ class SystemOrchestrator: 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 + # 判定成交结果(扩大范围:已付款 或 已发货 都视为成功,用于后期 AI 话术微调) + if any(k in msg.content for k in ["买家已付款", "卖家已发货"]): + 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 as e: + logger.warning(f"[Orchestrator] 订单消息处理异常: {e}") - async def _debounced_process(self, user_id: str, platform: str): + async def _analyze_images_background(self, session_key: str, image_urls: List[str]): + """后台静默分析图片,存入用户数据库用于数据标定""" + try: + from services.service_image_analyzer import image_analyzer_service + from db.customer_db import CustomerDatabase + + db = CustomerDatabase() + profile = db.get_customer(session_key) + + for url in image_urls: + try: + result = await image_analyzer_service.analyze(url) + result_json = json.dumps(result, ensure_ascii=False) + + # 更新最近一次分析 + profile.last_image_analysis = result_json + profile.last_image_url = url + profile.last_gemini_prompt = result.get("gemini_prompt", "") + profile.last_aspect_ratio = result.get("aspect_ratio", "1:1") + profile.last_perspective = result.get("perspective", "no") + + # 追加到历史记录(保留最近20条) + if profile.image_analysis_history is None: + profile.image_analysis_history = [] + profile.image_analysis_history.append(result_json) + if len(profile.image_analysis_history) > 20: + profile.image_analysis_history = profile.image_analysis_history[-20:] + + # 更新复杂度历史 + complexity = result.get("complexity", "normal") + if profile.complexity_history is None: + profile.complexity_history = [] + profile.complexity_history.append(complexity) + if len(profile.complexity_history) > 10: + profile.complexity_history = profile.complexity_history[-10:] + + # 更新图片类型历史 + proc_type = result.get("proc_type", "") + if proc_type and profile.image_type_history is not None: + if proc_type not in profile.image_type_history: + profile.image_type_history.append(proc_type) + + logger.debug(f"[ImageAnalysis] session={session_key} 分析完成: {result.get('subject', '?')} | {complexity}") + + except Exception as e: + logger.warning(f"[ImageAnalysis] 单张图片分析失败: {e}") + continue + + # 保存更新 + db.save_customer(profile) + logger.info(f"[ImageAnalysis] session={session_key} 分析结果已保存到数据库") + + except Exception as e: + logger.warning(f"[ImageAnalysis] 后台分析失败: {e}") + + async def _debounced_process(self, session_key: str, 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, []) + async with self._get_user_lock(session_key): + messages = self._pending_messages.pop(session_key, []) if not messages: return # A. 合并与元数据修复 @@ -108,6 +196,7 @@ class SystemOrchestrator: msg_id=merged_msg_id, user_id=user_id, content=combined_content, + msg_type=messages[-1].msg_type, image_urls=all_image_urls, acc_id=acc_id, acc_type=acc_type @@ -116,17 +205,22 @@ class SystemOrchestrator: # 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) + await repo.save_chat(platform, user_id, db_content, "in", acc_id=acc_id, image_urls=all_image_urls) + + # B2. 后台图片分析(不阻塞主流程,用于数据标定) + if all_image_urls: + asyncio.create_task(self._analyze_images_background(session_key, all_image_urls)) - # C. 冷却检查:如果 60秒内发过转接,告诉大脑“已处于转接中” - is_in_cooldown = (time.time() - self._last_transfer_time.get(user_id, 0)) < 60 + # C. 冷却检查:如果转接冷却期内发过转接,告诉大脑"已处于转接中" + is_in_cooldown = (time.time() - self._last_transfer_time.get(session_key, 0)) < TRANSFER_COOLDOWN_SEC # D. 思考 - history = await repo.get_chat_history(user_id, limit=10) + 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 is_in_cooldown: + # 只在“明确又要转接”时注入冷却提示,普通问候/新需求不注入 + transfer_intent = self._has_transfer_intent(combined_content) + if is_in_cooldown and transfer_intent: final_msg.content = f"【系统:当前已向设计师发出转接请求,请勿再次调用转接工具】\n{final_msg.content}" std_res = await self.brain.think_and_reply(final_msg, history=history) @@ -139,7 +233,7 @@ class SystemOrchestrator: 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() + self._last_transfer_time[session_key] = time.time() except asyncio.CancelledError: pass except Exception as e: logger.exception(f"[Orchestrator] 处理失败: {e}") diff --git a/core/pydantic_ai_agent_v2.py b/core/pydantic_ai_agent_v2.py index aa1394c..dc08158 100644 --- a/core/pydantic_ai_agent_v2.py +++ b/core/pydantic_ai_agent_v2.py @@ -11,6 +11,25 @@ logger = logging.getLogger("cs_agent") from core.skill_manager import skill_manager + +def _clip(text: str, limit: int = 1200) -> str: + if text is None: + return "" + text = str(text) + if len(text) <= limit: + return text + return f"{text[:limit]}...(截断, 共{len(text)}字)" + + +def _fmt_time(ts: Any) -> str: + s = str(ts or "").strip() + if not s: + return "--:--:--" + if " " in s: + return s.split(" ", 1)[1] + return s + + class CustomerServiceBrain: """ 重构后的单一 Agent 大脑: @@ -27,27 +46,38 @@ class CustomerServiceBrain: provider=OpenAIProvider(api_key=self.api_key, base_url=self.base_url) ) - all_skills = skill_manager.get_all_skills_text() + exclude_names = os.getenv("SKILL_EXCLUDE_FROM_PROMPT", "pricing-skill") + excluded_skills = [s.strip().lower() for s in exclude_names.split(",") if s.strip()] + all_skills = skill_manager.get_all_skills_text(exclude=excluded_skills) + logger.info(f"[SkillManager] 已从提示词排除技能: {excluded_skills}") # --- 统一口径后的 System Prompt --- system_prompt = ( - "你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话高端、专业。\n\n" + "你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话自然、专业。\n\n" "【统一称呼规范】\n" - "1. 严禁使用'师傅'、'客服'、'专员'等词汇!\n" - "2. 必须统一称呼为【设计师】。比如:'找设计师看下'、'设计师马上来'、'等设计师核价'。\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" + "1. 业务:只聊高清修复和找原图。核心链路:引导发图 -> 问需求 -> 找设计师。\n" + "2. **主动引导(关键)**:如果客户【没发图】就问能不能做、问收费,你必须回:'亲亲先发图我看下哈'。\n" + "3. **非业务问题**:如果客户问招聘、合作、闲聊等与做图无关的话题,礼貌拒绝:'亲亲咱这边只做图哦,暂不招人哈'。\n" + "4. **客户说没有参考图**:如果客户明确说'没有图'、'找不到'、'想让你们帮找',直接转人工:'好的,我这就叫设计师帮您找哈'。\n" + "5. **客户问尺寸/能否打印/退款**:这类问题需要设计师判断,直接转人工:'这个设计师帮您看下哈'。\n" + "6. 转接时机:收到图片并明确需求后,立即调用转人工工具,并告知:'收到,正在呼叫设计师核价,稍等哈'。\n" + "7. **下线安抚(重要)**:只有当【本次】工具返回 'ERROR_NO_DESIGNER_ONLINE' 时才能说下班。不能根据历史对话或自己猜测说下班!\n" + "8. 正在转接中:如果系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'。\n" + "9. **每次转接必须调用工具**:不要根据之前的结果猜测,每次需要转接都必须重新调用工具检查设计师是否在线。\n\n" + + "【必杀令 - 严格遵守】\n" + "1. 每句回复严禁超过15个字!语气淘宝亲切风,多用'哈'、'呢'。\n" "2. 严禁报价,严禁复读图片已收到的情况。\n" - "3. 必须原样输出工具返回的'正在为您转接|'指令。\n\n" + "3. 必须原样输出工具返回的'正在为您转接|'指令。\n" + "4. **严禁**说'在呢铁子'!只能说'在呢'或'在呢亲'。\n" + "5. **严禁**重复发送相同内容!如果刚说过的话,换一种说法。\n" + "6. **严禁**输出任何代码、标记、括号等乱码!只输出自然语言。\n" + "7. **严禁**自己臆造'下班'!只有工具返回ERROR才能说下班。\n\n" f"业务参考:\n{all_skills}" ) @@ -57,26 +87,70 @@ class CustomerServiceBrain: 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:]] + lines = [ + f"[{_fmt_time(h.get('timestamp'))}] {('客户' 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}" + logger.info( + f"[PROMPT->AI] user={msg.user_id} acc={msg.acc_id} images={len(msg.image_urls)}\n" + f"{_clip(full_input)}" + ) 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 "在呢铁子。" + # --- 终极修复:强制截获工具返回的转接指令 --- + reply_text = "" + # pydantic-ai 1.x 使用 result.output(旧版 0.x 使用 result.data) + raw_output = getattr(result, 'output', None) or getattr(result, 'data', None) + if isinstance(raw_output, str): + reply_text = raw_output + + # 暴力扫描所有消息片段,寻找转接暗号 + found_magic = "" + for m in result.all_messages(): + if hasattr(m, 'parts'): + for part in m.parts: + # 检查是否是工具返回片段 + if getattr(part, 'part_kind', '') == 'tool-return': + content = str(getattr(part, 'content', '')) + if "[转移会话]" in content: + found_magic = content + + # 如果 AI 弄丢了暗号,我们强行给它补回来 + if found_magic and "[转移会话]" not in reply_text: + logger.info(f"[Brain] 检测到 AI 弄丢了转接暗号,正在强制恢复: {found_magic[:30]}...") + 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']*>.*', '', reply_text, flags=re.DOTALL) # 清理 内部思考泄漏 + reply_text = re.sub(r']*>', '', reply_text) # 清理 think 标签 + reply_text = re.sub(r'```[^`]*```', '', reply_text) # 清理代码块 + reply_text = re.sub(r'\{["\'][^}]+\}', '', reply_text) # 清理 JSON + reply_text = reply_text.strip() + + # 过滤"在呢铁子" + if "在呢铁子" in reply_text: + reply_text = reply_text.replace("在呢铁子", "在呢亲") + + if not reply_text: + reply_text = "稍等我看看。" + + logger.info(f"[THINK/RAW_OUTPUT] user={msg.user_id}\n{_clip(reply_text)}") need_transfer = "[转移会话]" in reply_text diff --git a/core/repository.py b/core/repository.py index 0a86175..57df58a 100644 --- a/core/repository.py +++ b/core/repository.py @@ -18,24 +18,33 @@ class DataRepository: # --- 聊天记录 (异步化) --- - async def save_chat(self, platform: str, user_id: str, content: str, direction: str, acc_id: str = ""): + async def save_chat(self, platform: str, user_id: str, content: str, direction: str, acc_id: str = "", image_urls: list = None): """异步持久化存储聊天记录""" + # 将图片URL列表转为\n分隔的字符串 + urls_str = "\n".join(image_urls) if image_urls else "" return await asyncio.to_thread( log_message, customer_id=user_id, message=content, direction=direction, platform=platform, - acc_id=acc_id + acc_id=acc_id, + image_urls=urls_str ) - async def get_chat_history(self, user_id: str, limit: int = 10) -> List[dict]: + async def get_chat_history(self, user_id: str, limit: int = 10, acc_id: str = "") -> List[dict]: """异步获取历史记录""" - rows = await asyncio.to_thread(get_conversation, user_id, limit=limit) + rows = await asyncio.to_thread(get_conversation, user_id, limit=limit, acc_id=acc_id) history = [] for r in rows: role = "user" if r["direction"] == "in" else "assistant" - history.append({"role": role, "content": r["message"]}) + history.append( + { + "role": role, + "content": r["message"], + "timestamp": r.get("timestamp", ""), + } + ) return history # --- 客户相关 (异步化) --- diff --git a/core/schema.py b/core/schema.py index a912734..395287d 100644 --- a/core/schema.py +++ b/core/schema.py @@ -9,6 +9,7 @@ class StandardMessage(BaseModel): user_id: str # 发送者唯一ID user_name: str = "" # 发送者昵称 content: str # 消息文本内容 + msg_type: int = 0 # 消息类型:0 文本, 1 图片, 2 语音等 image_urls: List[str] = [] # 提取出来的图片链接 acc_id: str = "" # 商家/店铺账号ID acc_type: str = "" # 平台类型标识 diff --git a/core/skill_manager.py b/core/skill_manager.py index a929052..c12099f 100644 --- a/core/skill_manager.py +++ b/core/skill_manager.py @@ -48,9 +48,11 @@ class SkillManager: parts.append(f"### 技能:{name}\n{content}") return "\n\n".join(parts) - def get_all_skills_text(self) -> str: + def get_all_skills_text(self, exclude: Optional[List[str]] = None) -> str: """获取所有技能的合集(用于全能大脑模式)""" - return self.compose_skills(list(self._skill_cache.keys())) + exclude_set = {n.lower() for n in (exclude or [])} + names = [n for n in self._skill_cache.keys() if n not in exclude_set] + return self.compose_skills(names) # 全局单例 skill_manager = SkillManager() diff --git a/core/task_scheduler.py b/core/task_scheduler.py index bc71782..6ab8533 100644 --- a/core/task_scheduler.py +++ b/core/task_scheduler.py @@ -7,11 +7,16 @@ import asyncio import logging from typing import Optional, Dict from datetime import datetime -from .websocket_client import QingjianAPIClient +from .websocket_client_v2 import QingjianAPIClient from db.task_db.task_model import get_task_manager, TaskStatus, TaskPriority logger = logging.getLogger(__name__) +# 配置常量 +TIMEOUT_CHECK_INTERVAL_SEC = 300 # 超时检查间隔(5分钟) +ERROR_RETRY_DELAY_SEC = 60 # 错误后重试延迟(1分钟) +QUEUE_POLL_INTERVAL_SEC = 1 # 队列轮询间隔(秒) + class TaskScheduler: """任务调度器""" @@ -54,14 +59,14 @@ class TaskScheduler: # 通知天网任务超时 await self._notify_tianwang(task['task_id'], 'timeout') - # 每 5 分钟检查一次 - await asyncio.sleep(300) + # 每隔固定时间检查一次 + await asyncio.sleep(TIMEOUT_CHECK_INTERVAL_SEC) except asyncio.CancelledError: break except Exception as e: logger.error(f"超时检查失败:{e}") - await asyncio.sleep(60) + await asyncio.sleep(ERROR_RETRY_DELAY_SEC) async def _process_task_queue(self): """处理任务队列""" @@ -69,8 +74,8 @@ class TaskScheduler: while self.running: try: # 这里实际应该从队列获取任务 - # 简化处理:每秒检查一次待触发任务 - await asyncio.sleep(1) + # 简化处理:定期检查待触发任务 + await asyncio.sleep(QUEUE_POLL_INTERVAL_SEC) except Exception as e: logger.error(f"任务队列处理失败:{e}") diff --git a/core/websocket_client_v2.py b/core/websocket_client_v2.py index 78c0992..52e6ef1 100644 --- a/core/websocket_client_v2.py +++ b/core/websocket_client_v2.py @@ -15,7 +15,7 @@ class QingjianAPIClient: 重构后的轻简API客户端 (协议全复刻版) """ - def __init__(self, uri=None, enable_agent: bool = True): + def __init__(self, uri=None, enable_agent: bool = True, worker_id: int = -1, worker_count: int = 1): from config.config import QINGJIAN_WS_URI self.uri = uri or QINGJIAN_WS_URI self.websocket = None @@ -23,6 +23,12 @@ class QingjianAPIClient: self.logger = logger self.enable_agent = enable_agent + # 多进程分片逻辑 + self.worker_id = worker_id + self.worker_count = worker_count + if self.worker_id >= 0: + logger.info(f"[WebSocket] 启用分片模式: Worker {self.worker_id}/{self.worker_count}") + # 初始化新架构总指挥部 self.orchestrator = init_orchestrator(ws_client=self) logger.info("[WebSocket] 新架构 Orchestrator 已就绪。") @@ -36,13 +42,35 @@ class QingjianAPIClient: async def receive_messages(self): await receive_messages_flow(self) + def _should_handle(self, customer_id: str) -> bool: + """分片判定:这个客户归我管吗?""" + if self.worker_id < 0 or self.worker_count <= 1: + return True + + # 如果没有 customer_id,为了安全起见,只让 Worker 0 处理 + 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 + async def handle_message(self, message): """收到消息处理""" try: data = json.loads(message) + # 预提取客户ID用于分片判定 + customer_id = str(data.get("cy_id") or data.get("from_id") or "") + if not self._should_handle(customer_id): + return + await self.orchestrator.on_raw_message_received(platform="qianniu", raw_data=data) except Exception as e: - logger.error(f"[WebSocket] 处理消息异常: {e}") + raw_preview = str(message).replace("\n", "\\n") + if len(raw_preview) > 300: + raw_preview = raw_preview[:300] + "..." + logger.error(f"[WebSocket] 处理消息异常: {e} raw={raw_preview}") async def send(self, customer_id: str, acc_id: str, acc_type: str, content: str, msg_type: int = 0): """ diff --git a/db/__pycache__/chat_log_db.cpython-310.pyc b/db/__pycache__/chat_log_db.cpython-310.pyc index f151f7b..ecbfddb 100755 Binary files a/db/__pycache__/chat_log_db.cpython-310.pyc and b/db/__pycache__/chat_log_db.cpython-310.pyc differ diff --git a/db/__pycache__/customer_db.cpython-310.pyc b/db/__pycache__/customer_db.cpython-310.pyc index 776213f..cc562be 100755 Binary files a/db/__pycache__/customer_db.cpython-310.pyc and b/db/__pycache__/customer_db.cpython-310.pyc differ diff --git a/db/chat_log_db.py b/db/chat_log_db.py index 384ab57..e89e82f 100755 --- a/db/chat_log_db.py +++ b/db/chat_log_db.py @@ -113,6 +113,11 @@ def init_db(): conn.execute("CREATE INDEX idx_ts ON chat_logs(timestamp)") if "idx_acc" not in exists: conn.execute("CREATE INDEX idx_acc ON chat_logs(acc_id)") + # 添加 image_urls 列(如果不存在) + try: + conn.execute("ALTER TABLE chat_logs ADD COLUMN image_urls TEXT DEFAULT ''") + except Exception: + pass # 列已存在 else: conn.execute(""" CREATE TABLE IF NOT EXISTS chat_logs ( @@ -133,6 +138,10 @@ def init_db(): conn.execute("ALTER TABLE chat_logs ADD COLUMN acc_id TEXT DEFAULT ''") except Exception: pass + try: + conn.execute("ALTER TABLE chat_logs ADD COLUMN image_urls TEXT DEFAULT ''") + except Exception: + pass conn.execute("CREATE INDEX IF NOT EXISTS idx_acc ON chat_logs(acc_id)") conn.commit() @@ -150,15 +159,16 @@ def log_message( acc_id: str = "", # 店铺账号ID platform: str = "", msg_type: int = 0, + image_urls: str = "", # 图片URL列表,用\n分隔 ): """记录一条聊天消息""" ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") with _get_conn() as conn: conn.execute( _sql("INSERT INTO chat_logs " - "(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp) " - "VALUES (?,?,?,?,?,?,?,?)"), - (customer_id, customer_name, acc_id, platform, direction, message, msg_type, ts), + "(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp, image_urls) " + "VALUES (?,?,?,?,?,?,?,?,?)"), + (customer_id, customer_name, acc_id, platform, direction, message, msg_type, ts, image_urls), ) conn.commit() @@ -198,10 +208,10 @@ def get_customers(limit: int = 100) -> List[Dict]: return [dict(r) for r in rows] -def get_conversation(customer_id: str, limit: int = 200) -> List[Dict]: +def get_conversation(customer_id: str, limit: int = 200, acc_id: str = "") -> List[Dict]: """返回某客户的最近对话记录(按时间升序)""" + # 忽略 acc_id 过滤,实现全店铺记忆 with _get_conn() as conn: - # 核心修复:先取最新的 limit 条,再按时间正序排列 rows = conn.execute(_sql(""" SELECT * FROM ( SELECT id, direction, message, msg_type, timestamp, acc_id @@ -216,24 +226,15 @@ def get_conversation(customer_id: str, limit: int = 200) -> List[Dict]: def get_recent_conversation(customer_id: str, acc_id: str = "", limit: int = 10) -> List[Dict]: - """返回某客户近期对话(同店铺),用于企微推送保持连贯""" + """返回某客户近期对话,忽略 acc_id 过滤""" with _get_conn() as conn: - if acc_id: - rows = conn.execute(_sql(""" - SELECT id, direction, message, timestamp, acc_id - FROM chat_logs - WHERE customer_id = ? AND acc_id = ? - ORDER BY id DESC - LIMIT ? - """), (customer_id, acc_id, limit)).fetchall() - else: - rows = conn.execute(_sql(""" - SELECT id, direction, message, timestamp, acc_id - FROM chat_logs - WHERE customer_id = ? - ORDER BY id DESC - LIMIT ? - """), (customer_id, limit)).fetchall() + rows = conn.execute(_sql(""" + SELECT id, direction, message, timestamp, acc_id + FROM chat_logs + WHERE customer_id = ? + ORDER BY id DESC + LIMIT ? + """), (customer_id, limit)).fetchall() out = [dict(r) for r in reversed(rows)] return out diff --git a/db/chat_log_db/chats.db b/db/chat_log_db/chats.db index 3927cce..72bccb4 100755 Binary files a/db/chat_log_db/chats.db and b/db/chat_log_db/chats.db differ diff --git a/db/customer_db.py b/db/customer_db.py index 9f80065..20ea2f1 100755 --- a/db/customer_db.py +++ b/db/customer_db.py @@ -76,6 +76,8 @@ class CustomerProfile: last_gemini_prompt: str = "" # 最近一次图片的 Gemini 处理提示词 last_aspect_ratio: str = "1:1" # 最近一次图片的建议输出比例 last_perspective: str = "no" # 最近一次图片的透视状态 + last_image_analysis: str = "" # 最近一次图片分析结果(JSON字符串,用于数据标定) + image_analysis_history: List[str] = None # 图片分析历史记录(JSON列表,用于数据标定) pending_quote_images: List[str] = None # 待统一报价图片队列(持久化) pending_quote_requirements: List[str] = None # 待统一报价需求队列(持久化) @@ -165,6 +167,8 @@ class CustomerProfile: self.pending_quote_images = [] if self.pending_quote_requirements is None: self.pending_quote_requirements = [] + if self.image_analysis_history is None: + self.image_analysis_history = [] class CustomerDatabase: diff --git a/db/image_tasks.db b/db/image_tasks.db index 63d4f28..99f6d94 100644 Binary files a/db/image_tasks.db and b/db/image_tasks.db differ diff --git a/db/image_tasks_db.py b/db/image_tasks_db.py index b9ce4a8..24e143f 100644 --- a/db/image_tasks_db.py +++ b/db/image_tasks_db.py @@ -47,6 +47,13 @@ class ImageTaskManager: ''') cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON image_tasks(status)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_cust_plat ON image_tasks(customer_id, platform)') + # 兼容旧库:补齐缺失字段 + cursor.execute("PRAGMA table_info(image_tasks)") + existing_cols = {row[1] for row in cursor.fetchall()} + if "outcome" not in existing_cols: + cursor.execute("ALTER TABLE image_tasks ADD COLUMN outcome TEXT DEFAULT 'pending'") + if "price" not in existing_cols: + cursor.execute("ALTER TABLE image_tasks ADD COLUMN price REAL DEFAULT 0.0") conn.commit() conn.close() @@ -88,6 +95,27 @@ class ImageTaskManager: except Exception as e: logger.error(f"Failed to update task status: {e}") + def update_price(self, customer_id: str, platform: str, price: float): + """记录任务的成交价格""" + now = datetime.now().isoformat() + try: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute(''' + UPDATE image_tasks + SET price = ?, updated_at = ? + WHERE task_id = ( + SELECT task_id FROM image_tasks + WHERE customer_id = ? AND platform = ? + ORDER BY created_at DESC LIMIT 1 + ) + ''', (price, now, customer_id, platform)) + conn.commit() + conn.close() + logger.info(f"[DB] 客户 {customer_id} 任务价格更新为: ¥{price}") + except Exception as e: + logger.error(f"Failed to update price: {e}") + def update_outcome(self, customer_id: str, platform: str, outcome: str): """记录任务的最终结局(用于训练样本分类)""" now = datetime.now().isoformat() diff --git a/image/__pycache__/__init__.cpython-310.pyc b/image/__pycache__/__init__.cpython-310.pyc deleted file mode 100755 index d3508cc..0000000 Binary files a/image/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/image/__pycache__/image_analyzer.cpython-310.pyc b/image/__pycache__/image_analyzer.cpython-310.pyc deleted file mode 100755 index 2879cb5..0000000 Binary files a/image/__pycache__/image_analyzer.cpython-310.pyc and /dev/null differ diff --git a/image/image_analyzer.py b/image/image_analyzer.py deleted file mode 100755 index f9076b2..0000000 --- a/image/image_analyzer.py +++ /dev/null @@ -1,756 +0,0 @@ -""" -图片复杂度识别模块 - -使用智谱 GLM-4V 视觉模型分析客户发来的图片, -判断处理难度,为客服AI提供报价依据。 - -复杂度等级(越平整越便宜): - simple → 10-15元(画面平整、无小字、无人脸、无阴影) - normal → 15-20元(一般复杂度) - complex → 20-25元(有褶皱/小字/人脸/阴影) - hard → 25-30元(非常复杂) - -报价维度:平整度、含文字(小字加价)、含人脸、阴影。 -同一 URL 5 分钟内复用缓存,节省 API 调用。 -""" -import os -import asyncio -import base64 -import time -from typing import Optional, Tuple -from openai import AsyncOpenAI -from dotenv import load_dotenv -from PIL import Image -import aiohttp - -load_dotenv() - - -ANALYSIS_PROMPT = """你是一个电商图片处理评估专家,同时也是 Gemini 图像生成提示词专家。 -请仔细分析这张图片,输出以下字段,每行一个,不要多余内容: - -敏感内容: -平整度: -含文字: -含人脸: -阴影: -复杂度: -原因: <15字以内,说明复杂度判断依据> -主体: <图片核心内容,如:印花图案/logo/人物/产品/老照片/风景/文字/其他> -类型: <处理类型,如:印花提取/高清修复/去背景/老照片修复/logo提取/人像修复/其他> -质量: <原图质量,如:清晰/轻微模糊/严重模糊/低分辨率/截图/扫描件> -可做: -风险: -透视: -比例: <从以下选一个最合适的:1:1 / 9:16 / 16:9 / 3:4 / 4:3 / 3:2 / 2:3 / 5:4 / 4:5> -提示词: <为 Gemini 写处理指令,中文,60字以内,说明要做什么、保留什么、去掉什么> -备注: <给客服AI的特别提示,没有则填无> - -判断规则: - -【报价核心:越平整越便宜】 -- 平整度 flat:画面平整、无褶皱、无透视 → 便宜 -- 平整度 mild:轻微褶皱/透视 → 中等 -- 平整度 rough:有褶皱/透视/曲面 → 贵 -- 含文字:大字没关系不加价;小字需精细保留/清晰化 → 加价(含文字填 yes 仅指有小字的情况) -- 含人脸 yes:有人脸 → 加价 -- 阴影 yes:有明显阴影需处理 → 加价 -综合以上因素,越平整、无小字、无人脸、无阴影 → 越便宜(simple) - -【含文字】 -- yes:含小字需精细保留/清晰化(小字难处理 → 加价) -- no:无文字,或仅有大字(大字没关系 → 不加价) - -【文字数量加价规则】 -- none:无文字,不加价 -- 少量 (1-10 字):+5 元 -- 中量 (11-50 字):+10-15 元 -- 大量 (51-200 字):+20-30 元 -- 极多 (200 字以上):+30-50 元 - -【文字分层需求】 -- yes:客户要求可编辑分层文件(PSD 等) → 基础价格 x2 或 +50 元起 -- no:普通图片处理 → 正常价格 - -【文字分层 + 大量文字】 -- 如果 文字数量=大量/极多 且 文字分层需求=yes → 总价可达 60-80 元 - -【含人脸】 -- yes:图中有真实人物面孔(人像照/集体照/证件照/老照片等) -- no:无人脸或人脸极小不影响主体 - -【风险评估 - 重要!】 -- none:印花/图案/logo/风景/产品,AI处理效果稳定,可直接报价接单 -- low:有人脸但清晰度尚可,AI修复后人脸相似度70-90%,可以接单但要说明风险 -- high:以下任一情况 → 严重模糊的人脸照片/老照片人像/需要打印/客户问能否找回原图 - high情况下,可做改为partial,备注写明风险话术,谨慎接单 - -【敏感内容检测 - 必须严格判断!】 -- yes:含以下任一内容 → 色情/黄色/擦边/裸露/性暗示/大尺度/涉政/暴力/血腥/违禁品/地图类 - 敏感内容=yes 时,可做必须填 no,直接拒绝不接单 -- no:无上述敏感内容,可以正常接单处理 - -【可做判断 - 决定是否接单】 -- yes:效果有把握,可以接单处理 -- partial:能处理但有明显限制(人脸变形风险/分辨率极低/严重损坏)→ 可以接单但要说明风险 -- no:无法接单(纯黑/纯白/完全损坏/找原始 RAW 文件/敏感内容/违法内容) - -【敏感内容】优先判断,若为 yes 则 可做 必填 no -- yes:图片含色情/黄色/擦边/裸露/性暗示/大尺度等违规内容 -- no:无上述敏感内容 - -【可做判断】 -- yes:效果有把握,可直接处理 -- partial:能处理但有明显限制(人脸变形风险/分辨率极低/严重损坏) -- no:无法处理(纯黑/纯白/完全损坏/找原始RAW文件/敏感内容) - -【风险话术模板(备注字段)】 -- 含人脸+需打印:AI修复后人脸可能有轻微变化,建议先看效果确认再打印 -- 严重模糊人脸:这张模糊程度较高,修复后清晰了但人脸可能跟原来有差异 -- 找原图:找不到原始文件,只能对现有图片做高清修复处理 -- 完全损坏:这张无法处理 - -【透视判断】 -- no:正面拍摄,无明显变形 -- mild:轻微透视(衣服悬挂/桌面小角度斜拍) -- strong:严重透视(俯拍/贴墙/大角度倾斜) - -【比例选择】 -- 印花/图案/logo/正方形 -> 1:1 -- 竖屏壁纸/短视频封面 -> 9:16 -- 宽屏/横版视频 -> 16:9 -- 移动广告/Instagram竖图 -> 4:5 -- 竖向人像/海报/证件照 -> 3:4 -- 竖向相机照片 -> 2:3 -- 接近正方形产品图 -> 5:4 -- 横向标准图/风景 -> 4:3 -- 横向相机照片/产品实拍 -> 3:2 - -示例1(印花,无风险): -敏感内容: no -平整度: mild -含文字: no -含人脸: no -阴影: no -复杂度: complex -原因: 印花细节密集颜色层次多 -主体: 印花图案 -类型: 印花提取 -质量: 轻微模糊 -可做: yes -风险: none -透视: mild -比例: 1:1 -提示词: 提取衣物印花图案,去除褶皱和背景杂色,补全缺失部分,保持颜色细节100%还原,输出干净平面印花图 -备注: 无 - -示例2(人像老照片,要打印): -敏感内容: no -平整度: flat -含文字: no -含人脸: yes -阴影: no -复杂度: hard -原因: 严重模糊人脸细节丢失 -主体: 人物照片 -类型: 人像修复 -质量: 严重模糊 -可做: partial -风险: high -透视: no -比例: 3:4 -提示词: 对模糊人像进行高清修复,增强细节,保持人物特征不变 -备注: AI修复后人脸可能有轻微变化,建议先看效果确认满意再用于打印 - -示例3(平整印花,最便宜): -敏感内容: no -平整度: flat -含文字: no -含人脸: no -阴影: no -复杂度: simple -原因: 画面平整无褶皱无文字无人脸 -主体: 印花图案 -类型: 印花提取 -质量: 清晰 -可做: yes -风险: none -透视: no -比例: 1:1 -提示词: 提取印花图案,去除背景,输出干净平面图 -备注: 无""" - - -class ImageAnalyzer: - """图片复杂度分析器""" - - # 同一 URL 5 分钟内复用结果,节省 API 调用 - _CACHE_TTL_SECONDS = 300 - _analysis_cache: dict = {} # url -> (result_dict, timestamp) - - PRICE_MAP = { - "simple": (10, 15, "画面简单干净"), - "normal": (15, 20, "一般复杂度"), - "complex": (20, 25, "细节偏多"), - "hard": (25, 30, "非常复杂"), - } - # 注意:含文字很多时,不能报 simple/normal 的低价,必须 complex 起步 - - def __init__(self): - self.api_key = os.getenv("OPENAI_API_KEY") - self.base_url = os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4") - # 视觉模型,智谱 GLM-4V 系列 - self.vision_model = os.getenv("VISION_MODEL", "glm-4v-flash") - - def _is_url(self, image_path: str) -> bool: - return image_path.startswith("http://") or image_path.startswith("https://") - - def _load_image_base64(self, image_path: str) -> Optional[str]: - """本地图片转 base64""" - try: - with open(image_path, "rb") as f: - return base64.b64encode(f.read()).decode("utf-8") - except Exception as e: - print(f"[ImageAnalyzer] 读取图片失败: {e}") - return None - - async def _get_image_size(self, image_path: str) -> Tuple[int, int]: - """获取图片像素尺寸 (width, height),URL 或 本地路径""" - try: - if self._is_url(image_path): - timeout = aiohttp.ClientTimeout(total=10) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(image_path) as resp: - if resp.status != 200: - return (0, 0) - data = await resp.read() - from io import BytesIO - with Image.open(BytesIO(data)) as img: - w, h = img.size - return (int(w), int(h)) - else: - with Image.open(image_path) as img: - w, h = img.size - return (int(w), int(h)) - except Exception as e: - print(f"[ImageAnalyzer] 获取尺寸失败: {e}") - return (0, 0) - - # 最短等待时间(秒):即使AI极快返回,也等这么久,看起来像真人在找 - MIN_WAIT_SECONDS = 4 - - DENSE_TEXT_SUBJECT_KEYWORDS = ( - "宣传栏", "公告栏", "展板", "海报墙", "通知栏", "知识栏", "制度牌", "公示栏", "墙报", "密密麻麻", - "宣传海报", "知识海报", "科普海报", "防灾减灾", "宣传板", "宣传页", - "表格", "检索表", "配伍表", "药物配伍", "课程表", "流程表", "说明表", "数据表", - "word wall", "poster wall", "bulletin board", - ) - MANY_FACES_SUBJECT_KEYWORDS = ( - "多人", "多人脸", "人群", "群像", "合照", "集体照", "全家福", "毕业照", "婚礼合影", "大合照", - "crowd", "group photo", "many faces", - ) - FORBIDDEN_CONTENT_KEYWORDS = ( - # 党政/涉政 - "党政", "涉政", "政治人物", "领导人", "国旗", "国徽", "党徽", "党旗", "时政宣传", - "政治事件", "时政", "政要", "政治海报", "政治宣传", "政治标语", - "天安门", "人民大会堂", "中南海", - "习近平", "毛泽东", "邓小平", "江泽民", "胡锦涛", "李克强", "周恩来", - "中国共产党", "共产党", "中共", "党代会", "两会", "人大", "政协", - "trump", "donald trump", "biden", "putin", "zelensky", "xi jinping", - # 地图类(业务规则:地图一律不接) - "地图", "地形图", "行政区划图", "世界地图", "中国地图", "卫星地图", "导航图", "航海图", - "map", "topographic map", "satellite map", "navigation map", - # 黄暴血腥 - "黄色", "擦边", "裸露", "色情", "性暗示", "暴力", "凶杀", "打斗", "枪击", "血腥", "尸体", "虐待", - # 英文兜底 - "political", "government propaganda", "nsfw", "porn", "nude", "violence", "bloody", "gore", - ) - - async def analyze(self, image_path: str) -> dict: - """ - 异步分析图片复杂度(使用火山引擎 /responses 接口)。 - 实际等待时间 = max(视觉AI响应时间, MIN_WAIT_SECONDS) - - Args: - image_path: 图片URL 或 本地路径 - - Returns: - { - "complexity": "simple|normal|complex|hard", - "reason": "原因描述", - "price_min": 最低报价, - "price_max": 最高报价, - "price_suggest": 建议报价, - "elapsed": 实际耗时秒数, - "success": True/False - } - """ - if not self.api_key: - await asyncio.sleep(self.MIN_WAIT_SECONDS) - return self._fallback("未配置 API Key") - - # 缓存:仅对 URL 生效,本地路径不缓存 - cache_key = image_path if self._is_url(image_path) else None - if cache_key: - now = time.monotonic() - cached = self._analysis_cache.get(cache_key) - if cached: - result, cached_at = cached - if now - cached_at < self._CACHE_TTL_SECONDS: - print(f"[ImageAnalyzer] 缓存命中 | URL 已分析过,跳过 API 调用") - result = dict(result) - result["elapsed"] = 0 - return result - else: - del self._analysis_cache[cache_key] - - start = time.monotonic() - - try: - # 构建图片内容 - if self._is_url(image_path): - image_item = { - "type": "input_image", - "image_url": image_path - } - else: - b64 = self._load_image_base64(image_path) - if not b64: - await asyncio.sleep(self.MIN_WAIT_SECONDS) - return self._fallback("图片读取失败") - image_item = { - "type": "input_image", - "image_url": f"data:image/jpeg;base64,{b64}" - } - - # 使用火山引擎官方 SDK(AsyncOpenAI + /responses 接口) - client = AsyncOpenAI( - base_url=self.base_url, - api_key=self.api_key, - ) - - response = await client.responses.create( - model=self.vision_model, - input=[ - { - "role": "user", - "content": [ - image_item, - { - "type": "input_text", - "text": ANALYSIS_PROMPT - } - ] - } - ] - ) - - content = response.output_text - - elapsed = time.monotonic() - start - print(f"[ImageAnalyzer] 视觉AI响应耗时: {elapsed:.1f}s") - - await self._wait_remaining(elapsed) - - result = self._parse_result(content) - result["elapsed"] = elapsed - - # 计算尺寸与类型加价 - try: - w, h = await self._get_image_size(image_path) - mp = round((w * h) / 1_000_000, 2) if w and h else 0.0 - result["width"] = w - result["height"] = h - result["megapixels"] = mp - - # 归一化类型 - subj = (result.get("subject") or "").lower() - ptype = (result.get("proc_type") or "").lower() - ratio = result.get("aspect_ratio") or "1:1" - category = "general" - # 初步判断 - if ("壁纸" in subj) or ("wallpaper" in subj) or ratio in ("9:16", "16:9"): - category = "wallpaper" - elif ("衣" in subj) or ("服" in subj) or ("印花" in subj) or ("fabric" in subj) or ("cloth" in subj) or ("服装" in subj) or ("印花" in ptype): - category = "clothing" - elif ("logo" in subj) or ("logo" in ptype): - category = "logo" - elif ("海报" in subj) or ("poster" in subj): - category = "poster" - elif ("人像" in subj) or ("人物" in subj) or ("portrait" in subj): - category = "portrait" - elif ("产品" in subj) or ("product" in subj): - category = "product" - elif ("老照片" in subj) or ("old photo" in subj): - category = "old_photo" - # 可印花/印刷物体扩展 - keywords = subj + " " + ptype - if any(k in keywords for k in ["装饰画", "挂画", "油画", "canvas", "painting"]): - category = "decor_painting" - elif any(k in keywords for k in ["窗帘", "curtain"]): - category = "curtain" - elif any(k in keywords for k in ["地垫", "脚垫", "地毯", "垫", "mat", "rug"]): - category = "floor_mat" - elif any(k in keywords for k in ["广告牌", "喷绘", "展架", "灯箱", "banner", "billboard"]): - category = "billboard" - elif any(k in keywords for k in ["毯子", "毛毯", "blanket"]): - category = "blanket" - elif any(k in keywords for k in ["桌布", "台布", "tablecloth", "桌旗"]): - category = "tablecloth" - elif any(k in keywords for k in ["书本", "书籍", "封面", "book", "book cover"]): - category = "book" - elif any(k in keywords for k in ["鼠标垫", "mouse pad", "mousepad"]): - category = "mouse_pad" - elif any(k in keywords for k in ["头像", "个人头像", "个人照", "profile", "avatar"]): - category = "avatar" - result["category"] = category - - surcharge = 0 - size_note = "" - # 按类别设定尺寸要求与加价阈值(单位:百万像素) - if category == "wallpaper": - if h and h < 1920: - size_note = "壁纸高度低于1920px,清晰度可能不足" - if mp > 8: - surcharge = 10 - elif mp > 3: - surcharge = 5 - elif category == "clothing": - if (w and w < 1024) or (h and h < 1024): - size_note = "印花源图边长低于1024px,放大后细节可能不足" - if mp > 6: - surcharge = 10 - elif mp > 2: - surcharge = 5 - elif category in ("poster", "portrait", "product"): - if mp > 12: - surcharge = 10 - elif mp > 6: - surcharge = 5 - elif category == "logo": - if mp > 6: - surcharge = 5 - elif category == "decor_painting": - if (w and w < 1500) or (h and h < 1500): - size_note = "装饰画边长低于1500px,打印放大可能不够清晰" - if mp > 12: - surcharge = 10 - elif mp > 6: - surcharge = 5 - elif category == "curtain": - if (w and w < 1500): - size_note = "窗帘宽度低于1500px,印花放大可能不够清晰" - if mp > 16: - surcharge = 10 - elif mp > 8: - surcharge = 5 - elif category == "floor_mat": - if mp > 12: - surcharge = 10 - elif mp > 6: - surcharge = 5 - elif category == "billboard": - if (w and w < 2000) or (h and h < 1000): - size_note = "广告牌尺寸较小,建议更高分辨率以保证喷绘清晰" - if mp > 20: - surcharge = 10 - elif mp > 10: - surcharge = 5 - elif category == "blanket": - if mp > 16: - surcharge = 10 - elif mp > 8: - surcharge = 5 - elif category == "tablecloth": - if mp > 12: - surcharge = 10 - elif mp > 6: - surcharge = 5 - elif category == "book": - if (w and w < 800): - size_note = "书本封面宽度低于800px,印刷细节可能不足" - if mp > 6: - surcharge = 5 - elif category == "mouse_pad": - if (w and w < 1000): - size_note = "鼠标垫源图宽度低于1000px,细节可能不足" - if mp > 4: - surcharge = 5 - elif category == "avatar": - if (w and w < 800) or (h and h < 800): - size_note = "头像边长低于800px,清晰度可能不足" - if mp > 6: - surcharge = 5 - else: - if mp > 8: - surcharge = 10 - elif mp > 4: - surcharge = 5 - - # 应用加价,保持5的整数倍与 10-30 区间 - base = result.get("price_suggest", 20) - adjusted = base + surcharge - adjusted = max(10, min(30, adjusted)) - adjusted = round(adjusted / 5) * 5 - # 同步范围 - result["price_suggest"] = adjusted - result["price_max"] = max(result["price_max"], adjusted) - result["size_surcharge"] = surcharge - result["size_note"] = size_note - except Exception as e: - print(f"[ImageAnalyzer] 尺寸与类型加价计算失败: {e}") - - # 写入缓存 - if cache_key: - self._analysis_cache[cache_key] = (dict(result), time.monotonic()) - # 简单清理:缓存超过 50 条时删最旧的 - if len(self._analysis_cache) > 50: - oldest = min(self._analysis_cache.items(), key=lambda x: x[1][1]) - del self._analysis_cache[oldest[0]] - - return result - - except asyncio.TimeoutError: - elapsed = time.monotonic() - start - print(f"[ImageAnalyzer] 请求超时 ({elapsed:.1f}s)") - return self._fallback("请求超时") - except Exception as e: - elapsed = time.monotonic() - start - print(f"[ImageAnalyzer] 分析失败: {e}") - await self._wait_remaining(elapsed) - return self._fallback(str(e)) - - async def _wait_remaining(self, elapsed: float): - """补足最短等待时间""" - remaining = self.MIN_WAIT_SECONDS - elapsed - if remaining > 0: - await asyncio.sleep(remaining) - - def _parse_line(self, content: str, *keys: str) -> str: - """从多行文本中提取指定字段值,支持中英文冒号""" - for line in content.strip().split("\n"): - line = line.strip() - for key in keys: - if line.startswith(key): - return line.split(":", 1)[-1].split(":", 1)[-1].strip() - return "" - - def _parse_result(self, content: str) -> dict: - """解析模型返回的结果""" - p = self._parse_line - - # 复杂度 - complexity_raw = p(content, "复杂度:", "复杂度:").lower() - complexity = complexity_raw if complexity_raw in self.PRICE_MAP else "normal" - - sensitive = p(content, "敏感内容:", "敏感内容:").lower().strip() - flatness = p(content, "平整度:", "平整度:").lower().strip() # flat|mild|rough - has_text = p(content, "含文字:", "含文字:").lower().strip() - text_amount = p(content, "文字数量:", "文字数量:").strip() - text_layer_need = p(content, "文字分层需求:", "文字分层需求:").lower().strip() - has_face = p(content, "含人脸:", "含人脸:").lower().strip() - has_shadow = p(content, "阴影:", "阴影:").lower().strip() - reason = p(content, "原因:", "原因:") - subject = p(content, "主体:", "主体:") - proc_type = p(content, "类型:", "类型:") - quality = p(content, "质量:", "质量:") - feasibility = p(content, "可做:", "可做:").lower() - risk = p(content, "风险:", "风险:").lower().strip() - perspective = p(content, "透视:", "透视:").lower().strip() - aspect_ratio = p(content, "比例:", "比例:").strip() - gemini_prompt= p(content, "提示词:", "提示词:") - note = p(content, "备注:", "备注:") - - if has_face not in ("yes", "no"): - has_face = "no" - valid_text_amounts = {"none", "少量 (1-10 字)", "中量 (11-50 字)", "大量 (51-200 字)", "极多 (200 字以上)"} - if text_amount not in valid_text_amounts: - text_amount = "none" - if text_layer_need not in ("yes", "no"): - text_layer_need = "no" - if risk not in ("none", "low", "high"): - risk = "none" - if perspective not in ("no", "mild", "strong"): - perspective = "no" - - scene_text = ((subject or "") + " " + (proc_type or "") + " " + (reason or "") + " " + (note or "")).lower() - - # 识别“密集文字场景”关键词(中文 + 英文兜底) - dense_text_scene = any( - kw in scene_text - for kw in self.DENSE_TEXT_SUBJECT_KEYWORDS - ) - dense_text_hint = any( - kw in scene_text - for kw in ("密集文字", "大量文字", "多板块") - ) - - # 校验比例合法性 - valid_ratios = {"1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5"} - if aspect_ratio not in valid_ratios: - aspect_ratio = "1:1" # 默认正方形 - - price_min, price_max, default_reason = self.PRICE_MAP[complexity] - if not reason: - reason = default_reason - if feasibility not in ("yes", "partial", "no"): - feasibility = "yes" - - - # 【重要】含文字很多时,不能低价,必须 complex 起步(20 元以上) - # 有文字跟没文字是两个价格 - if has_text == "yes": - if complexity == "simple": - # 简单但含文字 → 提升到 normal 价格 - price_min, price_max, _ = self.PRICE_MAP["normal"] - reason = "含文字,需精细处理" - elif complexity == "normal": - # normal 含文字 → 提升到 complex 价格 - price_min, price_max, _ = self.PRICE_MAP["complex"] - reason = "含文字,需精细处理" - # complex/hard 保持原价,已经够高 - # 建议报价:complex/hard 取固定值,simple/normal 取中间,且必须为5的整数倍 - raw = price_max if complexity in ("complex", "hard") else (price_min + price_max) // 2 - price_suggest = round(raw / 5) * 5 - - # 【文字数量加价】 - text_surcharge = 0 - if text_amount == "少量 (1-10 字)": - text_surcharge = 5 - reason += " | 含少量文字" - elif text_amount == "中量 (11-50 字)": - text_surcharge = 15 - reason += " | 含中量文字" - elif text_amount == "大量 (51-200 字)": - text_surcharge = 30 - reason += " | 含大量文字" - elif text_amount == "极多 (200 字以上)": - text_surcharge = 50 - reason += " | 含极多文字" - - # 【文字分层需求加价】 - layer_surcharge = 0 - if text_layer_need == "yes": - if text_surcharge > 0: - # 有文字且需要分层 → 价格 x2 或 +50 元 - layer_surcharge = max(50, price_suggest) - reason += " | 需要文字分层" - else: - # 无文字但需要分层 → +30 元 - layer_surcharge = 30 - reason += " | 需要分层文件" - - # 加上文字加价 - price_suggest += text_surcharge + layer_surcharge - - # 【文字分层 + 大量文字】特殊处理 → 60-80 元 - if text_amount in ["大量 (51-200 字)", "极多 (200 字以上)"] and text_layer_need == "yes": - if price_suggest < 60: - price_suggest = 60 - elif price_suggest > 80: - price_suggest = 80 - reason += " | 大量文字分层" - - # 硬规则1:文字很多(>100)且密密麻麻不接单 - text_gt_100 = text_amount in ["大量 (51-200 字)", "极多 (200 字以上)"] - dense_text_hard_reject = text_gt_100 or dense_text_scene or (has_text == "yes" and dense_text_hint) - if dense_text_hard_reject: - feasibility = "no" - risk = "high" - note = "文字内容过于密集(如宣传栏/公告栏),暂不接单处理" - reason = (reason or "文字密集") + " | 密集文字场景不接单" - price_suggest = 0 - - # 硬规则2:多人脸不接;1-2 人脸可做 - many_faces_scene = any(k in scene_text for k in self.MANY_FACES_SUBJECT_KEYWORDS) - if has_face == "yes" and many_faces_scene: - feasibility = "no" - risk = "high" - note = "多人脸/群像场景处理风险高,暂不接单" - reason = (reason or "多人脸") + " | 多人脸场景不接单" - price_suggest = 0 - - # 硬规则3:党政/涉黄/暴力/血腥/地图内容不接单 - forbidden_scene = any(k in scene_text for k in self.FORBIDDEN_CONTENT_KEYWORDS) - sensitive_hit = str(sensitive or "").strip().lower() in ("yes", "true", "1", "是") - if forbidden_scene or sensitive_hit: - feasibility = "no" - risk = "high" - note = "含政治/党政/涉黄/暴力/血腥/地图等敏感内容,不接单" - reason = (reason or "敏感内容") + " | 敏感内容不接单(政治/地图类一律拒单)" - price_suggest = 0 - - # 确保是 5 的倍数 - price_suggest = round(price_suggest / 5) * 5 - - risk_label = {"none": "无风险", "low": "低风险", "high": "高风险"}.get(risk, "") - sens_tag = " | 敏感:是" if sensitive == "yes" else "" - print(f"[ImageAnalyzer] 识别结果: {complexity} | {reason} | 建议报价: {price_suggest}元{sens_tag}") - print(f"[ImageAnalyzer] 主体: {subject} | 类型: {proc_type} | 质量: {quality} | 平整度: {flatness} | 含文字: {has_text} | 含人脸: {has_face} | 阴影: {has_shadow} | 风险: {risk_label} | 透视: {perspective} | 比例: {aspect_ratio} | 可做: {feasibility}") - if gemini_prompt: - print(f"[ImageAnalyzer] Gemini提示词: {gemini_prompt}") - if note and note not in ("无", ""): - print(f"[ImageAnalyzer] 备注: {note}") - - return { - "complexity": complexity, - "reason": reason, - "subject": subject, - "proc_type": proc_type, - "quality": quality, - "flatness": flatness if flatness in ("flat", "mild", "rough") else "", - "has_text": has_text if has_text in ("yes", "no") else "no", - "text_amount": text_amount, - "text_layer_need": text_layer_need, - "text_surcharge": text_surcharge, - "layer_surcharge": layer_surcharge, - "has_face": has_face, # yes / no - "has_shadow": has_shadow if has_shadow in ("yes", "no") else "no", - "risk": risk, # none / low / high - "feasibility": feasibility, - "perspective": perspective, - "aspect_ratio": aspect_ratio, - "gemini_prompt": gemini_prompt, - "note": note, - "price_min": price_min, - "price_max": price_max, - "price_suggest": price_suggest, - "success": True - } - - def _fallback(self, reason: str) -> dict: - """识别失败时的默认结果(返回 normal,让人工判断)""" - print(f"[ImageAnalyzer] 识别失败,使用默认值: {reason}") - text_amount = "none" - text_layer_need = "no" - text_surcharge = 0 - layer_surcharge = 0 - return { - "complexity": "normal", - "reason": reason, - "subject": "", - "proc_type": "", - "quality": "", - "flatness": "", - "has_text": "no", - "text_amount": text_amount, - "text_layer_need": text_layer_need, - "text_surcharge": text_surcharge, - "layer_surcharge": layer_surcharge, - "has_face": "no", - "has_shadow": "no", - "risk": "none", - "feasibility": "yes", - "perspective": "no", - "aspect_ratio": "1:1", - "gemini_prompt": "", - "note": "", - "price_min": 20, - "price_max": 30, - "price_suggest": 25, - "success": False - } - - -# 全局实例 -image_analyzer = ImageAnalyzer() diff --git a/image/image_precheck.py b/image/image_precheck.py deleted file mode 100755 index facbf6f..0000000 --- a/image/image_precheck.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -""" -图片预检 - 下载后检查尺寸/格式/是否损坏,不合格直接拒单 -""" -import os -import logging -from typing import Tuple - -logger = logging.getLogger(__name__) - -# 可配置 -MIN_WIDTH = int(os.getenv("IMAGE_PRECHECK_MIN_WIDTH", "50")) -MIN_HEIGHT = int(os.getenv("IMAGE_PRECHECK_MIN_HEIGHT", "50")) -MAX_WIDTH = int(os.getenv("IMAGE_PRECHECK_MAX_WIDTH", "8000")) -MAX_HEIGHT = int(os.getenv("IMAGE_PRECHECK_MAX_HEIGHT", "8000")) -MIN_SIZE = int(os.getenv("IMAGE_PRECHECK_MIN_BYTES", "100")) # 至少 100 字节 -MAX_SIZE = int(os.getenv("IMAGE_PRECHECK_MAX_BYTES", "0")) # 0=不限制 -SUPPORTED_FORMATS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") - - -def precheck(local_path: str) -> Tuple[bool, str]: - """ - 预检图片文件。 - - Returns: - (ok, message) - ok=False 时 message 为拒单原因 - """ - if not os.path.exists(local_path): - return False, "图片文件不存在" - size = os.path.getsize(local_path) - if size < MIN_SIZE: - return False, f"图片太小({size} 字节),可能损坏或格式异常" - if MAX_SIZE > 0 and size > MAX_SIZE: - return False, f"图片过大({size/1024/1024:.1f}MB),超过 {MAX_SIZE/1024/1024:.0f}MB 限制" - - try: - from PIL import Image - with Image.open(local_path) as img: - w, h = img.size - if w < MIN_WIDTH or h < MIN_HEIGHT: - return False, f"图片尺寸过小({w}x{h}),最小 {MIN_WIDTH}x{MIN_HEIGHT}" - if w > MAX_WIDTH or h > MAX_HEIGHT: - return False, f"图片尺寸过大({w}x{h}),最大 {MAX_WIDTH}x{MAX_HEIGHT}" - img.verify() - except Exception as e: - return False, f"图片无法读取或已损坏:{str(e)[:50]}" - return True, "" diff --git a/image/image_processor.py b/image/image_processor.py deleted file mode 100755 index dec6739..0000000 --- a/image/image_processor.py +++ /dev/null @@ -1,328 +0,0 @@ -"""图片处理模块 - 调用 Gemini 作图API,含质检与自动重试""" -import os -import uuid -import tempfile -from typing import Optional, Dict, Any -from dotenv import load_dotenv - -load_dotenv() - -_OUTPUT_DIR = os.getenv("RESULT_IMAGE_DIR", "results") -_MAX_RETRIES = int(os.getenv("PROCESS_MAX_RETRIES", "2")) # 含首次共最多处理几次 - - -class ImageProcessor: - """图片处理 - 对接 GeminiExtractV2Service,含质检与重试""" - - def __init__(self): - os.makedirs(_OUTPUT_DIR, exist_ok=True) - - # ─── 内部工具 ──────────────────────────────────────────── - - async def _download(self, url: str) -> str: - """下载图片到临时文件,返回本地路径""" - import aiohttp - tmp = os.path.join(tempfile.gettempdir(), f"gemini_in_{uuid.uuid4().hex}.jpg") - headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/122.0.0.0 Safari/537.36" - ), - "Referer": "https://www.taobao.com/", - "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", - } - async with aiohttp.ClientSession(headers=headers) as session: - async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: - if resp.status != 200: - raise RuntimeError(f"下载图片失败: HTTP {resp.status}") - with open(tmp, "wb") as f: - f.write(await resp.read()) - return tmp - - async def _do_perspective(self, service, src: str, level: str) -> str: - """透视矫正,返回矫正后文件路径(失败则返回原路径)""" - out = os.path.join(tempfile.gettempdir(), f"gemini_persp_{uuid.uuid4().hex}.jpg") - ok, msg, _ = await service.correct_perspective(src, out, level=level) - if ok: - print(f"[ImageProcessor] 透视矫正完成") - return out - else: - print(f"[ImageProcessor] 透视矫正失败 ({msg}),跳过") - if os.path.exists(out): - os.remove(out) - return src - - @staticmethod - def _build_retry_prompt(gemini_prompt: str, qa_issue: str, qa_suggestion: str) -> str: - """ - 根据 QA 质检问题类型,智能调整重试提示词。 - 比简单追加建议更有针对性,让 Gemini 知道上次哪里出了问题。 - """ - base = gemini_prompt or "" - issue = (qa_issue or "").lower() - suggestion = qa_suggestion if qa_suggestion and qa_suggestion != "无" else "" - - # 背景不干净 - if any(kw in issue for kw in ["背景", "杂物", "多余", "白色不纯"]): - prefix = "【重要:背景必须是纯白色 #FFFFFF,去掉所有杂物和阴影】" - return prefix + ("\n" + base if base else "") - - # 清晰度/细节不足 - if any(kw in issue for kw in ["模糊", "清晰", "细节", "锐化", "分辨率"]): - prefix = "【重要:提升整体清晰度和细节,输出高分辨率版本,不要模糊】" - return prefix + ("\n" + base if base else "") - - # 内容缺失/截断 - if any(kw in issue for kw in ["缺失", "截断", "不完整", "边缘", "裁剪"]): - prefix = "【重要:保留主体完整内容,不要截断边缘,确保四角全部保留】" - return prefix + ("\n" + base if base else "") - - # 颜色偏差 - if any(kw in issue for kw in ["颜色", "色彩", "偏色", "色调"]): - prefix = "【重要:忠实还原原图颜色,不要改变色调或过度饱和】" - return prefix + ("\n" + base if base else "") - - # AI幻觉/变形 - if any(kw in issue for kw in ["幻觉", "变形", "失真", "扭曲", "ai生成"]): - prefix = "【重要:严格按原图内容处理,不要添加或改变任何图案细节】" - return prefix + ("\n" + base if base else "") - - # 没有匹配到具体类型,直接用质检建议 - if suggestion: - return (base + f"\n【上次问题:{qa_issue}。本次改进方向:{suggestion}】").strip() - - return base - - async def _do_main(self, service, src: str, gemini_prompt: str, aspect_ratio: str, - attempt: int, qa_issue: str = "", qa_suggestion: str = "") -> tuple[bool, str, str]: - """ - 执行一次主处理。 - 重试时根据 QA 问题类型智能调整提示词。 - - Returns: - (success, output_path, message) - """ - out_name = f"result_{uuid.uuid4().hex}.jpg" - output_path = os.path.join(_OUTPUT_DIR, out_name) - - if attempt == 1: - prompt = gemini_prompt or None - else: - prompt = self._build_retry_prompt(gemini_prompt, qa_issue, qa_suggestion) - print(f"[ImageProcessor] 重试策略 | 问题: {qa_issue} | 提示词: {(prompt or '')[:80]}...") - - print(f"[ImageProcessor] 主处理第 {attempt} 次 (比例={aspect_ratio})...") - success, message, _ = await service.extract_pattern( - input_path=src, - output_path=output_path, - custom_prompt=prompt, - aspect_ratio=aspect_ratio, - ) - return success, output_path, message - - # ─── 主入口 ────────────────────────────────────────────── - - async def process_image( - self, - image_url: str, - operation: str, - requirements: str = "", - gemini_prompt: str = "", - aspect_ratio: str = "1:1", - perspective: str = "no", - proc_type: str = "", - subject: str = "", - quality: str = "", - params: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """ - 完整处理流程:下载 → 透视矫正(可选)→ Gemini主处理 → 质检 → 重试(可选) - - Returns: - { - "success": bool, - "result_path": str, - "message": str, - "qa_score": int, # 质检得分 0-100 - "qa_pass": bool, # 是否通过质检 - "qa_issue": str, # 质检发现的问题 - "attempts": int, # 共处理了几次 - } - """ - from services.service_gemini import GeminiExtractV2Service - from image.image_qa import image_qa - - # Step 1: 下载原图 - try: - tmp_input = await self._download(image_url) - except Exception as e: - return { - "success": False, "result_path": "", "message": str(e), - "qa_score": 0, "qa_pass": False, "qa_issue": "下载失败", "attempts": 0, - } - - # Step 1.5: 敏感图片检测 - try: - from utils.content_filter import is_sensitive_image - sensitive, reason = await is_sensitive_image(tmp_input) - if sensitive: - if os.path.exists(tmp_input): - os.remove(tmp_input) - return { - "success": False, "result_path": "", "message": reason, - "qa_score": 0, "qa_pass": False, "qa_issue": "敏感图片", "attempts": 0, - } - except Exception as e: - print(f"[ImageProcessor] 敏感图片检测异常: {e},继续处理") - - # Step 1.6: 预检(尺寸/格式/损坏) - try: - from image.image_precheck import precheck - ok, msg = precheck(tmp_input) - if not ok: - if os.path.exists(tmp_input): - os.remove(tmp_input) - return { - "success": False, "result_path": "", "message": msg, - "qa_score": 0, "qa_pass": False, "qa_issue": "预检不通过", "attempts": 0, - } - except Exception as e: - print(f"[ImageProcessor] 预检异常: {e},继续处理") - - service = GeminiExtractV2Service() - tmp_files = [tmp_input] - try: - # Step 2: 透视矫正 - current_input = tmp_input - if perspective in ("mild", "strong"): - print(f"[ImageProcessor] 透视矫正中 (level={perspective})...") - corrected = await self._do_perspective(service, tmp_input, perspective) - if corrected != tmp_input: - tmp_files.append(corrected) - current_input = corrected - - # Step 3: 主处理 + 质检,最多 _MAX_RETRIES 次 - qa_result = {"score": 0, "pass": False, "issue": "未质检", "suggestion": "无"} - output_path = "" - last_message = "" - qa_issue = "" - qa_suggestion = "" - - for attempt in range(1, _MAX_RETRIES + 1): - ok, output_path, last_message = await self._do_main( - service, current_input, gemini_prompt, aspect_ratio, - attempt=attempt, qa_issue=qa_issue, qa_suggestion=qa_suggestion, - ) - - if not ok: - print(f"[ImageProcessor] 第 {attempt} 次处理失败: {last_message}") - if attempt < _MAX_RETRIES: - continue - return { - "success": False, "result_path": "", "message": last_message, - "qa_score": 0, "qa_pass": False, "qa_issue": "Gemini处理失败", "attempts": attempt, - } - - # Step 4: 质检 - print(f"[ImageProcessor] 质检中 (第 {attempt} 次结果)...") - qa_result = await image_qa.check( - original_path=current_input, - result_path=output_path, - proc_type=proc_type, - subject=subject, - quality=quality, - gemini_prompt=gemini_prompt, - ) - qa_issue = qa_result.get("issue", "") - qa_suggestion = qa_result.get("suggestion", "无") - - if qa_result["pass"]: - print(f"[ImageProcessor] 质检通过 ({qa_result['score']}分),共处理 {attempt} 次") - break - else: - print(f"[ImageProcessor] 质检不合格 ({qa_result['score']}分),问题: {qa_result['issue']}") - if attempt < _MAX_RETRIES: - # 清理这次不合格的结果 - if os.path.exists(output_path): - os.remove(output_path) - print(f"[ImageProcessor] 准备第 {attempt + 1} 次重试...") - else: - print(f"[ImageProcessor] 已达最大重试次数 {_MAX_RETRIES},保留最后结果,人工跟进") - - return { - "success": True, - "result_path": output_path, - "message": last_message, - "qa_score": qa_result.get("score", 0), - "qa_pass": qa_result.get("pass", False), - "qa_issue": qa_result.get("issue", ""), - "attempts": attempt, - } - - except Exception as e: - return { - "success": False, "result_path": "", "message": f"处理异常: {e}", - "qa_score": 0, "qa_pass": False, "qa_issue": str(e), "attempts": 0, - } - finally: - await service.cleanup() - for f in tmp_files: - if os.path.exists(f): - os.remove(f) - - async def enhance(self, image_url: str) -> Dict[str, Any]: - return await self.process_image(image_url, "enhance") - - async def remove_bg(self, image_url: str) -> Dict[str, Any]: - return await self.process_image(image_url, "remove_bg") - - async def resize(self, image_url: str, width: int, height: int = 0) -> Dict[str, Any]: - """ - 改尺寸:下载图片(或读取本地路径),按指定宽高缩放,保存到 results/。 - - Args: - image_url: 图片 URL 或本地路径 - width: 目标宽度(像素) - height: 目标高度(0=按宽度等比缩放) - - Returns: - {"success": bool, "result_path": str, "message": str} - """ - from PIL import Image - is_temp = image_url.startswith(("http://", "https://")) - try: - if is_temp: - tmp = await self._download(image_url) - else: - tmp = image_url - if not os.path.exists(tmp): - return {"success": False, "result_path": "", "message": f"文件不存在: {tmp}"} - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - - try: - img = Image.open(tmp).convert("RGB") - w_orig, h_orig = img.size - if width <= 0 or width > 10000: - return {"success": False, "result_path": "", "message": f"宽度无效: {width}"} - if height == 0: - ratio = width / w_orig - height = int(h_orig * ratio) - elif height <= 0 or height > 10000: - return {"success": False, "result_path": "", "message": f"高度无效: {height}"} - resized = img.resize((width, height), Image.Resampling.LANCZOS) - out_name = f"resize_{uuid.uuid4().hex}.jpg" - out_path = os.path.join(_OUTPUT_DIR, out_name) - resized.save(out_path, "JPEG", quality=95) - print(f"[ImageProcessor] 改尺寸完成: {w_orig}x{h_orig} → {width}x{height}") - return {"success": True, "result_path": out_path, "message": f"已改为 {width}x{height}"} - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - finally: - if is_temp and os.path.exists(tmp): - os.remove(tmp) - - -# 全局实例 -image_processor = ImageProcessor() diff --git a/image/image_qa.py b/image/image_qa.py deleted file mode 100755 index 416dd37..0000000 --- a/image/image_qa.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -图片处理结果质检模块 - -处理完成后,用视觉 AI 对比原图和结果图,判断是否符合客户需求。 -评分 0-100,低于阈值则判定不合格,触发重试或人工跟进。 -""" -import base64 -import os -import time -import asyncio -from typing import Optional -from dotenv import load_dotenv - -load_dotenv() - -_QA_PASS_SCORE = int(os.getenv("QA_PASS_SCORE", "70")) # 合格分数线,默认70 - -QA_PROMPT_TEMPLATE = """\ -你是一名专业的图片处理质检员,需要评估处理结果是否满足要求。 - -【处理类型】{proc_type} -【客户需求/Gemini提示词】{gemini_prompt} -【原图描述】主体:{subject},类型:{proc_type},质量:{quality} - -请对比左图(原图)和右图(处理结果),从以下维度打分(每项0-25分): - -1. 内容完整性:主体图案/内容是否完整保留,有无缺失、截断 -2. 畸变去除:褶皱/透视变形/背景是否已被清除 -3. 细节还原:颜色、线条、纹理等细节与原图的匹配程度 -4. 输出干净度:背景是否干净,有无多余内容、AI幻觉、模糊块 - -输出格式(严格按照此格式,每行一个字段): -完整性: <0-25> -畸变: <0-25> -细节: <0-25> -干净: <0-25> -总分: <0-100> -结论: -问题: <简述主要问题,不超过30字,无问题填"无"> -建议: <如果fail,给出重试改进建议,不超过40字,pass则填"无"> -""" - - -class ImageQA: - """处理结果质检器""" - - def __init__(self): - self.api_key = os.getenv("OPENAI_API_KEY") - self.base_url = os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4") - self.model = os.getenv("VISION_MODEL", "glm-4v-flash") - self.pass_score = _QA_PASS_SCORE - - def _to_base64(self, path: str) -> Optional[str]: - try: - with open(path, "rb") as f: - return base64.b64encode(f.read()).decode("utf-8") - except Exception as e: - print(f"[ImageQA] 读取图片失败 {path}: {e}") - return None - - def _parse(self, text: str) -> dict: - def p(key): - for line in text.splitlines(): - line = line.strip() - for k in [f"{key}:", f"{key}:"]: - if line.startswith(k): - return line[len(k):].strip() - return "" - - try: - score = int(p("总分")) - except ValueError: - score = 0 - - conclusion = p("结论").lower() - if conclusion not in ("pass", "fail"): - conclusion = "pass" if score >= self.pass_score else "fail" - - return { - "score": score, - "pass": conclusion == "pass", - "issue": p("问题"), - "suggestion": p("建议"), - "detail": { - "completeness": p("完整性"), - "distortion": p("畸变"), - "detail": p("细节"), - "clean": p("干净"), - }, - "raw": text, - } - - async def check( - self, - original_path: str, - result_path: str, - proc_type: str = "", - subject: str = "", - quality: str = "", - gemini_prompt: str = "", - ) -> dict: - """ - 质检处理结果。 - - Args: - original_path: 原图本地路径 - result_path: 处理结果本地路径 - proc_type: 处理类型(印花提取 / 高清修复等) - subject: 主体描述 - quality: 原图质量 - gemini_prompt: 传给 Gemini 的提示词(体现客户需求) - - Returns: - { - "score": int, # 0-100 - "pass": bool, # 是否合格 - "issue": str, # 主要问题 - "suggestion": str, # 重试改进建议 - "detail": dict, # 各维度分数 - } - """ - if not self.api_key: - print("[ImageQA] 未配置 API Key,跳过质检,默认通过") - return {"score": 80, "pass": True, "issue": "无", "suggestion": "无", "detail": {}} - - orig_b64 = self._to_base64(original_path) - result_b64 = self._to_base64(result_path) - if not orig_b64 or not result_b64: - print("[ImageQA] 图片读取失败,跳过质检") - return {"score": 75, "pass": True, "issue": "质检图片读取失败", "suggestion": "无", "detail": {}} - - prompt = QA_PROMPT_TEMPLATE.format( - proc_type=proc_type or "图片处理", - subject=subject or "未知", - quality=quality or "未知", - gemini_prompt=gemini_prompt or "按标准处理", - ) - - start = time.monotonic() - try: - from openai import AsyncOpenAI - client = AsyncOpenAI(base_url=self.base_url, api_key=self.api_key) - - response = await client.responses.create( - model=self.model, - input=[ - { - "role": "user", - "content": [ - { - "type": "input_image", - "image_url": f"data:image/jpeg;base64,{orig_b64}", - }, - { - "type": "input_image", - "image_url": f"data:image/jpeg;base64,{result_b64}", - }, - { - "type": "input_text", - "text": prompt, - }, - ], - } - ], - ) - content = response.output_text - elapsed = time.monotonic() - start - result = self._parse(content) - result["elapsed"] = round(elapsed, 1) - - status = "✓ 合格" if result["pass"] else "✗ 不合格" - print(f"[ImageQA] {status} | 得分: {result['score']}/100 | 问题: {result['issue']} | 耗时: {elapsed:.1f}s") - if not result["pass"]: - print(f"[ImageQA] 改进建议: {result['suggestion']}") - try: - from utils.api_cost_tracker import record - record("gemini_vision", count=1) - except Exception: - pass - return result - - except Exception as e: - elapsed = time.monotonic() - start - print(f"[ImageQA] 质检失败 ({elapsed:.1f}s): {e}") - return {"score": 75, "pass": True, "issue": f"质检异常: {e}", "suggestion": "无", "detail": {}} - - -# 全局实例 -image_qa = ImageQA() diff --git a/image/image_tools.py b/image/image_tools.py deleted file mode 100755 index a29cc8d..0000000 --- a/image/image_tools.py +++ /dev/null @@ -1,293 +0,0 @@ -""" -图片处理独立工具 - 可单独调用,也可被主流程复用。 - -主流程(付款触发)不变,这些工具供 AI 按需组合使用。 -""" -import os -import uuid -import tempfile -from typing import Dict, Any, Optional - -_OUTPUT_DIR = os.getenv("RESULT_IMAGE_DIR", "results") -os.makedirs(_OUTPUT_DIR, exist_ok=True) - - -async def _download(url: str) -> str: - """下载图片到临时文件""" - import aiohttp - tmp = os.path.join(tempfile.gettempdir(), f"img_{uuid.uuid4().hex}.jpg") - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Referer": "https://www.taobao.com/", - } - async with aiohttp.ClientSession(headers=headers) as session: - async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: - if resp.status != 200: - raise RuntimeError(f"下载失败: HTTP {resp.status}") - with open(tmp, "wb") as f: - f.write(await resp.read()) - return tmp - - -async def remove_background(image_url: str, save_path: str = "") -> Dict[str, Any]: - """ - 【独立工具】去背景 → 纯白/纯色背景。 - 输入 URL 或本地路径,输出白底产品图。 - """ - from image.perspective_fix import _gemini_call, PROMPT_WHITE_BG - tmp = None - try: - if image_url.startswith(("http://", "https://")): - tmp = await _download(image_url) - src = tmp - else: - src = image_url - if not os.path.exists(src): - return {"success": False, "result_path": "", "message": f"文件不存在: {src}"} - - out = save_path or os.path.join(_OUTPUT_DIR, f"bg_{uuid.uuid4().hex}.jpg") - ok = await _gemini_call(src, out, PROMPT_WHITE_BG, aspect_ratio="auto", label="去背景") - if ok: - return {"success": True, "result_path": out, "message": "去背景完成"} - return {"success": False, "result_path": "", "message": "去背景失败"} - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - - -async def perspective_correct(image_url: str, save_path: str = "") -> Dict[str, Any]: - """ - 【独立工具】透视矫正。 - 输入需为白底图(可先调 remove_background),输出展平后的图。 - """ - import cv2 - from image.perspective_fix import find_quad, four_point_transform - tmp = None - try: - if image_url.startswith(("http://", "https://")): - tmp = await _download(image_url) - src = tmp - else: - src = image_url - if not os.path.exists(src): - return {"success": False, "result_path": "", "message": f"文件不存在: {src}"} - - img = cv2.imread(src) - if img is None: - return {"success": False, "result_path": "", "message": "无法读取图片"} - pts = find_quad(img) - if pts is None: - return {"success": False, "result_path": "", "message": "未检测到四边形,无法透视矫正"} - warped = four_point_transform(img, pts) - out = save_path or os.path.join(_OUTPUT_DIR, f"persp_{uuid.uuid4().hex}.jpg") - cv2.imwrite(out, warped, [cv2.IMWRITE_JPEG_QUALITY, 95]) - return {"success": True, "result_path": out, "message": "透视矫正完成"} - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - - -async def extract_pattern(image_url: str, prompt: str = "", aspect_ratio: str = "1:1", - save_path: str = "") -> Dict[str, Any]: - """ - 【独立工具】印花提取/主处理。 - 按提示词和比例输出处理后的图。 - """ - from services.service_gemini import GeminiExtractV2Service - tmp = None - try: - if image_url.startswith(("http://", "https://")): - tmp = await _download(image_url) - src = tmp - else: - src = image_url - if not os.path.exists(src): - return {"success": False, "result_path": "", "message": f"文件不存在: {src}"} - - out = save_path or os.path.join(_OUTPUT_DIR, f"extract_{uuid.uuid4().hex}.jpg") - service = GeminiExtractV2Service() - try: - ok, msg, _ = await service.extract_pattern( - input_path=src, output_path=out, - custom_prompt=prompt or None, aspect_ratio=aspect_ratio, - ) - if ok and os.path.exists(out): - return {"success": True, "result_path": out, "message": "提取完成"} - return {"success": False, "result_path": "", "message": msg or "提取失败"} - finally: - await service.cleanup() - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - - -async def enhance_image(image_url: str, save_path: str = "") -> Dict[str, Any]: - """ - 【独立工具】高清增强。 - 使用 Qwen RunningHub,失败时降级 Gemini。 - """ - from services.service_qwen import 清晰化_api - from image.perspective_fix import _gemini_call, PROMPT_ENHANCE_SIMPLE - tmp = None - try: - if image_url.startswith(("http://", "https://")): - tmp = await _download(image_url) - src = tmp - else: - src = image_url - if not os.path.exists(src): - return {"success": False, "result_path": "", "message": f"文件不存在: {src}"} - - out = save_path or os.path.join(_OUTPUT_DIR, f"enh_{uuid.uuid4().hex}.jpg") - ok = await 清晰化_api(img_path=src, save_path=out) - if not ok: - ok = await _gemini_call(src, out, PROMPT_ENHANCE_SIMPLE, aspect_ratio="auto", label="增强") - if ok: - return {"success": True, "result_path": out, "message": "高清增强完成"} - return {"success": False, "result_path": "", "message": "高清增强失败"} - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - - -async def color_match_images(orig_url: str, result_url: str, save_path: str = "", - strength: float = 0.75) -> Dict[str, Any]: - """ - 【独立工具】颜色匹配。将 result 的色调匹配到 orig。 - """ - import cv2 - from image.perspective_fix import _color_match - tmp_orig = tmp_result = None - try: - if orig_url.startswith(("http://", "https://")): - tmp_orig = await _download(orig_url) - orig_path = tmp_orig - else: - orig_path = orig_url - if result_url.startswith(("http://", "https://")): - tmp_result = await _download(result_url) - result_path = tmp_result - else: - result_path = result_url - - orig_img = cv2.imread(orig_path) - result_img = cv2.imread(result_path) - if orig_img is None or result_img is None: - return {"success": False, "result_path": "", "message": "图片读取失败"} - matched = _color_match(orig_img, result_img, strength=strength) - out = save_path or os.path.join(_OUTPUT_DIR, f"color_{uuid.uuid4().hex}.jpg") - cv2.imwrite(out, matched, [cv2.IMWRITE_JPEG_QUALITY, 95]) - return {"success": True, "result_path": out, "message": f"颜色匹配完成(强度{strength:.0%})"} - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - finally: - for t in (tmp_orig, tmp_result): - if t and os.path.exists(t): - os.remove(t) - - -async def trim_border(image_url: str, save_path: str = "") -> Dict[str, Any]: - """ - 【独立工具】裁切四周背景边(支持任意颜色:白/黄/米等)。 - """ - import cv2 - from image.perspective_fix import tool_trim_white_border - tmp = None - try: - if image_url.startswith(("http://", "https://")): - tmp = await _download(image_url) - src = tmp - else: - src = image_url - if not os.path.exists(src): - return {"success": False, "result_path": "", "message": f"文件不存在: {src}"} - - img = cv2.imread(src) - if img is None: - return {"success": False, "result_path": "", "message": "无法读取图片"} - trimmed, did_trim, info = tool_trim_white_border(img) - out = save_path or os.path.join(_OUTPUT_DIR, f"trim_{uuid.uuid4().hex}.jpg") - cv2.imwrite(out, trimmed, [cv2.IMWRITE_JPEG_QUALITY, 95]) - return {"success": True, "result_path": out, "message": "裁边完成" if did_trim else "无需裁边"} - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - - -async def vectorize_to_eps(image_url: str, save_path: str = "") -> Dict[str, Any]: - """ - 【独立工具】矢量化 - 将图片转为 EPS 矢量文件。 - 客户要做矢量图、转 EPS、转 AI 格式时调用。 - """ - tmp = None - try: - if image_url.startswith(("http://", "https://")): - tmp = await _download(image_url) - src = tmp - else: - src = image_url - if not os.path.exists(src): - return {"success": False, "result_path": "", "message": f"文件不存在: {src}"} - - from services.service_vectorizer import VectorizerService - svc = VectorizerService() - out = save_path or os.path.join(_OUTPUT_DIR, f"vec_{uuid.uuid4().hex}.eps") - result_path = await svc.image_to_eps(src, save_eps_path=out) - if result_path and os.path.exists(result_path): - return {"success": True, "result_path": result_path, "message": "矢量化完成,已生成 EPS 文件"} - return {"success": False, "result_path": "", "message": "矢量化失败"} - except ImportError as e: - return {"success": False, "result_path": "", "message": f"矢量化服务不可用: {e}"} - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) - - -async def meitu_enhance(image_url: str, mode: str = "standard", save_path: str = "") -> Dict[str, Any]: - """ - 【独立工具】美图画质增强。 - 模式: crystal(极速重绘) standard(标准) enhance(增强) hdr(HDR) portrait(人像优化) - 客户要画质增强、清晰化、美图处理时调用。 - """ - tmp = None - try: - if image_url.startswith(("http://", "https://")): - tmp = await _download(image_url) - src = tmp - else: - src = image_url - if not os.path.exists(src): - return {"success": False, "result_path": "", "message": f"文件不存在: {src}"} - - from pathlib import Path - from services.service_meitu import MeituAPIService - svc = MeituAPIService() - output_dir = Path(_OUTPUT_DIR) - result = await svc.process_image(src, mode=mode, output_dir=output_dir) - out = result.get("processed_path") - if out and os.path.exists(str(out)): - if save_path: - import shutil - shutil.copy(str(out), save_path) - out = save_path - return {"success": True, "result_path": str(out), "message": f"画质增强完成({result.get('mode_name', mode)})"} - return {"success": False, "result_path": "", "message": "美图处理失败"} - except ImportError as e: - return {"success": False, "result_path": "", "message": f"美图服务不可用: {e}"} - except Exception as e: - return {"success": False, "result_path": "", "message": str(e)} - finally: - if tmp and os.path.exists(tmp): - os.remove(tmp) diff --git a/image/perspective_fix.py b/image/perspective_fix.py deleted file mode 100755 index 09eab7b..0000000 --- a/image/perspective_fix.py +++ /dev/null @@ -1,651 +0,0 @@ -""" -透视矫正三步流程: - Step1: Gemini 去背景 → 纯白背景 - Step2: OpenCV 在白背景图上检测四角 → warpPerspective 展平 - Step3: Gemini 对展平结果做高清增强 - -用法: - python perspective_fix.py <图片路径或URL> [--debug] [--skip-step1] [--skip-step3] -""" -import sys, io -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") -sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") - -import os, asyncio, uuid, tempfile -import numpy as np -import cv2 -from dotenv import load_dotenv - -load_dotenv() - -_OUTPUT_DIR = os.getenv("RESULT_IMAGE_DIR", "results") -os.makedirs(_OUTPUT_DIR, exist_ok=True) - -# ═══════════════════════════════════════════════════════════════ -# Gemini 辅助函数 -# ═══════════════════════════════════════════════════════════════ - -async def _gemini_call(input_path: str, output_path: str, prompt: str, - aspect_ratio: str = "1:1", label: str = "") -> bool: - from services.service_gemini import GeminiExtractV2Service - service = GeminiExtractV2Service() - try: - ok, msg, _ = await service.extract_pattern( - input_path=input_path, - output_path=output_path, - custom_prompt=prompt, - aspect_ratio=aspect_ratio, - ) - status = "成功" if ok else "失败" - print(f" [{label}] Gemini {status}: {msg[:80]}") - return ok and os.path.exists(output_path) - except Exception as e: - print(f" [{label}] Gemini 异常: {e}") - return False - finally: - await service.cleanup() - - -PROMPT_WHITE_BG = ( - "请处理这张图片:\n" - "1. 识别图中的地毯/地垫/印花布料/产品本体作为主体\n" - "2. 去掉主体上面放置的所有物品(杯子、碗、餐具、装饰品等),只保留地垫本身\n" - "3. 把所有背景(桌面、地板、墙壁、阴影)全部替换为纯白色(#FFFFFF)\n" - "4. 保持地垫/产品的颜色、图案、边缘完全不变\n" - "输出:只有主体产品、纯白背景、无杂物的干净产品图。" -) - -# 当第一次去背景效果不好时(白色覆盖率过低),用更强硬的提示词重试 -PROMPT_WHITE_BG_STRONG = ( - "严格执行:将这张图的背景彻底替换为纯白色 RGB(255,255,255)。\n" - "只保留图片中央的产品/地毯/布料主体,其他所有区域(桌面/地板/墙/阴影/物品)" - "一律改为纯白色。产品边缘要干净锐利,不留任何半透明或灰色区域。\n" - "重要:不论主体上摆放了什么东西,统统去掉,只输出产品本身+白色背景。" -) - -PROMPT_ENHANCE = ( - "请对这张已展平的图案进行高清增强:提升整体清晰度和色彩饱和度," - "修复边缘锯齿,补全缺失细节,输出印刷级高质量平面图,背景保持纯白。" -) - -# Step3 增强失败时的兜底提示词(更简单,成功率更高) -PROMPT_ENHANCE_SIMPLE = ( - "请提升这张图片的清晰度和画质,输出高清版本,背景保持纯白。" -) - - -def _measure_white_coverage(image: np.ndarray) -> float: - """返回图片中白色像素的百分比,用于判断去背景效果""" - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - _, mask = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY) - return float(np.sum(mask == 255)) / mask.size - - -def _color_match(source: np.ndarray, target: np.ndarray, - strength: float = 0.75, exclude_white: bool = True) -> np.ndarray: - """ - 将 target 的色调匹配到 source(类 PS「匹配颜色」)。 - 使用 LAB 色彩空间 Reinhard 均值/标准差迁移。 - - Args: - source: 原图(色彩参考来源) - target: 待调整图(处理后结果) - strength: 迁移强度 0.0-1.0,推荐 0.6-0.85 - exclude_white: 统计时排除白色像素,避免背景影响肤色/图案计算 - Returns: - 调色后的 BGR 图像 - """ - src_f = source.astype(np.float32) / 255.0 - tgt_f = target.astype(np.float32) / 255.0 - - src_lab = cv2.cvtColor(src_f, cv2.COLOR_BGR2Lab) - tgt_lab = cv2.cvtColor(tgt_f, cv2.COLOR_BGR2Lab) - result = tgt_lab.copy() - - for ch in range(3): - if exclude_white: - # 排除极亮像素(L > 95)统计,只看图案区域 - src_mask = src_lab[:, :, 0] < 95 - tgt_mask = tgt_lab[:, :, 0] < 95 - src_vals = src_lab[:, :, ch][src_mask] - tgt_vals = tgt_lab[:, :, ch][tgt_mask] - else: - src_vals = src_lab[:, :, ch].ravel() - tgt_vals = tgt_lab[:, :, ch].ravel() - - if src_vals.size == 0 or tgt_vals.size == 0: - continue - - src_mean, src_std = float(src_vals.mean()), float(src_vals.std()) - tgt_mean, tgt_std = float(tgt_vals.mean()), float(tgt_vals.std()) - - if tgt_std < 1e-6: - continue - - # Reinhard 迁移:先归一化到目标,再重映射到源分布 - shifted = (tgt_lab[:, :, ch] - tgt_mean) / tgt_std * src_std + src_mean - # 按 strength 混合:strength=1 完全迁移,0 保持不变 - result[:, :, ch] = shifted * strength + tgt_lab[:, :, ch] * (1.0 - strength) - - result_bgr = cv2.cvtColor(result, cv2.COLOR_Lab2BGR) - result_bgr = np.clip(result_bgr * 255, 0, 255).astype(np.uint8) - - print(f" [颜色匹配] 强度={strength:.0%} | " - f"源均值L={src_lab[:,:,0].mean():.1f} → 目标均值L={tgt_lab[:,:,0].mean():.1f}") - return result_bgr - - -# ═══════════════════════════════════════════════════════════════ -# OpenCV 透视矫正 -# ═══════════════════════════════════════════════════════════════ - -def order_points(pts: np.ndarray) -> np.ndarray: - """ - 把四个点排列为 [左上, 右上, 右下, 左下]。 - 使用质心角度排序,对矩形、菱形、平行四边形等各种透视形状均适用。 - """ - cx, cy = pts[:, 0].mean(), pts[:, 1].mean() - # 计算每个点相对质心的角度(从正上方顺时针) - angles = np.arctan2(pts[:, 1] - cy, pts[:, 0] - cx) - # 顺时针排序:从右上开始(角度最小的) - order = np.argsort(angles) - sorted_pts = pts[order] - # 找到最左上角作为起点(x+y 最小) - s = sorted_pts.sum(axis=1) - start = np.argmin(s) - # 从左上角开始顺时针排列 → [左上, 右上, 右下, 左下] - indices = [(start + i) % 4 for i in range(4)] - rect = sorted_pts[indices].astype("float32") - return rect - - -def four_point_transform(image: np.ndarray, pts: np.ndarray) -> np.ndarray: - rect = order_points(pts) - tl, tr, br, bl = rect - - w1 = np.linalg.norm(br - bl) - w2 = np.linalg.norm(tr - tl) - h1 = np.linalg.norm(tr - br) - h2 = np.linalg.norm(tl - bl) - W = int(max(w1, w2)) - H = int(max(h1, h2)) - - print(f" [CV] 角点: TL={tl.astype(int)} TR={tr.astype(int)} BR={br.astype(int)} BL={bl.astype(int)}") - print(f" [CV] 矫正后目标尺寸: {W}x{H}") - - dst = np.array([ - [0, 0 ], - [W - 1, 0 ], - [W - 1, H - 1], - [0, H - 1], - ], dtype="float32") - - M = cv2.getPerspectiveTransform(rect, dst) - warped = cv2.warpPerspective( - image, M, (W, H), - flags=cv2.INTER_LANCZOS4, - borderMode=cv2.BORDER_CONSTANT, - borderValue=(255, 255, 255), - ) - return warped - - -def _detect_bg_color(image: np.ndarray, corner_size: int = 24) -> np.ndarray: - """ - 从图片四个角落采样,估计背景颜色(BGR)。 - 适用于白色、米色、黄色、灰色等各种背景。 - """ - H, W = image.shape[:2] - cs = min(corner_size, H // 5, W // 5) - corners = [ - image[:cs, :cs], # 左上 - image[:cs, W-cs:], # 右上 - image[H-cs:, :cs], # 左下 - image[H-cs:, W-cs:], # 右下 - ] - pixels = np.concatenate([c.reshape(-1, 3) for c in corners], axis=0) - bg = np.median(pixels, axis=0).astype(np.uint8) - return bg # BGR - - -def tool_trim_white_border(image: np.ndarray, - tolerance: int = 18, - bg_ratio: float = 0.90, - padding: int = 4) -> tuple[np.ndarray, bool, dict]: - """ - 【Tool】智能背景边裁切(支持任意背景色:白/黄/米/灰等)。 - - 算法: - 1. 从四角采样估计背景色 - 2. 逐行/列扫描:若该行/列中 bg_ratio 以上的像素与背景色差异 <= tolerance,则为背景行/列 - 3. 找到内容区域边界后裁切 - - Returns: - (裁切后图片, 是否裁切, 详情dict) - """ - H, W = image.shape[:2] - bg_color = _detect_bg_color(image) - img_f = image.astype(np.int32) - - # 每个像素与背景色的最大通道差异 - diff = np.abs(img_f - bg_color.astype(np.int32)).max(axis=2) # H x W - is_bg = diff <= tolerance # True = 接近背景色 - - row_bg_ratio = is_bg.mean(axis=1) # 每行的背景像素占比 - col_bg_ratio = is_bg.mean(axis=0) # 每列的背景像素占比 - - top = next((i for i in range(H) if row_bg_ratio[i] < bg_ratio), H) - bottom = next((i for i in range(H-1,-1,-1) if row_bg_ratio[i] < bg_ratio), -1) + 1 - left = next((i for i in range(W) if col_bg_ratio[i] < bg_ratio), W) - right = next((i for i in range(W-1,-1,-1) if col_bg_ratio[i] < bg_ratio), -1) + 1 - - border_top = top - border_bottom = H - bottom - border_left = left - border_right = W - right - max_border = max(border_top, border_bottom, border_left, border_right) - - bg_hex = "#{:02X}{:02X}{:02X}".format(int(bg_color[2]), int(bg_color[1]), int(bg_color[0])) - info = {"top": border_top, "bottom": border_bottom, - "left": border_left, "right": border_right, "bg_color": bg_hex} - - if max_border < 5: - print(f" [裁边] 背景色{bg_hex} | 上{border_top} 下{border_bottom} 左{border_left} 右{border_right}px → 无需裁切") - return image, False, info - - y1 = max(0, top - padding) - y2 = min(H, bottom + padding) - x1 = max(0, left - padding) - x2 = min(W, right + padding) - cropped = image[y1:y2, x1:x2] - ch, cw = cropped.shape[:2] - print(f" [裁边] 背景色{bg_hex} | 上{border_top} 下{border_bottom} 左{border_left} 右{border_right}px → 裁切 {W}x{H}→{cw}x{ch}") - return cropped, True, info - - -async def tool_color_match(orig_img: np.ndarray, result_img: np.ndarray, - strength: float = 0.75) -> np.ndarray: - """【Tool】颜色匹配(封装版,供 AI 决策层调用)""" - return _color_match(orig_img, result_img, strength=strength) - - -async def ai_decide_postprocess(orig_img: np.ndarray, result_img: np.ndarray) -> dict: - """ - 【AI 决策层】用视觉模型分析出图效果,决定是否需要颜色匹配和白边裁切。 - - Returns: - { - "need_color_match": bool, - "color_strength": float, # 0.5-0.9 - "need_trim": bool, - "reason": str, - } - """ - import base64 - from dotenv import load_dotenv - load_dotenv() - api_key = os.getenv("OPENAI_API_KEY") - base_url = os.getenv("OPENAI_BASE_URL") - model = os.getenv("VISION_MODEL", "glm-4v-flash") - - # 无 API 时默认两个都做 - if not api_key: - return {"need_color_match": True, "color_strength": 0.75, - "need_trim": True, "reason": "无API Key,默认执行"} - - def _encode(img: np.ndarray) -> str: - resized = cv2.resize(img, (512, 512)) - _, buf = cv2.imencode(".jpg", resized, [cv2.IMWRITE_JPEG_QUALITY, 80]) - return base64.b64encode(buf).decode() - - orig_b64 = _encode(orig_img) - result_b64 = _encode(result_img) - - prompt = ( - "你是图片后处理决策助手。图一是原图,图二是AI处理后的结果图。请判断:\n\n" - "【问题1】颜色差异:处理后图片的整体色调与原图相比,差异是否明显?\n" - "(明显=色调/饱和度/冷暖差异很大;轻微=有轻微偏差;无=颜色基本一致)\n\n" - "【问题2】多余边框:处理后图片四周是否有不属于图案内容的多余空白边框?\n" - "注意:边框颜色不一定是白色,也可能是黄色、米色、灰色等任何纯色。\n" - "判断标准:图案内容的外围是否有一圈明显的纯色空白带。\n\n" - "严格按格式回答(每行一个字段,不要多余内容):\n" - "颜色差异: <明显|轻微|无>\n" - "多余边框: <有|无>\n" - "边框位置: <有边框的方向如「上下」,没有则填无>" - ) - - try: - from openai import AsyncOpenAI - client = AsyncOpenAI(base_url=base_url, api_key=api_key) - response = await client.chat.completions.create( - model=model, - messages=[{ - "role": "user", - "content": [ - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{orig_b64}"}}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{result_b64}"}}, - {"type": "text", "text": prompt}, - ], - }], - ) - text = response.choices[0].message.content or "" - print(f" [AI决策] 原始回答: {text.strip()[:120]}") - - def _get(key): - for line in text.splitlines(): - line = line.strip() - if line.startswith(key): - return line.split(":", 1)[-1].strip() - return "" - - color_level = _get("颜色差异") - has_border = "有" in _get("多余边框") - border_pos = _get("边框位置") - - strength_map = {"明显": 0.80, "轻微": 0.55, "无": 0.0} - color_strength = strength_map.get(color_level, 0.75) - need_color = color_strength > 0 - - reason = f"颜色差异={color_level or '?'}, 边框={'有('+border_pos+')' if has_border else '无'}" - print(f" [AI决策] {reason} → 颜色匹配={'✓' if need_color else '✗'}(强度{color_strength:.0%}), 裁边={'✓' if has_border else '✗'}") - - return { - "need_color_match": need_color, - "color_strength": color_strength, - "need_trim": has_border, - "reason": reason, - } - - except Exception as e: - print(f" [AI决策] 调用失败({e}),默认执行颜色匹配+裁边") - return {"need_color_match": True, "color_strength": 0.75, - "need_trim": True, "reason": f"AI决策失败: {e}"} - - -def _points_are_unique(pts: np.ndarray, min_dist: float = 20.0) -> bool: - """检查4个角点两两之间距离都大于 min_dist,防止重复点导致退化变换""" - for i in range(len(pts)): - for j in range(i + 1, len(pts)): - if np.linalg.norm(pts[i] - pts[j]) < min_dist: - return False - return True - - -def find_quad(image: np.ndarray): - """ - 在白背景图上检测主体四边形角点。 - 策略(按优先级): - 1. 二值化 + approxPolyDP(epsilon 从小到大尝试) - 2. 凸包取极值四点(最左/最右/最上/最下) - 3. minAreaRect 四角 - """ - h, w = image.shape[:2] - img_area = h * w - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - - # ── 获取主体轮廓 ────────────────────────────────────────── - _, thresh = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY_INV) - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 20)) - closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) - - cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - if not cnts: - edges = cv2.Canny(gray, 30, 100) - k2 = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 10)) - closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, k2) - cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - - if not cnts: - print(" [CV] 无法检测轮廓") - return None - - c = max(cnts, key=cv2.contourArea) - area = cv2.contourArea(c) - print(f" [CV] 主体轮廓面积: {area:.0f} / {img_area} ({area/img_area*100:.1f}%)") - if area < img_area * 0.05: - print(" [CV] 面积太小,背景可能去除不完全") - return None - - peri = cv2.arcLength(c, True) - - # ── 策略1:approxPolyDP,epsilon 逐步放大直到得到4个唯一角点 ── - for eps_ratio in [0.02, 0.03, 0.04, 0.05, 0.06]: - approx = cv2.approxPolyDP(c, eps_ratio * peri, True) - pts = approx.reshape(-1, 2).astype("float32") - if len(pts) == 4 and _points_are_unique(pts): - print(f" [CV] approxPolyDP 成功 (eps={eps_ratio}), 4个唯一角点") - return pts - print(f" [CV] approxPolyDP eps={eps_ratio}: {len(pts)} 顶点,唯一={_points_are_unique(pts) if len(pts)==4 else 'N/A'}") - - # ── 策略2:凸包极值四点(最左/最上/最右/最下)───────────── - hull = cv2.convexHull(c).reshape(-1, 2).astype("float32") - if len(hull) >= 4: - # 取4个极值方向的点 - left = hull[np.argmin(hull[:, 0])] # 最左 - right = hull[np.argmax(hull[:, 0])] # 最右 - top = hull[np.argmin(hull[:, 1])] # 最上 - bottom = hull[np.argmax(hull[:, 1])] # 最下 - pts = np.array([left, top, right, bottom], dtype="float32") - if _points_are_unique(pts): - print(f" [CV] 使用凸包极值四点: L={left.astype(int)} T={top.astype(int)} R={right.astype(int)} B={bottom.astype(int)}") - return pts - - # ── 策略3:minAreaRect 四角(兜底)───────────────────────── - print(f" [CV] 兜底:使用 minAreaRect") - rect = cv2.minAreaRect(c) - box = cv2.boxPoints(rect).astype("float32") - return box - - -def save_debug_img(image: np.ndarray, pts, path: str): - """保存带角点标注的调试图""" - dbg = image.copy() - if pts is not None: - rect = order_points(pts) - labels = ["TL", "TR", "BR", "BL"] - colors = [(0,0,255), (0,255,0), (255,0,0), (0,165,255)] - for i, (px, py) in enumerate(rect): - cv2.circle(dbg, (int(px), int(py)), 12, colors[i], -1) - cv2.putText(dbg, labels[i], (int(px)+15, int(py)), - cv2.FONT_HERSHEY_SIMPLEX, 1.2, colors[i], 3) - box = rect.reshape((-1,1,2)).astype(np.int32) - cv2.polylines(dbg, [box], True, (0,0,255), 3) - cv2.imwrite(path, dbg, [cv2.IMWRITE_JPEG_QUALITY, 90]) - print(f" [Debug] 调试图: {path}") - - -# ═══════════════════════════════════════════════════════════════ -# 主流程 -# ═══════════════════════════════════════════════════════════════ - -async def process(src: str, debug: bool = False, - skip_step1: bool = False, skip_step3: bool = False) -> str | None: - uid = uuid.uuid4().hex - tmp = [] # 临时文件列表,最后统一清理 - - # ── 下载(URL 情况)────────────────────────────────────── - if src.startswith("http"): - import aiohttp - dl = os.path.join(tempfile.gettempdir(), f"pfix_dl_{uid}.jpg") - tmp.append(dl) - print("[下载] 原图中...") - async with aiohttp.ClientSession(headers={ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", - "Referer": "https://www.taobao.com/", - }) as sess: - async with sess.get(src, timeout=aiohttp.ClientTimeout(total=30)) as r: - if r.status != 200: - print(f"[下载] 失败: HTTP {r.status}") - return None - with open(dl, "wb") as f: - f.write(await r.read()) - local_src = dl - else: - local_src = src - - current = local_src # 当前处理中的文件 - orig_img = cv2.imread(local_src) # 保留原图用于颜色匹配 - # 记录原图宽高比,用于检测 Gemini 旋转问题 - orig_ratio = (orig_img.shape[1] / orig_img.shape[0]) if orig_img is not None else 1.0 - - try: - # ── Step 1: Gemini 去背景 → 白背景 ────────────────── - if not skip_step1: - print("\n" + "─"*50) - print("Step 1 / 3 | Gemini 去背景 → 白色背景") - print("─"*50) - s1_out = os.path.join(tempfile.gettempdir(), f"pfix_s1_{uid}.jpg") - tmp.append(s1_out) - ok = await _gemini_call(current, s1_out, PROMPT_WHITE_BG, - aspect_ratio="auto", label="去背景") - if ok: - # 检查白色覆盖率,判断背景去除是否充分 - s1_img = cv2.imread(s1_out) - white_pct = _measure_white_coverage(s1_img) if s1_img is not None else 0.0 - print(f" [去背景] 白色覆盖率: {white_pct:.1%}", end="") - if white_pct < 0.20: - # 背景去除太差,用强化提示词重试 - print(" → 太低,强化提示词重试...") - s1_retry = os.path.join(tempfile.gettempdir(), f"pfix_s1r_{uid}.jpg") - tmp.append(s1_retry) - ok2 = await _gemini_call(current, s1_retry, PROMPT_WHITE_BG_STRONG, - aspect_ratio="auto", label="去背景(强化)") - if ok2: - r_img = cv2.imread(s1_retry) - retry_pct = _measure_white_coverage(r_img) if r_img is not None else 0.0 - print(f" [去背景] 重试白色覆盖率: {retry_pct:.1%}", end="") - if retry_pct >= white_pct: - print(" → 效果更好,采用重试结果") - current = s1_retry - else: - print(" → 效果未提升,保留首次结果") - current = s1_out - else: - print(" [去背景] 重试失败,保留首次结果") - current = s1_out - else: - print(" → 合格") - current = s1_out - else: - print(" Step1 失败,用原图继续") - else: - print("\n[跳过 Step1] 直接用原图") - - # ── Step 2: OpenCV 在白背景图上检测+透视矫正 ───────── - print("\n" + "─"*50) - print("Step 2 / 3 | OpenCV 轮廓检测 + 透视矫正") - print("─"*50) - img = cv2.imread(current) - if img is None: - print(f" 无法读取: {current}") - return None - - h, w = img.shape[:2] - print(f" 输入尺寸: {w}x{h}") - pts = find_quad(img) - - if debug: - dbg_path = os.path.join(_OUTPUT_DIR, f"debug_{uid}.jpg") - save_debug_img(img, pts, dbg_path) - - if pts is not None: - warped = four_point_transform(img, pts) - - # ── 方向校正:Gemini 可能把图旋转 90°,需要纠正 ── - wh2, ww2 = warped.shape[:2] - warped_ratio = ww2 / wh2 # 宽/高 - # 若原图横竖方向与矫正结果相反(比例差异超过 1.5 倍),旋转 90° - if orig_ratio > 1.0 and warped_ratio < 1.0 / 1.5: - # 原图横,结果竖 → 顺时针转 90° - warped = cv2.rotate(warped, cv2.ROTATE_90_CLOCKWISE) - print(f" [方向校正] 原图横({orig_ratio:.2f}) vs 矫正竖({warped_ratio:.2f}) → 旋转90°") - elif orig_ratio < 1.0 and warped_ratio > 1.5: - # 原图竖,结果横 → 逆时针转 90° - warped = cv2.rotate(warped, cv2.ROTATE_90_COUNTERCLOCKWISE) - print(f" [方向校正] 原图竖({orig_ratio:.2f}) vs 矫正横({warped_ratio:.2f}) → 旋转-90°") - else: - print(f" [方向校正] 方向一致,无需旋转 (原图比例={orig_ratio:.2f}, 矫正比例={warped_ratio:.2f})") - - s2_out = os.path.join(tempfile.gettempdir(), f"pfix_s2_{uid}.jpg") - tmp.append(s2_out) - cv2.imwrite(s2_out, warped, [cv2.IMWRITE_JPEG_QUALITY, 95]) - current = s2_out - wh2, ww2 = warped.shape[:2] - print(f" 透视矫正完成 → {ww2}x{wh2}") - else: - print(" 角点检测失败,跳过透视矫正,继续用白背景图") - - # ── Step 3: Qwen 高清增强 ───────────────────────────── - if not skip_step3: - print("\n" + "─"*50) - print("Step 3 / 5 | Qwen 高清增强(RunningHub)") - print("─"*50) - final_out = os.path.join(_OUTPUT_DIR, f"pfix_final_{uid}.jpg") - from services.service_qwen import 清晰化_api - ok = await 清晰化_api(img_path=current, save_path=final_out) - if ok: - print(f" [高清增强] Qwen 成功") - else: - # Qwen 失败,用 Gemini 简化提示词兜底 - print(" Qwen 失败,Gemini 兜底重试...") - ok = await _gemini_call(current, final_out, PROMPT_ENHANCE_SIMPLE, - aspect_ratio="auto", label="高清增强(Gemini兜底)") - if not ok: - print(" Step3 全部失败,直接保存矫正结果") - import shutil - shutil.copy2(current, final_out) - else: - final_out = os.path.join(_OUTPUT_DIR, f"pfix_final_{uid}.jpg") - import shutil - shutil.copy2(current, final_out) - print("\n[跳过 Step3] 直接保存矫正结果") - - # ── Step 4: AI 决策 + 后处理(颜色匹配 & 白边裁切)──── - print("\n" + "─"*50) - print("Step 4 / 4 | AI 决策后处理(颜色匹配 / 白边裁切)") - print("─"*50) - final_img = cv2.imread(final_out) - if final_img is not None and orig_img is not None: - decision = await ai_decide_postprocess(orig_img, final_img) - - # Tool 1: 颜色匹配 - if decision["need_color_match"]: - final_img = await tool_color_match(orig_img, final_img, - strength=decision["color_strength"]) - cv2.imwrite(final_out, final_img, [cv2.IMWRITE_JPEG_QUALITY, 95]) - else: - print(" [颜色匹配] AI 判断无需调色,跳过") - - # Tool 2: 白边裁切 - if decision["need_trim"]: - trimmed, did_trim, _ = tool_trim_white_border(final_img) - if did_trim: - cv2.imwrite(final_out, trimmed, [cv2.IMWRITE_JPEG_QUALITY, 95]) - else: - print(" [裁边] AI 判断无白边,跳过") - else: - print(" [Step4] 图片读取失败,跳过后处理") - - size_kb = os.path.getsize(final_out) / 1024 - print(f"\n{'='*50}") - print(f" 完成!输出文件: {final_out}") - print(f" 文件大小: {size_kb:.0f} KB") - print(f"{'='*50}") - return final_out - - finally: - for f in tmp: - if os.path.exists(f): - os.remove(f) - - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("用法: python perspective_fix.py <图片路径或URL> [--debug] [--skip-step1] [--skip-step3]") - sys.exit(1) - - src_arg = sys.argv[1] - debug_arg = "--debug" in sys.argv - skip1_arg = "--skip-step1" in sys.argv - skip3_arg = "--skip-step3" in sys.argv - asyncio.run(process(src_arg, debug=debug_arg, skip_step1=skip1_arg, skip_step3=skip3_arg)) diff --git a/legacy/agent_pre_rules.py b/legacy/agent_pre_rules.py deleted file mode 100644 index 7db22a5..0000000 --- a/legacy/agent_pre_rules.py +++ /dev/null @@ -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") diff --git a/legacy/agent_prompts.py b/legacy/agent_prompts.py deleted file mode 100644 index 3aa153f..0000000 --- a/legacy/agent_prompts.py +++ /dev/null @@ -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) diff --git a/legacy/ai_reply_flow.py b/legacy/ai_reply_flow.py deleted file mode 100644 index 71f5f7e..0000000 --- a/legacy/ai_reply_flow.py +++ /dev/null @@ -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 diff --git a/legacy/batch_quote_helpers.py b/legacy/batch_quote_helpers.py deleted file mode 100644 index c61c65f..0000000 --- a/legacy/batch_quote_helpers.py +++ /dev/null @@ -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, - } diff --git a/legacy/chat_log_db/chats.db b/legacy/chat_log_db/chats.db deleted file mode 100644 index c7881a1..0000000 Binary files a/legacy/chat_log_db/chats.db and /dev/null differ diff --git a/legacy/collection_intent_helpers.py b/legacy/collection_intent_helpers.py deleted file mode 100644 index 0bd1888..0000000 --- a/legacy/collection_intent_helpers.py +++ /dev/null @@ -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 diff --git a/legacy/context_helpers.py b/legacy/context_helpers.py deleted file mode 100644 index c42767d..0000000 --- a/legacy/context_helpers.py +++ /dev/null @@ -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 "" diff --git a/legacy/conversation_state_store.py b/legacy/conversation_state_store.py deleted file mode 100644 index 78a4cf2..0000000 --- a/legacy/conversation_state_store.py +++ /dev/null @@ -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) diff --git a/legacy/customer_db/customers.json b/legacy/customer_db/customers.json deleted file mode 100644 index 6d587d7..0000000 --- a/legacy/customer_db/customers.json +++ /dev/null @@ -1,889 +0,0 @@ -{ - "new_customer_001": { - "customer_id": "new_customer_001", - "name": "新客户小王", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 0, - "total_spent": 0, - "avg_order_value": 0.0, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "C", - "vip": false, - "vip_level": 0, - "last_price": 20, - "last_price_time": "2026-02-28T15:04:15.181813", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 0, - "lowest_price_accepted": 0, - "preferred_format": "jpg", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "", - "decision_speed": "", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 0, - "bulk_potential": "", - "churn_risk": "低", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 0, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:03:57.129715", - "last_update": "2026-02-28T15:04:15.184378" - }, - "fast_customer_002": { - "customer_id": "fast_customer_002", - "name": "爽快老客老李", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 8, - "total_spent": 280, - "avg_order_value": 0.0, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [ - "爽快" - ], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "C", - "vip": false, - "vip_level": 0, - "last_price": 10, - "last_price_time": "2026-02-28T15:06:10.872962", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 2, - "lowest_price_accepted": 10, - "preferred_format": "jpg", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "中", - "decision_speed": "快", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 8, - "bulk_potential": "", - "churn_risk": "低", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 0, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:03:57.131384", - "last_update": "2026-02-28T15:06:10.875534" - }, - "bargainer_003": { - "customer_id": "bargainer_003", - "name": "砍价王小张", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 3, - "total_spent": 45, - "avg_order_value": 0.0, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [ - "砍价狂", - "纠结" - ], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "C", - "vip": false, - "vip_level": 0, - "last_price": 10, - "last_price_time": "2026-02-28T15:05:45.067204", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 6, - "lowest_price_accepted": 10, - "preferred_format": "jpg", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "高", - "decision_speed": "慢", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 0, - "bulk_potential": "", - "churn_risk": "低", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 0, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:03:57.132648", - "last_update": "2026-02-28T15:05:45.071818" - }, - "vip_customer_004": { - "customer_id": "vip_customer_004", - "name": "VIP客户陈总", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 15, - "total_spent": 680, - "avg_order_value": 0.0, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [ - "爽快" - ], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "A", - "vip": true, - "vip_level": 2, - "last_price": 20, - "last_price_time": "2026-02-28T15:04:56.155844", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 0, - "lowest_price_accepted": 0, - "preferred_format": "jpg", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "低", - "decision_speed": "快", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 0, - "bulk_potential": "", - "churn_risk": "低", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 18, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:03:57.134104", - "last_update": "2026-02-28T15:04:56.158233" - }, - "high_value_005": { - "customer_id": "high_value_005", - "name": "高价值客户刘老板", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 20, - "total_spent": 1200, - "avg_order_value": 60, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [ - "爽快" - ], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "A", - "vip": false, - "vip_level": 0, - "last_price": 20, - "last_price_time": "2026-02-28T15:05:11.156030", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 0, - "lowest_price_accepted": 0, - "preferred_format": "jpg", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "低", - "decision_speed": "快", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 0, - "bulk_potential": "", - "churn_risk": "低", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 0, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:03:57.135396", - "last_update": "2026-02-28T15:05:11.160004" - }, - "blacklist_006": { - "customer_id": "blacklist_006", - "name": "黑名单客户", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 0, - "total_spent": 0.0, - "avg_order_value": 0.0, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "C", - "vip": false, - "vip_level": 0, - "last_price": 0, - "last_price_time": "", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 0, - "lowest_price_accepted": 0, - "preferred_format": "jpg", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "", - "decision_speed": "", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 0, - "bulk_potential": "", - "churn_risk": "低", - "upsell_opportunity": [], - "blacklist": true, - "blacklist_reason": "恶意投诉多次", - "vip_custom_price": 0, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:03:57.136490", - "last_update": "2026-02-28T15:05:27.155220" - }, - "test_new_001": { - "customer_id": "test_new_001", - "name": "新客户小王", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 0, - "total_spent": 0, - "avg_order_value": 0.0, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "C", - "vip": false, - "vip_level": 0, - "last_price": 0, - "last_price_time": "2026-02-28T15:27:40.801329", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 0, - "lowest_price_accepted": 0, - "preferred_format": "jpg", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "", - "decision_speed": "", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 0, - "bulk_potential": "", - "churn_risk": "低", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 0, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:29:05.719291", - "last_update": "2026-02-28T15:29:05.719308" - }, - "test_fast_002": { - "customer_id": "test_fast_002", - "name": "爽快老客老李", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 8, - "total_spent": 280, - "avg_order_value": 0.0, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [ - "爽快" - ], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "C", - "vip": false, - "vip_level": 0, - "last_price": 25, - "last_price_time": "", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 0, - "lowest_price_accepted": 0, - "preferred_format": "", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "低", - "decision_speed": "快", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 8, - "bulk_potential": "", - "churn_risk": "", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 0, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:29:05.720944", - "last_update": "2026-02-28T15:29:05.720948" - }, - "test_bargain_003": { - "customer_id": "test_bargain_003", - "name": "砍价王小张", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 3, - "total_spent": 45, - "avg_order_value": 0.0, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [ - "砍价狂", - "纠结" - ], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "C", - "vip": false, - "vip_level": 0, - "last_price": 15, - "last_price_time": "", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 4, - "lowest_price_accepted": 15, - "preferred_format": "", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "高", - "decision_speed": "慢", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 0, - "bulk_potential": "", - "churn_risk": "", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 0, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:29:05.722448", - "last_update": "2026-02-28T15:29:05.722454" - }, - "test_vip_004": { - "customer_id": "test_vip_004", - "name": "VIP 客户陈总", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 15, - "total_spent": 680, - "avg_order_value": 0.0, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [ - "爽快" - ], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "A", - "vip": true, - "vip_level": 2, - "last_price": 0, - "last_price_time": "", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 0, - "lowest_price_accepted": 0, - "preferred_format": "", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "低", - "decision_speed": "快", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 0, - "bulk_potential": "", - "churn_risk": "", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 18, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:29:05.723887", - "last_update": "2026-02-28T15:29:05.723890" - }, - "test_highvalue_005": { - "customer_id": "test_highvalue_005", - "name": "高价值客户刘老板", - "nickname": "", - "email": "", - "phone": "", - "wechat": "", - "address": "", - "platform": "", - "platform_id": "", - "budget": "", - "budget_range_min": 0, - "budget_range_max": 0, - "requirements": [], - "preference_services": [], - "total_orders": 20, - "total_spent": 1200, - "avg_order_value": 60, - "purchase_frequency": "", - "last_order_date": "", - "first_order_date": "", - "order_ids": [], - "pending_orders": 0, - "completed_orders": 0, - "refund_count": 0, - "personality": [ - "爽快" - ], - "communication_prefer": "", - "response_speed": "", - "patience_level": "", - "customer_level": "A", - "vip": false, - "vip_level": 0, - "last_price": 0, - "last_price_time": "", - "last_quote_no_convert": false, - "last_min_price": 0, - "last_image_url": "", - "last_image_time": "", - "last_gemini_prompt": "", - "last_aspect_ratio": "1:1", - "last_perspective": "no", - "processing_status": "", - "processing_image_url": "", - "expected_done_at": "", - "discount_given_count": 0, - "lowest_price_accepted": 0, - "preferred_format": "", - "preferred_size": "", - "last_conversation_summary": "", - "last_conversation_time": "", - "total_images_sent": 0, - "complexity_history": [], - "image_type_history": [], - "price_sensitivity": "低", - "decision_speed": "快", - "revision_count": 0, - "revision_orders": 0, - "total_completed_orders": 0, - "bulk_potential": "", - "churn_risk": "", - "upsell_opportunity": [], - "blacklist": false, - "blacklist_reason": "", - "vip_custom_price": 0, - "last_email_status": "", - "good_reviews": 0, - "bad_reviews": 0, - "dispute_count": 0, - "follow_up_by": "", - "follow_up_date": "", - "next_follow_date": "", - "source": "", - "coupon_used": "", - "notes": [], - "tags": [], - "created_at": "", - "last_contact": "2026-02-28T15:29:05.725313", - "last_update": "2026-02-28T15:29:05.725316" - } -} \ No newline at end of file diff --git a/legacy/customer_risk_db.py b/legacy/customer_risk_db.py deleted file mode 100644 index b65034d..0000000 --- a/legacy/customer_risk_db.py +++ /dev/null @@ -1,336 +0,0 @@ -"""客户风控数据库(MySQL 优先,SQLite 兜底)""" -import os -import sqlite3 -import json -from datetime import datetime -from pathlib import Path -from typing import Dict, Any -from dotenv import load_dotenv - -load_dotenv() - -_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower() -_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1") -_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306")) -_MYSQL_USER = os.getenv("MYSQL_USER", "root") -_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "") -_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs") - - -def _is_mysql() -> bool: - return _DB_TYPE in ("mysql", "mariadb") - - -class CustomerRiskDB: - def __init__(self, sqlite_path: str = "db/customer_risk_db/risk.db"): - self.sqlite_path = Path(sqlite_path) - self.backend = "mysql" if _is_mysql() else "sqlite" - self._sqlite_in_memory = False - try: - self._ensure_db() - except Exception: - # MySQL 不可用时自动回退,避免主流程被数据库连接拖垮 - self.backend = "sqlite" - try: - self._ensure_sqlite_db() - except Exception: - # 最后兜底:内存 SQLite,保证模块可导入 - self._sqlite_in_memory = True - self._ensure_sqlite_db() - - def _get_mysql_conn(self): - import pymysql - return pymysql.connect( - host=_MYSQL_HOST, - port=_MYSQL_PORT, - user=_MYSQL_USER, - password=_MYSQL_PASSWORD, - database=_MYSQL_DATABASE, - charset="utf8mb4", - cursorclass=pymysql.cursors.DictCursor, - autocommit=False, - ) - - def _get_sqlite_conn(self): - if self._sqlite_in_memory: - conn = sqlite3.connect(":memory:") - else: - self.sqlite_path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(self.sqlite_path)) - conn.row_factory = sqlite3.Row - return conn - - def _ensure_db(self): - if self.backend == "mysql": - with self._get_mysql_conn() as conn: - with conn.cursor() as cur: - cur.execute( - """ - CREATE TABLE IF NOT EXISTS customer_risk_profile ( - customer_id VARCHAR(128) PRIMARY KEY, - do_not_serve TINYINT(1) NOT NULL DEFAULT 0, - risk_level VARCHAR(16) NOT NULL DEFAULT 'low', - risk_score INT NOT NULL DEFAULT 0, - note TEXT, - tags_json TEXT, - updated_at DATETIME NOT NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """ - ) - cur.execute( - """ - CREATE TABLE IF NOT EXISTS customer_risk_event ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - customer_id VARCHAR(128) NOT NULL, - event_type VARCHAR(32) NOT NULL, - event_count INT NOT NULL DEFAULT 1, - note TEXT, - created_at DATETIME NOT NULL, - INDEX idx_customer_time (customer_id, created_at), - INDEX idx_event_type (event_type) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """ - ) - conn.commit() - return - self._ensure_sqlite_db() - - def _ensure_sqlite_db(self): - with self._get_sqlite_conn() as conn: - cur = conn.cursor() - cur.execute( - """ - CREATE TABLE IF NOT EXISTS customer_risk_profile ( - customer_id TEXT PRIMARY KEY, - do_not_serve INTEGER NOT NULL DEFAULT 0, - risk_level TEXT NOT NULL DEFAULT 'low', - risk_score INTEGER NOT NULL DEFAULT 0, - note TEXT, - tags_json TEXT, - updated_at TEXT NOT NULL - ) - """ - ) - cur.execute( - """ - CREATE TABLE IF NOT EXISTS customer_risk_event ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - customer_id TEXT NOT NULL, - event_type TEXT NOT NULL, - event_count INTEGER NOT NULL DEFAULT 1, - note TEXT, - created_at TEXT NOT NULL - ) - """ - ) - cur.execute("CREATE INDEX IF NOT EXISTS idx_customer_time ON customer_risk_event(customer_id, created_at)") - cur.execute("CREATE INDEX IF NOT EXISTS idx_event_type ON customer_risk_event(event_type)") - conn.commit() - - def record_event(self, customer_id: str, event_type: str, event_count: int = 1, note: str = ""): - if not customer_id or not event_type: - return - now = datetime.now() - if self.backend == "mysql": - with self._get_mysql_conn() as conn: - with conn.cursor() as cur: - cur.execute( - """ - INSERT INTO customer_risk_event (customer_id, event_type, event_count, note, created_at) - VALUES (%s, %s, %s, %s, %s) - """, - (customer_id, event_type, int(max(1, event_count)), note, now.strftime("%Y-%m-%d %H:%M:%S")), - ) - conn.commit() - return - with self._get_sqlite_conn() as conn: - cur = conn.cursor() - cur.execute( - """ - INSERT INTO customer_risk_event (customer_id, event_type, event_count, note, created_at) - VALUES (?, ?, ?, ?, ?) - """, - (customer_id, event_type, int(max(1, event_count)), note, now.isoformat()), - ) - conn.commit() - - def set_profile( - self, - customer_id: str, - *, - do_not_serve: bool = False, - risk_level: str = "low", - risk_score: int = 0, - note: str = "", - tags: list | None = None, - ): - if not customer_id: - return - tags_json = json.dumps(tags or [], ensure_ascii=False) - now = datetime.now() - if self.backend == "mysql": - with self._get_mysql_conn() as conn: - with conn.cursor() as cur: - cur.execute( - """ - REPLACE INTO customer_risk_profile - (customer_id, do_not_serve, risk_level, risk_score, note, tags_json, updated_at) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """, - ( - customer_id, - 1 if do_not_serve else 0, - risk_level, - int(max(0, risk_score)), - note, - tags_json, - now.strftime("%Y-%m-%d %H:%M:%S"), - ), - ) - conn.commit() - return - with self._get_sqlite_conn() as conn: - cur = conn.cursor() - cur.execute( - """ - INSERT INTO customer_risk_profile - (customer_id, do_not_serve, risk_level, risk_score, note, tags_json, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(customer_id) DO UPDATE SET - do_not_serve=excluded.do_not_serve, - risk_level=excluded.risk_level, - risk_score=excluded.risk_score, - note=excluded.note, - tags_json=excluded.tags_json, - updated_at=excluded.updated_at - """, - ( - customer_id, - 1 if do_not_serve else 0, - risk_level, - int(max(0, risk_score)), - note, - tags_json, - now.isoformat(), - ), - ) - conn.commit() - - def _sum_events(self, customer_id: str, event_type: str, days: int) -> int: - if self.backend == "mysql": - with self._get_mysql_conn() as conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT COALESCE(SUM(event_count), 0) AS total - FROM customer_risk_event - WHERE customer_id=%s - AND event_type=%s - AND created_at >= (NOW() - INTERVAL %s DAY) - """, - (customer_id, event_type, int(max(1, days))), - ) - row = cur.fetchone() or {} - return int(row.get("total") or 0) - with self._get_sqlite_conn() as conn: - cur = conn.cursor() - cur.execute( - """ - SELECT COALESCE(SUM(event_count), 0) AS total - FROM customer_risk_event - WHERE customer_id=? - AND event_type=? - AND created_at >= datetime('now', ?) - """, - (customer_id, event_type, f"-{int(max(1, days))} day"), - ) - row = cur.fetchone() - return int((row["total"] if row else 0) or 0) - - def get_profile(self, customer_id: str) -> Dict[str, Any]: - out = { - "customer_id": customer_id, - "do_not_serve": False, - "risk_level": "low", - "risk_score": 0, - "note": "", - "tags": [], - } - if self.backend == "mysql": - with self._get_mysql_conn() as conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT customer_id, do_not_serve, risk_level, risk_score, note, tags_json - FROM customer_risk_profile - WHERE customer_id=%s - LIMIT 1 - """, - (customer_id,), - ) - row = cur.fetchone() - if not row: - return out - out.update( - { - "do_not_serve": bool(row.get("do_not_serve")), - "risk_level": str(row.get("risk_level") or "low"), - "risk_score": int(row.get("risk_score") or 0), - "note": str(row.get("note") or ""), - "tags": json.loads(row.get("tags_json") or "[]"), - } - ) - return out - with self._get_sqlite_conn() as conn: - cur = conn.cursor() - cur.execute( - """ - SELECT customer_id, do_not_serve, risk_level, risk_score, note, tags_json - FROM customer_risk_profile - WHERE customer_id=? - LIMIT 1 - """, - (customer_id,), - ) - row = cur.fetchone() - if not row: - return out - out.update( - { - "do_not_serve": bool(row["do_not_serve"]), - "risk_level": str(row["risk_level"] or "low"), - "risk_score": int(row["risk_score"] or 0), - "note": str(row["note"] or ""), - "tags": json.loads(row["tags_json"] or "[]"), - } - ) - return out - - def evaluate_customer(self, customer_id: str) -> Dict[str, Any]: - profile = self.get_profile(customer_id) - refund_30d = self._sum_events(customer_id, "refund", 30) - unpaid_7d = self._sum_events(customer_id, "unpaid_order", 7) - bad_review_90d = self._sum_events(customer_id, "bad_review", 90) - - score = int(profile.get("risk_score") or 0) - score += refund_30d * 20 - score += unpaid_7d * 8 - score += bad_review_90d * 15 - - level = "low" - if score >= 70: - level = "high" - elif score >= 35: - level = "medium" - - return { - **profile, - "refund_30d": refund_30d, - "unpaid_7d": unpaid_7d, - "bad_review_90d": bad_review_90d, - "computed_score": score, - "computed_level": level, - } - - -risk_db = CustomerRiskDB() diff --git a/legacy/daily_summary.py b/legacy/daily_summary.py deleted file mode 100644 index 6d73a54..0000000 --- a/legacy/daily_summary.py +++ /dev/null @@ -1,300 +0,0 @@ -# -*- coding: utf-8 -*- -""" -每日聊天汇总定时任务 -- 每天 23:50 自动统计当日各店铺数据 -- 用 AI 生成自然语言摘要 -- 发送到企业微信 Webhook + QQ 邮件 -""" - -import asyncio -import os -import json -import logging -from datetime import datetime, date, timedelta -from typing import Optional - -import httpx -from dotenv import load_dotenv - -load_dotenv() -logger = logging.getLogger("cs_agent") - -WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "") -SUMMARY_EMAIL = os.getenv("SUMMARY_EMAIL", "") # 收摘要的邮箱 -SEND_HOUR = int(os.getenv("SUMMARY_HOUR", "23")) -SEND_MINUTE = int(os.getenv("SUMMARY_MINUTE", "50")) - - -# ────────────────────────────────────────── -# 统计数据整理 -# ────────────────────────────────────────── - -def _build_stats_text(target_date: str = "") -> str: - """整理今日数据,返回给 AI 的原始统计文本""" - from db import chat_log_db as db - from db.deal_outcome_db import get_daily_summary - - if not target_date: - target_date = datetime.now().strftime("%Y-%m-%d") - - stats = db.get_daily_stats(target_date) - convs = db.get_daily_conversations(target_date) - deal_sum = get_daily_summary(target_date) - - if not stats: - return f"{target_date} 当日无任何聊天记录。" - - # 按 acc_id 分组对话片段 - conv_map: dict[str, list] = {} - for c in convs: - aid = c.get("acc_id") or "未知店铺" - conv_map.setdefault(aid, []).append(c) - - lines = [f"【{target_date} 各店铺数据】\n"] - - # 成交/未成交汇总(供 AI 摘要与数据分析) - lines.append("【成交与未成交】") - lines.append(f" 成交:{deal_sum['成交数']} 笔,金额 {deal_sum['成交金额']:.0f} 元") - lines.append(f" 未成交:{deal_sum['未成交数']} 笔") - if deal_sum["未成交原因分布"]: - for reason, cnt in deal_sum["未成交原因分布"].items(): - lines.append(f" - {reason}:{cnt} 笔") - if deal_sum["成交明细"]: - for o in deal_sum["成交明细"][:5]: - r = "让价后" if o.get("discount_given") else "直接" - lines.append(f" ✓ {o.get('customer_name', '')[:6]} {r}成交 {o.get('amount', 0):.0f}元") - if deal_sum["未成交明细"]: - for o in deal_sum["未成交明细"][:5]: - lines.append(f" ✗ {o.get('customer_name', '')[:6]} {o.get('reason', '')}") - lines.append("") - - for s in stats: - acc = s.get("acc_id") or "未知店铺" - plat = s.get("platform") or "" - label = f"{acc}({plat})" if plat else acc - - lines.append(f"▶ 店铺:{label}") - lines.append(f" 接待客户:{s['unique_customers']} 人,共 {s['total_msgs']} 条消息(收 {s['recv']} 发 {s['sent']})") - lines.append(f" 首条:{(s.get('first_msg') or '')[-8:-3]} 末条:{(s.get('last_msg') or '')[-8:-3]}") - - shop_convs = conv_map.get(acc, []) - for c in shop_convs[:6]: # 最多展示6个客户片段 - name = c.get("customer_name") or c.get("customer_id", "")[:8] - snippet = (c.get("snippet") or "")[:120] - lines.append(f" · {name}({c['msg_count']}条){snippet}") - if len(shop_convs) > 6: - lines.append(f" ... 还有 {len(shop_convs)-6} 位客户") - lines.append("") - - return "\n".join(lines) - - -# ────────────────────────────────────────── -# AI 生成摘要 -# ────────────────────────────────────────── - -async def _ai_summary(raw_text: str) -> str: - """调用 AI 把统计文本转成自然语言日报""" - try: - from openai import AsyncOpenAI - client = AsyncOpenAI( - api_key=os.getenv("OPENAI_API_KEY"), - base_url=os.getenv("OPENAI_BASE_URL"), - ) - model = os.getenv("OPENAI_MODEL", "doubao-seed-2-0-lite-260215") - resp = await client.chat.completions.create( - model=model, - messages=[ - { - "role": "system", - "content": ( - "你是一名电商运营助理。根据下面的客服聊天数据," - "为老板写一份简洁的当日运营日报(200字以内)。" - "要包含:接待总人数、各店铺情况、有无成交或异常情况。" - "语气轻松,像发给老板的微信消息,不需要标题。" - ), - }, - {"role": "user", "content": raw_text}, - ], - max_tokens=300, - temperature=0.5, - ) - return resp.choices[0].message.content.strip() - except Exception as e: - # AI 失败就直接返回原始统计 - return raw_text - - -# ────────────────────────────────────────── -# 推送:企业微信 -# ────────────────────────────────────────── - -async def _send_wechat(content: str): - """推送到企业微信群机器人(markdown 格式,单条 ≤4096 字节自动分段)""" - if not WECHAT_WEBHOOK: - logger.info("[DailySummary] 未配置 WECHAT_WEBHOOK,跳过推送") - return - - # 企业微信单条 markdown 限 4096 字节,超长自动分段 - encoded = content.encode("utf-8") - chunks = [] - while encoded: - chunk = encoded[:3800].decode("utf-8", errors="ignore") - chunks.append(chunk) - encoded = encoded[len(chunk.encode("utf-8")):] - - async with httpx.AsyncClient(timeout=10) as client: - for i, chunk in enumerate(chunks): - payload = {"msgtype": "markdown", "markdown": {"content": chunk}} - try: - resp = await client.post(WECHAT_WEBHOOK, json=payload) - data = resp.json() - if data.get("errcode") == 0: - logger.info("[DailySummary] 企业微信推送成功(第%s段)", i + 1) - else: - logger.warning("[DailySummary] 企业微信推送失败: %s", data) - except Exception as e: - logger.exception("[DailySummary] 企业微信推送异常: %s", e) - - -# ────────────────────────────────────────── -# 推送:邮件 -# ────────────────────────────────────────── - -def _send_email(subject: str, body: str): - """发送日报邮件""" - if not SUMMARY_EMAIL: - return - try: - from mail.email_sender import email_sender - import smtplib - from email.mime.text import MIMEText - from email.header import Header - - msg = MIMEText(body, "plain", "utf-8") - msg["Subject"] = Header(subject, "utf-8").encode() - msg["From"] = f"{Header(email_sender.sender_name, 'utf-8').encode()} <{email_sender.smtp_user}>" - msg["To"] = SUMMARY_EMAIL - - with smtplib.SMTP(email_sender.smtp_host, email_sender.smtp_port) as s: - s.starttls() - s.login(email_sender.smtp_user, email_sender.smtp_password) - s.sendmail(email_sender.smtp_user, [SUMMARY_EMAIL], msg.as_string()) - logger.info("[DailySummary] 日报邮件已发送至 %s", SUMMARY_EMAIL) - except Exception as e: - logger.exception("[DailySummary] 日报邮件发送失败: %s", e) - - -# ────────────────────────────────────────── -# 企业微信 Markdown 排版 -# ────────────────────────────────────────── - -def _build_wechat_markdown(title: str, ai_text: str, raw_text: str, target_date: str = "") -> str: - """ - 构建符合企业微信规范的 markdown 内容。 - 支持:**bold**、、> 引用、``` 代码块、- 列表 - 不支持:
、HTML 标签(除 font/br) - """ - from db import chat_log_db as db - from db.deal_outcome_db import get_daily_summary - date = target_date or datetime.now().strftime("%Y-%m-%d") - stats = db.get_daily_stats(date) - deal_sum = get_daily_summary(date) - - lines = [f"## {title}\n"] - - # AI 摘要部分 - lines.append("> " + ai_text.replace("\n", "\n> ")) - lines.append("") - - # 成交/未成交 - lines.append("**📈 成交与未成交**") - lines.append(f"- 成交 **{deal_sum['成交数']}** 笔 · 金额 **{deal_sum['成交金额']:.0f}** 元") - lines.append(f"- 未成交 **{deal_sum['未成交数']}** 笔") - if deal_sum["未成交原因分布"]: - for reason, cnt in deal_sum["未成交原因分布"].items(): - lines.append(f" - {reason}:{cnt} 笔") - lines.append("") - - # 各店铺数据表格(企业微信不支持 | 表格,用列表代替) - if stats: - lines.append("**📋 各店铺明细**") - for s in stats: - acc = s.get("acc_id") or "未知店铺" - plat = s.get("platform") or "" - label = f"{acc}({plat})" if plat else acc - first = (s.get("first_msg") or "")[-8:-3] - last = (s.get("last_msg") or "")[-8:-3] - lines.append( - f"- {label} " - f"接待 **{s['unique_customers']}** 人 · " - f"消息 {s['total_msgs']} 条(收{s['recv']}/发{s['sent']})" - f" {first}~{last}" - ) - lines.append("") - lines.append(f"发送时间:{datetime.now().strftime('%H:%M:%S')}") - - return "\n".join(lines) - - -# ────────────────────────────────────────── -# 主入口:生成并推送日报 -# ────────────────────────────────────────── - -async def send_daily_summary(target_date: str = ""): - """生成并推送当日汇总""" - if not target_date: - target_date = datetime.now().strftime("%Y-%m-%d") - - logger.info("[DailySummary] 开始生成 %s 日报...", target_date) - - raw_text = _build_stats_text(target_date) - ai_text = await _ai_summary(raw_text) - title = f"📊 {target_date} 客服日报" - - # ── 企业微信 markdown(不支持
,用标准语法)── - wechat_md = _build_wechat_markdown(title, ai_text, raw_text, target_date) - await _send_wechat(wechat_md) - - # ── 邮件:纯文本 ── - email_body = f"{ai_text}\n\n{'='*40}\n\n{raw_text}" - _send_email(title, email_body) - - logger.info("[DailySummary] 日报推送完成") - return ai_text - - -# ────────────────────────────────────────── -# 定时调度(由 websocket_client 启动) -# ────────────────────────────────────────── - -async def scheduler(): - """每天 SEND_HOUR:SEND_MINUTE 触发日报""" - logger.info("[DailySummary] 定时日报已启动,发送时间 %02d:%02d", SEND_HOUR, SEND_MINUTE) - sent_today: Optional[str] = None # 记录已发日期,防重复 - - while True: - now = datetime.now() - today = now.strftime("%Y-%m-%d") - - if now.hour == SEND_HOUR and now.minute == SEND_MINUTE and sent_today != today: - sent_today = today - try: - await send_daily_summary(today) - except Exception as e: - logger.exception("[DailySummary] 日报生成出错: %s", e) - - # 每 30 秒检查一次 - await asyncio.sleep(30) - - -# ────────────────────────────────────────── -# 命令行手动触发 -# ────────────────────────────────────────── - -if __name__ == "__main__": - import sys - target = sys.argv[1] if len(sys.argv) > 1 else "" - result = asyncio.run(send_daily_summary(target)) - logger.info("\n=== AI 摘要 ===") - logger.info(result) diff --git a/legacy/deal_outcome_db.py b/legacy/deal_outcome_db.py deleted file mode 100644 index 5204a00..0000000 --- a/legacy/deal_outcome_db.py +++ /dev/null @@ -1,246 +0,0 @@ -# -*- coding: utf-8 -*- -""" -成交/未成交记录 - 用于日报与数据分析 -""" -import sqlite3 -import os -from datetime import datetime -from typing import List, Dict, Optional - -_DB_PATH = os.path.join(os.path.dirname(__file__), "deal_outcome_db", "outcomes.db") -_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower() -_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1") -_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306")) -_MYSQL_USER = os.getenv("MYSQL_USER", "root") -_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "") -_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs") - - -class _CompatResult: - def __init__(self, rows=None, rowcount: int = 0, lastrowid: int = 0): - self._rows = rows or [] - self.rowcount = rowcount - self.lastrowid = lastrowid - - def fetchall(self): - return self._rows - - def fetchone(self): - return self._rows[0] if self._rows else None - - -class _PyMySQLCompatConn: - def __init__(self, conn): - self._conn = conn - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - if exc_type: - try: - self._conn.rollback() - except Exception: - pass - self._conn.close() - - def execute(self, query: str, args=None): - cur = self._conn.cursor() - cur.execute(query, args or ()) - rows = cur.fetchall() if cur.description else [] - res = _CompatResult(rows=rows, rowcount=cur.rowcount, lastrowid=getattr(cur, "lastrowid", 0)) - cur.close() - return res - - def commit(self): - self._conn.commit() - -def _is_mysql() -> bool: - return _DB_TYPE in ("mysql", "mariadb") - -def _sql(query: str) -> str: - return query.replace("?", "%s") if _is_mysql() else query - - -def _get_conn() -> sqlite3.Connection: - if _is_mysql(): - import pymysql - conn = pymysql.connect( - host=_MYSQL_HOST, - port=_MYSQL_PORT, - user=_MYSQL_USER, - password=_MYSQL_PASSWORD, - database=_MYSQL_DATABASE, - charset="utf8mb4", - cursorclass=pymysql.cursors.DictCursor, - autocommit=False, - ) - return _PyMySQLCompatConn(conn) - os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True) - conn = sqlite3.connect(_DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def _init_db(): - with _get_conn() as conn: - if _is_mysql(): - conn.execute(""" - CREATE TABLE IF NOT EXISTS deal_outcomes ( - id INTEGER PRIMARY KEY AUTO_INCREMENT, - customer_id VARCHAR(128) NOT NULL, - customer_name VARCHAR(255) DEFAULT '', - acc_id VARCHAR(128) DEFAULT '', - platform VARCHAR(64) DEFAULT '', - date DATE NOT NULL, - outcome VARCHAR(16) NOT NULL, - reason TEXT, - order_id VARCHAR(128) DEFAULT '', - amount REAL DEFAULT 0, - discount_given INTEGER DEFAULT 0, - timestamp DATETIME NOT NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """) - idx_rows = conn.execute("SHOW INDEX FROM deal_outcomes").fetchall() - exists = {str(r.get("Key_name", "")) for r in idx_rows} - if "idx_deal_date" not in exists: - conn.execute("CREATE INDEX idx_deal_date ON deal_outcomes(date)") - if "idx_deal_customer" not in exists: - conn.execute("CREATE INDEX idx_deal_customer ON deal_outcomes(customer_id)") - if "idx_deal_acc" not in exists: - conn.execute("CREATE INDEX idx_deal_acc ON deal_outcomes(acc_id)") - if "idx_deal_outcome" not in exists: - conn.execute("CREATE INDEX idx_deal_outcome ON deal_outcomes(outcome)") - else: - conn.execute(""" - CREATE TABLE IF NOT EXISTS deal_outcomes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - customer_id TEXT NOT NULL, - customer_name TEXT DEFAULT '', - acc_id TEXT DEFAULT '', - platform TEXT DEFAULT '', - date TEXT NOT NULL, - outcome TEXT NOT NULL CHECK(outcome IN ('成交','未成交')), - reason TEXT DEFAULT '', - order_id TEXT DEFAULT '', - amount REAL DEFAULT 0, - discount_given INTEGER DEFAULT 0, - timestamp TEXT NOT NULL - ) - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_date ON deal_outcomes(date)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_customer ON deal_outcomes(customer_id)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_acc ON deal_outcomes(acc_id)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_outcome ON deal_outcomes(outcome)") - conn.commit() - - -_init_db() - - -def record_deal( - customer_id: str, - outcome: str, - reason: str = "", - customer_name: str = "", - acc_id: str = "", - platform: str = "", - order_id: str = "", - amount: float = 0, - discount_given: bool = False, -): - """记录一笔成交或未成交""" - ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - date = datetime.now().strftime("%Y-%m-%d") - with _get_conn() as conn: - conn.execute( - _sql("""INSERT INTO deal_outcomes - (customer_id, customer_name, acc_id, platform, date, outcome, reason, - order_id, amount, discount_given, timestamp) - VALUES (?,?,?,?,?,?,?,?,?,?,?)"""), - ( - customer_id, - customer_name or "", - acc_id or "", - platform or "", - date, - outcome, - reason or "", - order_id or "", - amount, - 1 if discount_given else 0, - ts, - ), - ) - conn.commit() - - -def get_daily_outcomes(date: str = "") -> List[Dict]: - """获取指定日期的成交/未成交记录,用于日报""" - if not date: - date = datetime.now().strftime("%Y-%m-%d") - with _get_conn() as conn: - rows = conn.execute( - _sql(""" - SELECT customer_id, customer_name, acc_id, outcome, reason, - order_id, amount, discount_given, timestamp - FROM deal_outcomes - WHERE date = ? - ORDER BY timestamp ASC - """), - (date,), - ).fetchall() - return [dict(r) for r in rows] - - -def get_daily_summary(date: str = "") -> Dict: - """获取指定日期的成交/未成交汇总统计""" - outcomes = get_daily_outcomes(date) - success = [o for o in outcomes if o["outcome"] == "成交"] - fail = [o for o in outcomes if o["outcome"] == "未成交"] - - # 按原因分组 - fail_by_reason: Dict[str, int] = {} - for o in fail: - r = o.get("reason") or "其他" - fail_by_reason[r] = fail_by_reason.get(r, 0) + 1 - - return { - "date": date or datetime.now().strftime("%Y-%m-%d"), - "成交数": len(success), - "未成交数": len(fail), - "成交金额": sum(o.get("amount") or 0 for o in success), - "成交明细": success, - "未成交明细": fail, - "未成交原因分布": fail_by_reason, - } - - -def export_for_analysis(start_date: str = "", end_date: str = "") -> List[Dict]: - """ - 导出成交/未成交记录,供数据库分析。 - 日期格式 YYYY-MM-DD,留空则查全部。 - """ - with _get_conn() as conn: - if start_date and end_date: - rows = conn.execute( - _sql("""SELECT * FROM deal_outcomes - WHERE date BETWEEN ? AND ? - ORDER BY date, timestamp"""), - (start_date, end_date), - ).fetchall() - elif start_date: - rows = conn.execute( - _sql("""SELECT * FROM deal_outcomes WHERE date >= ? ORDER BY date, timestamp"""), - (start_date,), - ).fetchall() - elif end_date: - rows = conn.execute( - _sql("""SELECT * FROM deal_outcomes WHERE date <= ? ORDER BY date, timestamp"""), - (end_date,), - ).fetchall() - else: - rows = conn.execute( - """SELECT * FROM deal_outcomes ORDER BY date, timestamp""" - ).fetchall() - return [dict(r) for r in rows] diff --git a/legacy/designer_roster_db.py b/legacy/designer_roster_db.py deleted file mode 100644 index 8f5c2dd..0000000 --- a/legacy/designer_roster_db.py +++ /dev/null @@ -1,279 +0,0 @@ -# -*- coding: utf-8 -*- -""" -设计师派单数据库(SQLite) - -同一设计师在不同店铺对应不同 group_id,派单时从在线设计师中轮询。 -企微群「上线」/「下线」通过 update_online(wechat_user_id, is_online) 更新。 -""" -import sqlite3 -import os -from typing import Optional - -_DB_PATH = os.path.join(os.path.dirname(__file__), "designer_roster_db", "roster.db") -_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower() -_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1") -_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306")) -_MYSQL_USER = os.getenv("MYSQL_USER", "root") -_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "") -_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs") - - -class _CompatResult: - def __init__(self, rows=None, rowcount: int = 0, lastrowid: int = 0): - self._rows = rows or [] - self.rowcount = rowcount - self.lastrowid = lastrowid - - def fetchall(self): - return self._rows - - def fetchone(self): - return self._rows[0] if self._rows else None - - -class _PyMySQLCompatConn: - def __init__(self, conn): - self._conn = conn - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - if exc_type: - try: - self._conn.rollback() - except Exception: - pass - self._conn.close() - - def execute(self, query: str, args=None): - cur = self._conn.cursor() - cur.execute(query, args or ()) - rows = cur.fetchall() if cur.description else [] - res = _CompatResult(rows=rows, rowcount=cur.rowcount, lastrowid=getattr(cur, "lastrowid", 0)) - cur.close() - return res - - def commit(self): - self._conn.commit() - -def _is_mysql() -> bool: - return _DB_TYPE in ("mysql", "mariadb") - -def _sql(query: str) -> str: - return query.replace("?", "%s") if _is_mysql() else query - - -def _get_conn() -> sqlite3.Connection: - if _is_mysql(): - import pymysql - conn = pymysql.connect( - host=_MYSQL_HOST, - port=_MYSQL_PORT, - user=_MYSQL_USER, - password=_MYSQL_PASSWORD, - database=_MYSQL_DATABASE, - charset="utf8mb4", - cursorclass=pymysql.cursors.DictCursor, - autocommit=False, - ) - return _PyMySQLCompatConn(conn) - os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True) - conn = sqlite3.connect(_DB_PATH) - conn.row_factory = sqlite3.Row - return conn - - -def init_db(): - with _get_conn() as conn: - if _is_mysql(): - conn.execute(""" - CREATE TABLE IF NOT EXISTS designers ( - id INTEGER PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(255) NOT NULL, - wechat_user_id VARCHAR(128) UNIQUE NOT NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS designer_shops ( - designer_id INTEGER NOT NULL, - shop_id VARCHAR(128) NOT NULL, - group_id VARCHAR(128) NOT NULL, - PRIMARY KEY (designer_id, shop_id), - FOREIGN KEY (designer_id) REFERENCES designers(id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS designer_online ( - wechat_user_id VARCHAR(128) PRIMARY KEY, - is_online INTEGER NOT NULL DEFAULT 0, - updated_at DATETIME - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS round_robin ( - shop_id VARCHAR(128) PRIMARY KEY, - last_index INTEGER NOT NULL DEFAULT 0 - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """) - else: - conn.execute(""" - CREATE TABLE IF NOT EXISTS designers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - wechat_user_id TEXT UNIQUE NOT NULL - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS designer_shops ( - designer_id INTEGER NOT NULL, - shop_id TEXT NOT NULL, - group_id TEXT NOT NULL, - PRIMARY KEY (designer_id, shop_id), - FOREIGN KEY (designer_id) REFERENCES designers(id) - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS designer_online ( - wechat_user_id TEXT PRIMARY KEY, - is_online INTEGER NOT NULL DEFAULT 0, - updated_at TEXT - ) - """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS round_robin ( - shop_id TEXT PRIMARY KEY, - last_index INTEGER NOT NULL DEFAULT 0 - ) - """) - conn.commit() - - -init_db() - - -# ========== 设计师管理 ========== - -def add_designer(name: str, wechat_user_id: str) -> int: - """添加设计师,返回 id""" - with _get_conn() as conn: - if _is_mysql(): - conn.execute( - "INSERT IGNORE INTO designers (name, wechat_user_id) VALUES (%s, %s)", - (name, wechat_user_id), - ) - else: - conn.execute( - "INSERT OR IGNORE INTO designers (name, wechat_user_id) VALUES (?, ?)", - (name, wechat_user_id), - ) - conn.commit() - row = conn.execute(_sql("SELECT id FROM designers WHERE wechat_user_id = ?"), (wechat_user_id,)).fetchone() - return row["id"] if row else 0 - - -def set_designer_shop(designer_id: int, shop_id: str, group_id: str): - """设置设计师在某店铺的分组 ID(同一设计师不同店铺不同 group_id)""" - with _get_conn() as conn: - if _is_mysql(): - conn.execute( - "REPLACE INTO designer_shops (designer_id, shop_id, group_id) VALUES (%s, %s, %s)", - (designer_id, shop_id, group_id), - ) - else: - conn.execute( - "INSERT OR REPLACE INTO designer_shops (designer_id, shop_id, group_id) VALUES (?, ?, ?)", - (designer_id, shop_id, group_id), - ) - conn.commit() - - -def update_online(wechat_user_id: str, is_online: bool): - """更新设计师在线状态(企微群「上线」/「下线」解析后调用)""" - from datetime import datetime - ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - with _get_conn() as conn: - if _is_mysql(): - conn.execute( - "REPLACE INTO designer_online (wechat_user_id, is_online, updated_at) VALUES (%s, %s, %s)", - (wechat_user_id, 1 if is_online else 0, ts), - ) - else: - conn.execute( - "INSERT OR REPLACE INTO designer_online (wechat_user_id, is_online, updated_at) VALUES (?, ?, ?)", - (wechat_user_id, 1 if is_online else 0, ts), - ) - conn.commit() - - -# ========== 派单 ========== - -def get_transfer_group_for_shop(shop_id: str) -> Optional[str]: - """ - 为店铺轮询派单,返回分组 ID。 - 从该店铺的在线设计师中轮询选一个,返回其在该店铺的 group_id。 - 无人在线则返回 None。 - """ - with _get_conn() as conn: - rows = conn.execute(_sql(""" - SELECT d.wechat_user_id, ds.group_id - FROM designer_shops ds - JOIN designers d ON d.id = ds.designer_id - JOIN designer_online o ON o.wechat_user_id = d.wechat_user_id AND o.is_online = 1 - WHERE ds.shop_id = ? - """), (shop_id,)).fetchall() - - if not rows: - return None - - with _get_conn() as conn: - rr = conn.execute(_sql("SELECT last_index FROM round_robin WHERE shop_id = ?"), (shop_id,)).fetchone() - last = rr["last_index"] if rr else 0 - idx = last % len(rows) - chosen = rows[idx] - if _is_mysql(): - conn.execute( - "REPLACE INTO round_robin (shop_id, last_index) VALUES (%s, %s)", - (shop_id, idx + 1), - ) - else: - conn.execute( - "INSERT OR REPLACE INTO round_robin (shop_id, last_index) VALUES (?, ?)", - (shop_id, idx + 1), - ) - conn.commit() - - return chosen["group_id"] - - -# ========== 查询 ========== - -def get_all_wechat_user_ids() -> list: - """获取所有设计师的 wechat_user_id(用于同步在线状态)""" - with _get_conn() as conn: - rows = conn.execute("SELECT wechat_user_id FROM designers").fetchall() - return [r["wechat_user_id"] for r in rows] - - -def list_designers(): - """列出所有设计师及其店铺分组""" - with _get_conn() as conn: - designers = conn.execute("SELECT id, name, wechat_user_id FROM designers").fetchall() - result = [] - for d in designers: - shops = conn.execute( - _sql("SELECT shop_id, group_id FROM designer_shops WHERE designer_id = ?"), - (d["id"],), - ).fetchall() - online = conn.execute( - _sql("SELECT is_online FROM designer_online WHERE wechat_user_id = ?"), - (d["wechat_user_id"],), - ).fetchone() - result.append({ - "id": d["id"], - "name": d["name"], - "wechat_user_id": d["wechat_user_id"], - "shops": {s["shop_id"]: s["group_id"] for s in shops}, - "is_online": bool(online and online["is_online"]), - }) - return result diff --git a/legacy/evolution/__init__.py b/legacy/evolution/__init__.py deleted file mode 100644 index f37b053..0000000 --- a/legacy/evolution/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Self-evolution MVP utilities for the customer service agent.""" - diff --git a/legacy/evolution/mvp.py b/legacy/evolution/mvp.py deleted file mode 100644 index 20998e7..0000000 --- a/legacy/evolution/mvp.py +++ /dev/null @@ -1,591 +0,0 @@ -from __future__ import annotations - -import json -import os -import sqlite3 -from dataclasses import asdict, dataclass -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Tuple - -ROOT = Path(__file__).resolve().parent.parent -ARTIFACT_DIR = ROOT / "evolution" / "artifacts" -DEFAULT_POLICY_PATH = ROOT / "config" / "evolution_policy.json" -DEFAULT_CANDIDATE_PATH = ROOT / "config" / "evolution_candidate.json" - -RISK_KEYWORDS = ( - "退款", - "退货", - "投诉", - "差评", - "举报", - "欺骗", - "骗人", - "不满意", - "生气", - "法院", - "起诉", -) -TRANSFER_HINTS = ("转人工", "人工", "为您转接", "专员", "稍后联系") -WEAK_REPLY_HINTS = ("不清楚", "不知道", "稍后", "晚点", "我再看下", "等会") -EMPATHY_HINTS = ("抱歉", "不好意思", "理解", "辛苦", "感谢反馈") - - -@dataclass -class Sample: - customer_id: str - acc_id: str - in_ts: str - in_text: str - out_ts: str - out_text: str - latency_sec: int - - -@dataclass -class Finding: - kind: str - severity: str - customer_id: str - acc_id: str - in_ts: str - in_text: str - out_text: str - detail: str - - -@dataclass -class ChatSourceConfig: - source: str = "auto" # auto | sqlite | mysql - sqlite_path: str = str(ROOT / "db" / "chat_log_db" / "chats.db") - mysql_host: str = os.getenv("MYSQL_HOST", "127.0.0.1") - mysql_port: int = int(os.getenv("MYSQL_PORT", "3306")) - mysql_user: str = os.getenv("MYSQL_USER", "root") - mysql_password: str = os.getenv("MYSQL_PASSWORD", "") - mysql_database: str = os.getenv("MYSQL_DATABASE", "ai_cs") - - -def _parse_ts(ts_text: str) -> Optional[datetime]: - if not ts_text: - return None - try: - return datetime.strptime(ts_text, "%Y-%m-%d %H:%M:%S") - except ValueError: - return None - - -def _to_ts_text(value: Any) -> str: - if isinstance(value, datetime): - return value.strftime("%Y-%m-%d %H:%M:%S") - if value is None: - return "" - return str(value) - - -def _iter_recent_conversations_sqlite( - cfg: ChatSourceConfig, - hours: int, - max_customers: int, - max_messages_per_customer: int, -) -> Iterable[Tuple[str, List[Dict[str, Any]]]]: - cutoff_dt = datetime.now() - timedelta(hours=hours) - cutoff_text = cutoff_dt.strftime("%Y-%m-%d %H:%M:%S") - db_path = Path(cfg.sqlite_path) - if not db_path.exists(): - return - conn = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True) - conn.row_factory = sqlite3.Row - try: - cur = conn.execute( - """ - SELECT customer_id, MAX(timestamp) AS last_ts - FROM chat_logs - WHERE timestamp >= ? - GROUP BY customer_id - ORDER BY last_ts DESC - LIMIT ? - """, - (cutoff_text, max_customers), - ) - customers = [dict(r) for r in cur.fetchall()] - for c in customers: - customer_id = str(c.get("customer_id") or "").strip() - if not customer_id: - continue - rows_cur = conn.execute( - """ - SELECT direction, message, timestamp, acc_id - FROM chat_logs - WHERE customer_id = ? AND timestamp >= ? - ORDER BY timestamp ASC, id ASC - LIMIT ? - """, - (customer_id, cutoff_text, max_messages_per_customer), - ) - rows = [dict(r) for r in rows_cur.fetchall()] - if rows: - yield customer_id, rows - finally: - conn.close() - - -def _iter_recent_conversations_mysql( - cfg: ChatSourceConfig, - hours: int, - max_customers: int, - max_messages_per_customer: int, -) -> Iterable[Tuple[str, List[Dict[str, Any]]]]: - try: - import pymysql - except Exception: - return - - cutoff_dt = datetime.now() - timedelta(hours=hours) - try: - conn = pymysql.connect( - host=cfg.mysql_host, - port=cfg.mysql_port, - user=cfg.mysql_user, - password=cfg.mysql_password, - database=cfg.mysql_database, - charset="utf8mb4", - cursorclass=pymysql.cursors.DictCursor, - autocommit=True, - ) - except Exception: - return - try: - with conn.cursor() as cur: - cur.execute( - """ - SELECT customer_id, MAX(timestamp) AS last_ts - FROM chat_logs - WHERE timestamp >= %s - GROUP BY customer_id - ORDER BY last_ts DESC - LIMIT %s - """, - (cutoff_dt, max_customers), - ) - customers = cur.fetchall() or [] - for c in customers: - customer_id = str(c.get("customer_id") or "").strip() - if not customer_id: - continue - with conn.cursor() as cur: - cur.execute( - """ - SELECT direction, message, timestamp, acc_id - FROM chat_logs - WHERE customer_id = %s AND timestamp >= %s - ORDER BY timestamp ASC, id ASC - LIMIT %s - """, - (customer_id, cutoff_dt, max_messages_per_customer), - ) - rows = cur.fetchall() or [] - normalized = [] - for r in rows: - normalized.append( - { - "direction": r.get("direction"), - "message": r.get("message"), - "timestamp": _to_ts_text(r.get("timestamp")), - "acc_id": r.get("acc_id"), - } - ) - if normalized: - yield customer_id, normalized - finally: - conn.close() - - -def _iter_recent_conversations( - cfg: ChatSourceConfig, - hours: int, - max_customers: int, - max_messages_per_customer: int, -) -> Iterable[Tuple[str, List[Dict[str, Any]]]]: - source = (cfg.source or "auto").strip().lower() - if source == "sqlite": - yield from _iter_recent_conversations_sqlite(cfg, hours, max_customers, max_messages_per_customer) - return - if source == "mysql": - yield from _iter_recent_conversations_mysql(cfg, hours, max_customers, max_messages_per_customer) - return - - # auto: prefer mysql when DB_TYPE=mysql, otherwise sqlite - db_type = os.getenv("DB_TYPE", "").strip().lower() - if db_type in ("mysql", "mariadb"): - got_any = False - for item in _iter_recent_conversations_mysql(cfg, hours, max_customers, max_messages_per_customer): - got_any = True - yield item - if got_any: - return - yield from _iter_recent_conversations_sqlite(cfg, hours, max_customers, max_messages_per_customer) - - -def build_samples( - hours: int = 24, - max_customers: int = 200, - max_messages_per_customer: int = 80, - chat_source: Optional[ChatSourceConfig] = None, -) -> List[Sample]: - cfg = chat_source or ChatSourceConfig() - samples: List[Sample] = [] - for customer_id, rows in _iter_recent_conversations( - cfg=cfg, - hours=hours, - max_customers=max_customers, - max_messages_per_customer=max_messages_per_customer, - ): - pending_in: Optional[Dict[str, Any]] = None - for row in rows: - direction = str(row.get("direction") or "") - if direction == "in": - pending_in = row - continue - if direction != "out" or pending_in is None: - continue - in_text = str(pending_in.get("message") or "").strip() - out_text = str(row.get("message") or "").strip() - if not in_text: - pending_in = None - continue - in_ts = _parse_ts(str(pending_in.get("timestamp") or "")) - out_ts = _parse_ts(str(row.get("timestamp") or "")) - latency = 0 - if in_ts and out_ts: - latency = int((out_ts - in_ts).total_seconds()) - samples.append( - Sample( - customer_id=customer_id, - acc_id=str(row.get("acc_id") or pending_in.get("acc_id") or ""), - in_ts=str(pending_in.get("timestamp") or ""), - in_text=in_text, - out_ts=str(row.get("timestamp") or ""), - out_text=out_text, - latency_sec=max(0, latency), - ) - ) - pending_in = None - return samples - - -def evaluate_samples(samples: List[Sample]) -> List[Finding]: - findings: List[Finding] = [] - for s in samples: - in_text = s.in_text - out_text = s.out_text - inbound_risky = any(k in in_text for k in RISK_KEYWORDS) - - if not out_text: - findings.append( - Finding( - kind="empty_reply", - severity="high", - customer_id=s.customer_id, - acc_id=s.acc_id, - in_ts=s.in_ts, - in_text=s.in_text, - out_text=s.out_text, - detail="收到消息但回复为空", - ) - ) - continue - - if s.latency_sec > 600: - findings.append( - Finding( - kind="slow_reply", - severity="medium", - customer_id=s.customer_id, - acc_id=s.acc_id, - in_ts=s.in_ts, - in_text=s.in_text, - out_text=s.out_text, - detail=f"回复耗时 {s.latency_sec}s (>600s)", - ) - ) - - if inbound_risky: - has_transfer = any(k in out_text for k in TRANSFER_HINTS) - has_empathy = any(k in out_text for k in EMPATHY_HINTS) - if not has_transfer: - findings.append( - Finding( - kind="risk_not_transferred", - severity="high", - customer_id=s.customer_id, - acc_id=s.acc_id, - in_ts=s.in_ts, - in_text=s.in_text, - out_text=s.out_text, - detail="高风险诉求未出现转人工提示", - ) - ) - if not has_empathy: - findings.append( - Finding( - kind="risk_no_empathy", - severity="medium", - customer_id=s.customer_id, - acc_id=s.acc_id, - in_ts=s.in_ts, - in_text=s.in_text, - out_text=s.out_text, - detail="高风险诉求回复缺少安抚语气", - ) - ) - - if any(k in out_text for k in WEAK_REPLY_HINTS): - findings.append( - Finding( - kind="weak_reply", - severity="medium", - customer_id=s.customer_id, - acc_id=s.acc_id, - in_ts=s.in_ts, - in_text=s.in_text, - out_text=s.out_text, - detail="回复存在低置信度兜底话术", - ) - ) - return findings - - -def summarize_findings(findings: List[Finding]) -> Dict[str, Any]: - by_kind: Dict[str, int] = {} - by_severity: Dict[str, int] = {} - for f in findings: - by_kind[f.kind] = by_kind.get(f.kind, 0) + 1 - by_severity[f.severity] = by_severity.get(f.severity, 0) + 1 - return {"total": len(findings), "by_kind": by_kind, "by_severity": by_severity} - - -def make_proposals(findings: List[Finding], sample_count: int) -> List[Dict[str, Any]]: - summary = summarize_findings(findings) - by_kind = summary["by_kind"] - - proposals: List[Dict[str, Any]] = [] - if by_kind.get("risk_not_transferred", 0) > 0: - proposals.append( - { - "id": "policy-risk-transfer", - "priority": "p0", - "module": "policy/prompt", - "title": "风险关键词触发后强制转人工", - "suggestion": "在风险路由的系统提示词中增加硬规则:遇到退款/投诉/法律威胁类诉求必须调用 transfer_to_human。", - "evidence_count": by_kind["risk_not_transferred"], - } - ) - if by_kind.get("risk_no_empathy", 0) > 0: - proposals.append( - { - "id": "tone-empathy-pack", - "priority": "p1", - "module": "policy/prompt", - "title": "高风险场景补充安抚模板", - "suggestion": "为投诉类回复追加一段安抚模板,降低激化概率。", - "evidence_count": by_kind["risk_no_empathy"], - } - ) - if by_kind.get("weak_reply", 0) > 0: - proposals.append( - { - "id": "fallback-reduction", - "priority": "p1", - "module": "intent/router", - "title": "减少低置信度兜底话术", - "suggestion": "出现“不清楚/稍后”等兜底词时,优先触发澄清问题或转人工而非直接结束。", - "evidence_count": by_kind["weak_reply"], - } - ) - if by_kind.get("slow_reply", 0) > 0: - proposals.append( - { - "id": "slow-path-timeout", - "priority": "p2", - "module": "tools/workflow", - "title": "慢链路超时与短回复兜底", - "suggestion": "当工具调用超过阈值时先发短确认回复,避免长时间无响应。", - "evidence_count": by_kind["slow_reply"], - } - ) - - proposals.append( - { - "id": "ops-regression-gate", - "priority": "p0", - "module": "eval/pipeline", - "title": "上线前回归门禁", - "suggestion": "新增候选策略必须在离线评测集上通过,再灰度 5% 流量后扩大。", - "evidence_count": sample_count, - } - ) - return proposals - - -def load_policy(path: Path = DEFAULT_POLICY_PATH) -> Dict[str, Any]: - if not path.exists(): - return { - "publish_gate": { - "min_sample_count": 30, - "max_high_findings_rate": 0.08, - "max_ai_fail_rate": 5.0, - "max_transfer_rate": 45.0, - } - } - return json.loads(path.read_text(encoding="utf-8")) - - -def can_publish_candidate(samples: List[Sample], findings: List[Finding], runtime_hours: int, policy: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]: - try: - from utils.metrics_tracker import get_runtime_summary - except Exception: - def get_runtime_summary(hours: int = 24) -> Dict[str, Any]: - return {"window_hours": hours, "counts": {}, "rates": {"ai_fail_rate": 0.0, "transfer_rate": 0.0}} - - gate = (policy or {}).get("publish_gate", {}) - min_sample_count = int(gate.get("min_sample_count", 30)) - max_high_rate = float(gate.get("max_high_findings_rate", 0.08)) - max_ai_fail_rate = float(gate.get("max_ai_fail_rate", 5.0)) - max_transfer_rate = float(gate.get("max_transfer_rate", 45.0)) - - high_cnt = sum(1 for f in findings if f.severity == "high") - sample_count = max(1, len(samples)) - high_rate = high_cnt / sample_count - runtime = get_runtime_summary(hours=runtime_hours) - ai_fail_rate = float(runtime.get("rates", {}).get("ai_fail_rate", 0.0)) - transfer_rate = float(runtime.get("rates", {}).get("transfer_rate", 0.0)) - - reasons = [] - ok = True - if len(samples) < min_sample_count: - ok = False - reasons.append(f"样本不足: {len(samples)} < {min_sample_count}") - if high_rate > max_high_rate: - ok = False - reasons.append(f"高危发现占比过高: {high_rate:.2%} > {max_high_rate:.2%}") - if ai_fail_rate > max_ai_fail_rate: - ok = False - reasons.append(f"AI失败率过高: {ai_fail_rate:.2f}% > {max_ai_fail_rate:.2f}%") - if transfer_rate > max_transfer_rate: - ok = False - reasons.append(f"转人工率过高: {transfer_rate:.2f}% > {max_transfer_rate:.2f}%") - - return ok, { - "sample_count": len(samples), - "high_findings": high_cnt, - "high_findings_rate": round(high_rate, 4), - "runtime": runtime, - "policy_gate": gate, - "reasons": reasons, - } - - -def _write_json(path: Path, payload: Dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - - -def _write_jsonl(path: Path, rows: Iterable[Dict[str, Any]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8") as f: - for row in rows: - f.write(json.dumps(row, ensure_ascii=False) + "\n") - - -def run_cycle( - hours: int = 24, - max_customers: int = 200, - max_messages_per_customer: int = 80, - runtime_hours: int = 24, - publish: bool = False, - chat_source: Optional[ChatSourceConfig] = None, - policy_path: Path = DEFAULT_POLICY_PATH, - candidate_path: Path = DEFAULT_CANDIDATE_PATH, -) -> Dict[str, Any]: - ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) - now_tag = datetime.now().strftime("%Y%m%d_%H%M%S") - source_error = "" - - try: - samples = build_samples( - hours=hours, - max_customers=max_customers, - max_messages_per_customer=max_messages_per_customer, - chat_source=chat_source, - ) - except Exception as e: - samples = [] - source_error = str(e) - findings = evaluate_samples(samples) - proposals = make_proposals(findings=findings, sample_count=len(samples)) - policy = load_policy(path=policy_path) - publish_ok, gate_report = can_publish_candidate( - samples=samples, - findings=findings, - runtime_hours=runtime_hours, - policy=policy, - ) - - sample_file = ARTIFACT_DIR / f"samples_{now_tag}.jsonl" - eval_file = ARTIFACT_DIR / f"eval_report_{now_tag}.json" - proposal_file = ARTIFACT_DIR / f"proposals_{now_tag}.json" - - _write_jsonl(sample_file, (asdict(s) for s in samples)) - _write_json( - eval_file, - { - "generated_at": datetime.now().isoformat(timespec="seconds"), - "sample_count": len(samples), - "finding_summary": summarize_findings(findings), - "publish_gate_report": gate_report, - }, - ) - _write_json( - proposal_file, - { - "generated_at": datetime.now().isoformat(timespec="seconds"), - "proposals": proposals, - }, - ) - - published = False - candidate_payload: Dict[str, Any] = {} - if publish and publish_ok: - candidate_payload = { - "version": f"candidate-{now_tag}", - "created_at": datetime.now().isoformat(timespec="seconds"), - "sample_file": str(sample_file), - "eval_file": str(eval_file), - "proposal_file": str(proposal_file), - "gate_report": gate_report, - "proposals": proposals, - "status": "ready_for_gray_5_percent", - } - _write_json(candidate_path, candidate_payload) - published = True - - source_view = asdict(chat_source) if chat_source else asdict(ChatSourceConfig()) - if source_view.get("mysql_password"): - source_view["mysql_password"] = "***" - - return { - "samples": len(samples), - "findings": len(findings), - "publish_ok": publish_ok, - "published": published, - "chat_source": source_view, - "source_error": source_error, - "artifacts": { - "samples": str(sample_file), - "evaluation": str(eval_file), - "proposals": str(proposal_file), - "candidate": str(candidate_path) if published else "", - }, - "gate_report": gate_report, - "top_proposals": proposals[:3], - } diff --git a/legacy/features/price_negotiation_with_registration.md b/legacy/features/price_negotiation_with_registration.md deleted file mode 100644 index a97f8a3..0000000 --- a/legacy/features/price_negotiation_with_registration.md +++ /dev/null @@ -1,101 +0,0 @@ -# 压价引导注册功能 - -## 功能说明 - -当客户觉得价格太贵时,AI 客服会分两步应对: - -### 第一步:直接让价 -- 只让价一次 -- 不低于底线价格 -- 话术自然多变 - -### 第二步:引导客户自己去注册 -如果让价后客户还是嫌贵,引导客户**自己去注册 xinhui.cloud**获取新用户余额。 - -## ⚠️ 核心要求 - -1. **每个话术都必须带网站地址 xinhui.cloud** -2. **强调客户自己去弄**(自己去操作/自己下单/自己弄) -3. 语气直接,不要软 -4. 不要说具体金额 - -## 触发条件 - -客户消息包含以下关键词: -- "贵" -- "有点贵" -- "太贵了" -- "便宜点" -- "少点" -- "打折" - -## 话术示例(每个都带网站) - -### 让价话术 -- "那给你少点,XX 吧" -- "最低 XX 了" -- "给你个优惠价 XX" -- "看你诚心要,XX 给你" - -### 注册引导话术(让价后仍嫌贵时使用) -**每个话术都必须包含 xinhui.cloud 网址!** - -1. 「嫌贵就去 xinhui.cloud 注册个号,自己有余额可以弄」 -2. 「xinhui.cloud 去注册下,新用户送体验金,自己弄便宜」 -3. 「去 xinhui.cloud 注册个账号,里面有余额,自己下单划算」 -4. 「自己上 xinhui.cloud 注册,新人有福利,比直接找我便宜」 -5. 「xinhui.cloud 去注册个号,自己充值有优惠,自己弄吧」 -6. 「嫌贵就自己去 xinhui.cloud 弄,注册送余额,比找我划算」 -7. 「xinhui.cloud 注册个号,自己充值自己弄,便宜些」 -8. 「去 xinhui.cloud 注册,里面有余额可以用,自己操作吧」 - -## 对话示例 - -``` -客户:这个多少钱? -AI: 20 元 - -客户:太贵了,便宜点吧 -AI: 那给你少点,15 吧 - -客户:还是有点贵 -AI: 嫌贵就去 xinhui.cloud 注册个号,自己有余额可以弄 -``` - -或: - -``` -客户:有点贵啊 -AI: 最低 15 了 - -客户:还是贵 -AI: xinhui.cloud 去注册下,新用户送体验金,自己弄便宜 -``` - -## 注意事项 - -1. ⚠️ **每个话术都必须带 xinhui.cloud 网址** -2. ⚠️ **强调"自己去弄""自己操作""自己下单"** -3. 不说具体余额金额 -4. 每次选择不同话术变体 -5. 只在让价后仍嫌贵时使用 -6. 语气直接,不要软 - -## 代码位置 - -- 技能文档:`/skills/customer-service/customer-skill.md` -- Agent 代码:`/core/pydantic_ai_agent.py` 第 1745 行 - -## 测试方法 - -```bash -# 启动 AI 客服系统 -systemctl start ai-cs - -# 查看日志 -journalctl -u ai-cs -f -``` - -模拟客户消息:"太贵了,便宜点吧" - -预期 AI 回复:先让价,如仍嫌贵则引导客户自己去 xinhui.cloud 注册 diff --git a/legacy/features/risk_customer_detection.md b/legacy/features/risk_customer_detection.md deleted file mode 100644 index c2ef114..0000000 --- a/legacy/features/risk_customer_detection.md +++ /dev/null @@ -1,129 +0,0 @@ -# 找茬客户识别功能 - -## 功能说明 - -识别高风险找茬客户,建议 AI 客服不做这单,直接拒绝或转人工。 - ---- - -## 🚨 高风险信号(识别 3 个以上→拒绝接单) - -| 信号 | 示例 | 风险等级 | -|------|------|----------| -| **下单后立即申请退款** | 刚拍下就退款 0.88 元 | 🔴 高 | -| **从高价砍到低价** | 30 元砍到 10 元 | 🔴 高 | -| **反复问退款(2 次+)** | "不满意可以退吗"×2 | 🔴 高 | -| **质疑服务内容** | "源文件还是什么" | 🟡 中 | -| **质疑价值** | "就一张图片" | 🟡 中 | -| **想占便宜** | "小一点就快一点的嘛" | 🟡 中 | -| **重复问同一问题** | 同样的问题问 2 次+ | 🔴 高 | - ---- - -## 应对策略 - -### 识别到 3 个以上高风险信号 - -**直接拒绝接单** - -**话术示例**: -- 「不好意思,这单做不了」 -- 「去别家做吧」 -- 「这单接不了,你找别人吧」 - -**不要**: -- ❌ 不要解释原因 -- ❌ 不要讨价还价 -- ❌ 不要妥协 - ---- - -## 真实案例分析 - -### 案例:高风险找茬客户 - -**对话记录**: -``` -客户:这个原图有吗 -AI: 没问题,拍下安排。 -客户:拍啦 -[客户立即申请退款 0.88 元] -客户:太贵了,10 元 -AI: 30 -[退款成功] -客户:要多久? -客户:300×50cm -AI: 1 个小时 -客户:小一点就快一点的嘛 -客户:20 可以吗 -AI: [不回应] -客户:25 -客户:源文件还是什么? -客户:就一张图片 -客户:不满意可以退吗 -客户:不满意可以退吗(第 2 次问) -AI: 去别家做吧 -``` - -**风险信号识别**: -1. ✅ 下单后立即申请退款 -2. ✅ 从 30 砍到 10 元 -3. ✅ 质疑价值("就一张图片") -4. ✅ 想占便宜("小一点就快一点") -5. ✅ 重复问退款(2 次) - -**结论**:5 个高风险信号 → **拒绝接单** ✅ - ---- - -## 代码位置 - -- Agent 代码:`/core/pydantic_ai_agent.py` - 找茬客户识别规则 -- 技能文档:`/skills/customer-service/customer-skill.md` - 客服话术指南 - ---- - -## 测试方法 - -### 模拟高风险客户 - -```bash -# 启动 AI 客服 -systemctl start ai-cs - -# 查看日志 -journalctl -u ai-cs -f -``` - -**模拟对话**: -``` -客户:20 可以吗 -AI: 最低 30 -客户:25 -客户:不满意可以退吗 -客户:不满意可以退吗(第 2 次) -``` - -**预期 AI 回复**: -- 「不好意思,这单做不了」 -- 「去别家做吧」 - ---- - -## 注意事项 - -1. **识别 3 个以上信号才拒绝**:不要误伤正常客户 -2. **话术简洁**:不要解释原因 -3. **态度坚定**:不要妥协 -4. **不调用报价工具**:直接拒绝 - ---- - -## 与转人工的区别 - -| 情况 | 处理方式 | -|------|----------| -| 退款/投诉/情绪激动 | 转人工 | -| 找茬客户(3 个+信号) | 直接拒绝 | -| 敏感内容 | 直接拒绝 | - diff --git a/legacy/features/self_evolution_mvp.md b/legacy/features/self_evolution_mvp.md deleted file mode 100644 index 1fd2436..0000000 --- a/legacy/features/self_evolution_mvp.md +++ /dev/null @@ -1,45 +0,0 @@ -# 自我进化 MVP(可控版) - -目标:让客服 agent 持续变聪明,同时避免“自动改坏线上”。 - -## 1. 已落地能力 - -- 失败样本采集:从 `db/chat_log_db/chats.db` 抽取近 N 小时客服问答对。 -- 离线评测:自动识别高风险未转人工、低置信度兜底、慢回复等问题。 -- 改进建议生成:输出可执行的模块级 proposal(prompt/router/workflow)。 -- 发布门禁:结合运行指标(`config/.runtime_metrics.jsonl`)判断是否允许发布候选版本。 -- 候选产物:通过门禁后写入 `config/evolution_candidate.json`,用于 5% 灰度。 - -## 2. 运行方式 - -```bash -python scripts/evolution_cycle.py --hours 24 --publish -``` - -默认即读取线上 MySQL(`--source mysql`)。连接信息来自 `.env` 的 `MYSQL_*`。 - -常用参数: - -- `--max-customers 200` -- `--max-messages-per-customer 80` -- `--runtime-hours 24` -- `--policy-path config/evolution_policy.json` - -## 3. 产物说明 - -运行后会在 `evolution/artifacts/` 生成: - -- `samples_*.jsonl`:评测样本 -- `eval_report_*.json`:评测摘要与门禁结果 -- `proposals_*.json`:改进建议列表 - -当 `--publish` 且门禁通过时: - -- 写入 `config/evolution_candidate.json` -- 状态标记为 `ready_for_gray_5_percent` - -## 4. 下一步建议 - -- 把 `scripts/evolution_cycle.py` 加入每日定时任务(例如凌晨 2 点)。 -- 在灰度层接入 `evolution_candidate.json` 的版本号,按店铺或客户哈希做 5% 放量。 -- 将 proposal 落地为具体 patch 后,先跑 `tests/` 回归,再扩大流量。 diff --git a/legacy/features/text_surcharge.md b/legacy/features/text_surcharge.md deleted file mode 100644 index 75cf94e..0000000 --- a/legacy/features/text_surcharge.md +++ /dev/null @@ -1,158 +0,0 @@ -# 文字加价功能 - -## 功能说明 - -当识别到图片含有很多文字时,AI 客服系统会自动提高报价,不能低价。 - -**核心原则**:有文字跟没文字是两个价格! - ---- - -## 价格规则 - -### 含文字很多时 - -| 原复杂度 | 原价区间 | 加价后 | 加价后区间 | -|---------|---------|--------|----------| -| simple | 10-15 元 | → normal | 15-20 元 | -| normal | 15-20 元 | → complex | 20-25 元 | -| complex | 20-25 元 | 保持不变 | 20-25 元 | -| hard | 25-30 元 | 保持不变 | 25-30 元 | - -### 判断标准 - -**含文字很多**(需要加价): -- ✅ 图片里有大量小字 -- ✅ 需要精细保留文字清晰度 -- ✅ 文字需要清晰化处理 - -**不含文字或文字很少**(不加价): -- ❌ 图片干净,没文字 -- ❌ 只有零星几个大字 - ---- - -## 代码修改 - -### 1. image_analyzer.py - -文件:`/root/ai_customer_service/ai_cs/image/image_analyzer.py` - -**修改位置**:第 528-542 行 - -```python -# 【重要】含文字很多时,不能低价,必须 complex 起步(20 元以上) -# 有文字跟没文字是两个价格 -if has_text == "yes": - if complexity == "simple": - # 简单但含文字 → 提升到 normal 价格 - price_min, price_max = self.PRICE_MAP["normal"] - reason = "含文字,需精细处理" - elif complexity == "normal": - # normal 含文字 → 提升到 complex 价格 - price_min, price_max = self.PRICE_MAP["complex"] - reason = "含文字,需精细处理" - # complex/hard 保持原价,已经够高 -``` - -### 2. pydantic_ai_agent.py - -文件:`/root/ai_customer_service/ai_cs/core/pydantic_ai_agent.py` - -**修改位置**:第 863-869 行 - -```python -【文字加价规则】⚠️ 重要 -- 含文字很多时不能低价,有文字跟没文字是两个价格 -- 含文字的图必须 complex 起步(20 元以上) -- 客户嫌贵时明确告知:「有文字跟没文字是两个价格」 -- 简单图但含文字 → normal 价格(15-20 元) -- normal 图含文字 → complex 价格(20-25 元) -``` - -### 3. customer-skill.md - -文件:`/root/ai_customer_service/ai_cs/skills/customer-service/customer-skill.md` - -**新增章节**:⑫ 文字加价规则 - ---- - -## 对话示例 - -### 示例 1:简单图但含文字 - -``` -客户:[发送一张含文字的图片] -AI: 图里有不少字,要精细处理,20 元 -客户:这么贵 -AI: 有文字的图跟没文字的价格不一样,已经是最低价了 -``` - -### 示例 2:normal 图含文字 - -``` -客户:这个多少钱? -AI: 25 元 -客户:太贵了 -AI: 含文字的图要精细处理,成本更高,跟没文字的价格不一样 -``` - -### 示例 3:客户问为什么贵 - -``` -客户:这个为什么比那个贵? -AI: 这个图含文字,需要精细处理,有文字跟没文字是两个价格 -``` - ---- - -## 话术要点 - -### 必须包含的信息 -1. ✅ 明确告知「有文字跟没文字是两个价格」 -2. ✅ 说明「文字需要精细处理」 -3. ✅ 强调「已经是最低价」 - -### 常用话术 -- 「有文字跟没文字是两个价格」 -- 「文字处理要精细,成本高」 -- 「含文字的图都这个价」 -- 「文字越多越贵」 -- 「已经是最低价了,含文字的都这样」 - ---- - -## 测试方法 - -### 测试步骤 - -1. **准备测试图片** - - 一张含有很多小字的图片 - - 一张干净的图片(对比) - -2. **发送给 AI 客服** - ```bash - systemctl start ai-cs - journalctl -u ai-cs -f - ``` - -3. **验证报价** - - 含文字图片:应该报价 20 元以上 - - 干净图片:正常报价 10-15 元 - -### 预期结果 - -- ✅ 含文字图片自动加价 -- ✅ 客户嫌贵时明确告知「有文字跟没文字是两个价格」 -- ✅ 价格必须为 5 的整数倍 - ---- - -## 注意事项 - -1. **只针对小字**:大字不加价 -2. **价格必须为 5 的倍数**:15/20/25/30 -3. **明确告知客户**:不要偷偷加价 -4. **坚持原则**:客户嫌贵也要说明原因 - diff --git a/legacy/features/tuhui_upload.md b/legacy/features/tuhui_upload.md deleted file mode 100644 index e7f002e..0000000 --- a/legacy/features/tuhui_upload.md +++ /dev/null @@ -1,137 +0,0 @@ -# 图绘上传功能 - -## 功能说明 - -AI 客服处理完图片后,上传到图绘平台并定价,告诉客户「这个图片是找到的」,然后问客户要下载还是购买。 - ---- - -## ⚠️ 重要话术 - -**必须说**: -- ✅ 「找到了!图片已经上传到图绘平台了」 -- ✅ 「你是要下载还是直接购买?」 -- ✅ 「价格 XX 元」 - -**不要说**: -- ❌ 「处理好了」 -- ❌ 「修复好了」 -- ❌ 「AI 修复的」 - ---- - -## 使用流程 - -1. **客户发图** → AI 客服收到图片 -2. **图片处理** → 调用图片处理工具 -3. **上传图绘** → 调用 `upload_to_tuhui_platform()` 工具 -4. **返回话术** → 「找到了!...你是要下载还是购买?」 - ---- - -## 对话示例 - -### 示例 1:标准流程 - -``` -客户:[发送图片] -AI: 收到了,稍等... - -[AI 调用图片处理工具] -[AI 调用 upload_to_tuhui_platform 工具] - -AI: 找到了!图片已经上传到图绘平台了,作品 ID: 123 -AI: 你是要下载还是直接购买?价格 20 元。 -``` - -### 示例 2:客户选择下载 - -``` -AI: 找到了!图片已经上传到图绘平台了 -AI: 你是要下载还是直接购买?价格 20 元。 - -客户:下载 -AI: 好的,拍下后就可以下载了 -``` - -### 示例 3:客户选择购买 - -``` -AI: 你是要下载还是直接购买?价格 20 元。 - -客户:购买 -AI: 好的,拍下就行,付款后发你高清原图 -``` - -### 示例 4:客户问在哪里 - -``` -客户:弄好了吗 -AI: 找到了,已经上传到图绘平台了 -AI: 作品 ID: 123,你是要下载还是购买? -``` - ---- - -## 配置说明 - -### .env 配置 - -```bash -# 图绘平台配置 -TUHUI_BASE_URL=http://127.0.0.1:8002 -TUHUI_PHONE=17520145271 # 图绘账号手机号 -TUHUI_PASSWORD=zuowei1216 # 图绘账号密码 -TUHUI_DEFAULT_PRICE=20 # 默认定价(元) -``` - -### AI Agent 工具 - -```python -@self.agent.tool -async def upload_to_tuhui_platform( - ctx: RunContext[AgentDeps], - image_path: str, - title: str, - price: int = 20 -) -> str: - """将处理好的图片上传到图绘平台并定价""" - # 返回:「找到了!图片已经上传到图绘平台了,作品 ID: 123。你是要下载还是直接购买?价格 20 元。」 -``` - ---- - -## 代码位置 - -- 上传服务:`/services/service_tuhui_upload.py` -- Agent 工具:`/core/pydantic_ai_agent.py` 第 220 行 -- 客服话术:`/skills/customer-service/customer-skill.md` 第⑭节 - ---- - -## 注意事项 - -1. ⚠️ **必须说「找到了」**,不要说「处理好了」 -2. ⚠️ **必须问「要下载还是购买」** -3. ⚠️ **必须说价格** -4. ✅ 图片是"找到的",不是"处理的" -5. ✅ 客户可以选择下载或购买 - ---- - -## 测试方法 - -```bash -# 1. 配置图绘账号 -vi /root/ai_customer_service/ai_cs/.env - -# 2. 重启 AI 客服 -systemctl restart ai-cs - -# 3. 查看日志 -journalctl -u ai-cs -f - -# 4. 发送图片测试 -# 观察日志中的上传结果和话术 -``` - diff --git a/legacy/find_image_flow.py b/legacy/find_image_flow.py deleted file mode 100644 index f282118..0000000 --- a/legacy/find_image_flow.py +++ /dev/null @@ -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) diff --git a/legacy/image_workflow_router.py b/legacy/image_workflow_router.py deleted file mode 100644 index 7098b0a..0000000 --- a/legacy/image_workflow_router.py +++ /dev/null @@ -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 diff --git a/legacy/intent_analyzer.py b/legacy/intent_analyzer.py deleted file mode 100644 index 70fc499..0000000 --- a/legacy/intent_analyzer.py +++ /dev/null @@ -1,159 +0,0 @@ -# -*- coding: utf-8 -*- -""" -语义匹配 - 用 embedding 做意图/情绪识别 -配置 EMBEDDING_MODEL 后启用,否则回退到关键词 -""" -import os -import logging -from dataclasses import dataclass -from typing import Optional - -logger = logging.getLogger(__name__) - -# 意图模板(用于 embedding 相似度匹配) -INTENT_TEMPLATES = { - "询价": "我想问一下价格多少钱", - "发图": "我发图给你看看", - "砍价": "能不能便宜点太贵了", - "批量": "我要做很多张图批量", - "加急": "能不能快点很急", - "售后": "已经付款了什么时候好", - "修改": "不满意要改一下", - "转接": "我要退款投诉", - "打招呼": "你好在吗有人吗", -} -EMOTION_TEMPLATES = { - "平静": "好的谢谢", - "着急": "快点啊很急", - "不满": "怎么这么慢不满意", - "砍价": "太贵了便宜点", -} - - -_template_embeddings: dict = {} - - -@dataclass -class IntentDecision: - intent: str = "" - source: str = "none" # embedding / keyword / none - score: float = 0.0 - -def _get_embedding(text: str, cache_key: str = None) -> Optional[list]: - """调用 embedding API,失败返回 None。cache_key 用于缓存模板向量""" - model = os.getenv("EMBEDDING_MODEL", "") - if not model: - return None - if cache_key and cache_key in _template_embeddings: - return _template_embeddings[cache_key] - try: - from openai import OpenAI - client = OpenAI( - api_key=os.getenv("OPENAI_API_KEY"), - base_url=os.getenv("OPENAI_BASE_URL"), - ) - resp = client.embeddings.create(model=model, input=text[:2000]) - emb = resp.data[0].embedding - if cache_key: - _template_embeddings[cache_key] = emb - return emb - except Exception as e: - logger.debug(f"embedding 失败: {e}") - return None - - -def _cosine_sim(a: list, b: list) -> float: - if not a or not b or len(a) != len(b): - return 0.0 - dot = sum(x * y for x, y in zip(a, b)) - na = sum(x * x for x in a) ** 0.5 - nb = sum(y * y for y in b) ** 0.5 - if na == 0 or nb == 0: - return 0.0 - return dot / (na * nb) - - -def detect_intent_embedding(msg: str) -> Optional[str]: - """用 embedding 检测意图,未配置或失败返回 None。""" - decision = detect_intent_embedding_decision(msg) - return decision.intent or None - - -def detect_intent_embedding_decision(msg: str) -> IntentDecision: - """返回 embedding 意图决策(含分值)。""" - msg_emb = _get_embedding(msg) - if not msg_emb: - return IntentDecision() - best_intent, best_score = "", 0.0 - for intent, template in INTENT_TEMPLATES.items(): - tpl_emb = _get_embedding(template, cache_key=f"intent_{intent}") - if not tpl_emb: - continue - sim = _cosine_sim(msg_emb, tpl_emb) - if sim > best_score: - best_score = sim - best_intent = intent - if best_score > 0.6: - return IntentDecision(intent=best_intent, source="embedding", score=float(best_score)) - return IntentDecision() - - -def detect_emotion_embedding(msg: str) -> Optional[str]: - """用 embedding 检测情绪""" - msg_emb = _get_embedding(msg) - if not msg_emb: - return None - best_emotion, best_score = "", 0.0 - for emotion, template in EMOTION_TEMPLATES.items(): - tpl_emb = _get_embedding(template, cache_key=f"emotion_{emotion}") - if not tpl_emb: - continue - sim = _cosine_sim(msg_emb, tpl_emb) - if sim > best_score: - best_score = sim - best_emotion = emotion - return best_emotion if best_score > 0.55 else None - - -def detect_intent_keywords(msg: str) -> str: - """关键词回退:无 embedding 时使用""" - m = (msg or "").strip().lower() - if any(k in m for k in ["退款", "退货", "投诉"]): - return "转接" - if any(k in m for k in ["多张", "批量", "很多", "几十张"]): - return "批量" - if any(k in m for k in ["快点", "加急", "很急", "着急"]): - return "加急" - if any(k in m for k in ["便宜", "贵", "少点", "打折"]): - return "砍价" - if any(k in m for k in ["改", "修改", "不满意"]): - return "修改" - if any(k in m for k in ["多少钱", "价格", "报价", "多钱", "收费", "怎么收费", "咋收费"]): - return "询价" - if any(k in m for k in ["在吗", "你好", "有人"]): - return "打招呼" - return "" - - -def detect_intent(msg: str) -> IntentDecision: - """ - AI 意图判定 + 规则兜底: - 1) 有 embedding 配置时先走 embedding。 - 2) 失败/低置信时回退关键词规则。 - """ - text = (msg or "").strip() - if not text: - return IntentDecision() - - try: - emb_decision = detect_intent_embedding_decision(text) - except Exception: - emb_decision = IntentDecision() - if emb_decision.intent: - return emb_decision - - kw_intent = detect_intent_keywords(text) - if kw_intent: - return IntentDecision(intent=kw_intent, source="keyword", score=0.0) - return IntentDecision() - diff --git a/legacy/mail/__init__.py b/legacy/mail/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/mail/email_receiver.py b/legacy/mail/email_receiver.py deleted file mode 100644 index 6b791a0..0000000 --- a/legacy/mail/email_receiver.py +++ /dev/null @@ -1,331 +0,0 @@ -""" -邮件接收模块 - 监控收件箱,客户发图询价/下单自动处理 - -流程: - 客户发邮件(含图片附件)→ 自动分析图片复杂度 → 回复报价 - 客户回复"拍了"/"确认" → 创建处理任务 → Gemini 作图 → 发结果 -""" -import asyncio -import imaplib -import email -import email.header -import os -import tempfile -import logging -from datetime import datetime -from email.header import decode_header -from typing import Optional - -logger = logging.getLogger(__name__) - -# 支持的图片格式 -IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") - - -def _decode_str(value: str) -> str: - """解码邮件头部字段(处理中文编码)""" - if not value: - return "" - parts = decode_header(value) - result = [] - for part, charset in parts: - if isinstance(part, bytes): - try: - result.append(part.decode(charset or "utf-8", errors="replace")) - except Exception: - result.append(part.decode("utf-8", errors="replace")) - else: - result.append(part) - return "".join(result) - - -class EmailReceiver: - """IMAP 邮件接收器,轮询新邮件并自动处理图片询价""" - - def __init__( - self, - imap_host: str = "imap.qq.com", - imap_port: int = 993, - username: str = "", - password: str = "", - poll_interval: int = 30, - ): - self.imap_host = imap_host - self.imap_port = imap_port - self.username = username - self.password = password - self.poll_interval = poll_interval - self._running = False - self._send_reply = None # 注入的回复函数 - - def register_reply_callback(self, callback): - """注入回复函数(直接用 email_sender 回复)""" - self._send_reply = callback - - # ========== 主循环 ========== - - async def start(self): - """启动轮询(作为后台任务运行)""" - self._running = True - logger.info(f"[EmailReceiver] 启动,每 {self.poll_interval}s 检查一次收件箱") - while self._running: - try: - await self._check_inbox() - except Exception as e: - logger.error(f"[EmailReceiver] 轮询异常: {e}") - await asyncio.sleep(self.poll_interval) - - def stop(self): - self._running = False - - # ========== 收件箱检查 ========== - - async def _check_inbox(self): - """连接 IMAP,检查未读邮件""" - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self._check_inbox_sync) - - def _check_inbox_sync(self): - """同步版收件箱检查(在线程池里跑,避免阻塞事件循环)""" - try: - conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port) - conn.login(self.username, self.password) - conn.select("INBOX") - - # 搜索未读邮件 - _, msg_ids = conn.search(None, "UNSEEN") - ids = msg_ids[0].split() - if not ids: - conn.logout() - return - - logger.info(f"[EmailReceiver] 发现 {len(ids)} 封未读邮件") - - for msg_id in ids: - try: - _, data = conn.fetch(msg_id, "(RFC822)") - raw = data[0][1] - msg = email.message_from_bytes(raw) - self._process_email_sync(msg) - # 标记为已读 - conn.store(msg_id, "+FLAGS", "\\Seen") - except Exception as e: - logger.error(f"[EmailReceiver] 处理邮件 {msg_id} 失败: {e}") - - conn.logout() - except Exception as e: - logger.error(f"[EmailReceiver] IMAP 连接失败: {e}") - - # ========== 邮件处理 ========== - - def _process_email_sync(self, msg): - """处理单封邮件:提取发件人、附件图片,触发分析和回复""" - sender = _decode_str(msg.get("From", "")) - subject = _decode_str(msg.get("Subject", "(无主题)")) - - # 提取发件人邮箱地址 - sender_email = self._extract_email_addr(sender) - if not sender_email: - logger.warning(f"[EmailReceiver] 无法解析发件人地址: {sender}") - return - - logger.info(f"[EmailReceiver] 处理邮件 | 来自: {sender_email} | 主题: {subject}") - - # 提取正文 - body_text = self._extract_body(msg) - - # 提取图片附件 - image_paths = self._extract_images(msg) - - # 异步触发处理(把同步上下文切回事件循环) - loop = asyncio.new_event_loop() - try: - loop.run_until_complete( - self._handle_email(sender_email, subject, body_text, image_paths) - ) - finally: - loop.close() - # 清理临时图片 - for p in image_paths: - try: - os.remove(p) - except Exception: - pass - - async def _handle_email( - self, - sender_email: str, - subject: str, - body: str, - image_paths: list, - ): - """根据邮件内容决定如何处理""" - body_lower = (body or "").lower() - - # ① 有图片附件 → 分析图片,回复报价 - if image_paths: - await self._handle_image_inquiry(sender_email, subject, image_paths) - return - - # ② 纯文字邮件 → 引导发图 - await self._reply_email( - to=sender_email, - subject=f"Re: {subject}", - body=self._html( - "您好!收到您的邮件。

" - "请将您需要处理的图片作为附件发送过来,我们会尽快为您报价。

" - "支持格式:JPG、PNG、WEBP 等常见图片格式。" - ), - ) - - async def _handle_image_inquiry( - self, sender_email: str, subject: str, image_paths: list - ): - """分析图片,回复报价""" - from image.image_analyzer import image_analyzer - - quotes = [] - for idx, img_path in enumerate(image_paths, 1): - try: - # image_analyzer 支持本地路径 - result = await image_analyzer.analyze(img_path) - price = result.get("price_suggest", 30) - reason = result.get("reason", "") - label = { - "simple": "画面简洁", - "normal": "一般复杂度", - "complex": "细节较多", - "hard": "非常复杂", - }.get(result.get("complexity", ""), "") - quotes.append( - f"图片{idx}:{label},建议报价 {price} 元" - + (f"({reason})" if reason else "") - ) - except Exception as e: - logger.error(f"[EmailReceiver] 图片分析失败: {e}") - quotes.append(f"图片{idx}:分析失败,建议报价 30 元") - - # 多图打包优惠 - n = len(image_paths) - if n >= 5: - tip = f"

📦 您共发来 {n} 张图片,支持打包优惠,欢迎咨询。" - elif n >= 3: - tip = f"

📦 您共发来 {n} 张图片,3张以上可享9折优惠。" - else: - tip = "" - - quote_html = "
".join(quotes) - body = self._html( - f"您好!感谢您发来图片,已为您完成分析:

" - f"{quote_html}{tip}

" - f"如需处理,请直接在淘宝店铺下单,付款后我们会尽快为您完成制作并发回。
" - f"如有疑问欢迎回复此邮件。" - ) - - await self._reply_email( - to=sender_email, - subject=f"Re: {subject}" if subject else "您的图片报价", - body=body, - ) - logger.info(f"[EmailReceiver] 已向 {sender_email} 回复报价") - - # ========== 工具方法 ========== - - async def _reply_email(self, to: str, subject: str, body: str): - """发送回复邮件""" - try: - from mail.email_sender import email_sender - result = email_sender.send(to_email=to, subject=subject, body=body) - if not result.get("success"): - logger.error(f"[EmailReceiver] 回复发送失败: {result.get('message')}") - except Exception as e: - logger.error(f"[EmailReceiver] 回复异常: {e}") - - def _extract_email_addr(self, from_field: str) -> Optional[str]: - """从 From 字段提取邮箱地址""" - import re - m = re.search(r'[\w\.\+\-]+@[\w\.\-]+\.\w+', from_field) - return m.group(0) if m else None - - def _extract_body(self, msg) -> str: - """提取邮件纯文本正文""" - body = "" - if msg.is_multipart(): - for part in msg.walk(): - ct = part.get_content_type() - if ct == "text/plain": - charset = part.get_content_charset() or "utf-8" - try: - body += part.get_payload(decode=True).decode(charset, errors="replace") - except Exception: - pass - else: - charset = msg.get_content_charset() or "utf-8" - try: - body = msg.get_payload(decode=True).decode(charset, errors="replace") - except Exception: - pass - return body.strip() - - def _extract_images(self, msg) -> list: - """提取邮件中的图片附件,保存到临时文件,返回路径列表""" - paths = [] - for part in msg.walk(): - content_disposition = part.get("Content-Disposition", "") - content_type = part.get_content_type() - - is_attachment = "attachment" in content_disposition - is_image_type = content_type.startswith("image/") - - filename = part.get_filename() - if filename: - filename = _decode_str(filename) - - # 判断是否是图片 - if not (is_image_type or (filename and any( - filename.lower().endswith(ext) for ext in IMAGE_EXTS - ))): - continue - - try: - data = part.get_payload(decode=True) - if not data: - continue - suffix = ".jpg" - if filename: - ext = os.path.splitext(filename)[1].lower() - if ext in IMAGE_EXTS: - suffix = ext - fd, tmp_path = tempfile.mkstemp(suffix=suffix, prefix="email_img_") - with os.fdopen(fd, "wb") as f: - f.write(data) - paths.append(tmp_path) - logger.info(f"[EmailReceiver] 提取图片附件: {filename} → {tmp_path}") - except Exception as e: - logger.error(f"[EmailReceiver] 提取附件失败: {e}") - - return paths - - @staticmethod - def _html(content: str) -> str: - return f""" - - {content} -

-
-

修图客服 · 自动回复

- - """ - - -# ========== 全局实例(从 .env 读取配置)========== -from dotenv import load_dotenv -load_dotenv() - -email_receiver = EmailReceiver( - imap_host="imap.qq.com", - imap_port=993, - username=os.getenv("SMTP_USER", ""), - password=os.getenv("SMTP_PASSWORD", ""), - poll_interval=int(os.getenv("EMAIL_POLL_INTERVAL", "30")), -) diff --git a/legacy/mail/email_sender.py b/legacy/mail/email_sender.py deleted file mode 100644 index 24cec55..0000000 --- a/legacy/mail/email_sender.py +++ /dev/null @@ -1,112 +0,0 @@ -"""邮件发送模块""" -import os -import smtplib -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.mime.image import MIMEImage -from email.header import Header -from typing import Optional, List -from dotenv import load_dotenv - -load_dotenv() - - -class EmailSender: - """邮件发送""" - - def __init__(self): - self.smtp_host = os.getenv("SMTP_HOST", "") - self.smtp_port = int(os.getenv("SMTP_PORT", "587")) - self.smtp_user = os.getenv("SMTP_USER", "") - self.smtp_password = os.getenv("SMTP_PASSWORD", "") - self.sender_name = os.getenv("SENDER_NAME", "修图客服") - - def send( - self, - to_email: str, - subject: str, - body: str, - images: Optional[List[str]] = None - ) -> dict: - """ - 发送邮件 - - Args: - to_email: 收件人邮箱 - subject: 邮件主题 - body: 邮件正文 - images: 图片路径列表 - - Returns: - {"success": bool, "message": str} - """ - if not self.smtp_host or not self.smtp_user: - return {"success": False, "message": "未配置邮件SMTP"} - - try: - # 创建邮件 - msg = MIMEMultipart('related') - msg['From'] = f"{Header(self.sender_name, 'utf-8').encode()} <{self.smtp_user}>" - msg['To'] = to_email - msg['Subject'] = subject - - # 添加正文 - msg.attach(MIMEText(body, 'html', 'utf-8')) - - # 添加图片 - if images: - for idx, img_path in enumerate(images): - if os.path.exists(img_path): - with open(img_path, 'rb') as f: - img = MIMEImage(f.read()) - img.add_header('Content-ID', f'') - msg.attach(img) - - # 发送邮件(失败时重试 1 次) - import time - last_err = None - for attempt in range(2): - try: - server = smtplib.SMTP(self.smtp_host, self.smtp_port) - server.starttls() - server.login(self.smtp_user, self.smtp_password) - server.sendmail(self.smtp_user, to_email, msg.as_string()) - server.quit() - return {"success": True, "message": "发送成功"} - except Exception as e: - last_err = e - if attempt == 0: - time.sleep(2) - return {"success": False, "message": f"发送失败: {str(last_err)}"} - except Exception as e: - return {"success": False, "message": f"发送失败: {str(e)}"} - - def send_completed_work( - self, - to_email: str, - customer_name: str, - image_description: str, - result_images: List[str] - ) -> dict: - """发送完成的作品""" - subject = f"您的修图作品已完成 - {image_description}" - - body = f""" - - -

您好 {customer_name},您的修图作品已完成!

-

感谢您选择我们的服务。以下是您处理后的图片:

-

处理内容: {image_description}

-
-

如有任何问题,请随时联系我们。

-
-

祝您生活愉快!

- - - """ - - return self.send(to_email, subject, body, result_images) - - -# 全局实例 -email_sender = EmailSender() diff --git a/legacy/message_orchestrator.py b/legacy/message_orchestrator.py deleted file mode 100644 index d9b5b54..0000000 --- a/legacy/message_orchestrator.py +++ /dev/null @@ -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, - ) diff --git a/legacy/order_flow.py b/legacy/order_flow.py deleted file mode 100644 index d3e323e..0000000 --- a/legacy/order_flow.py +++ /dev/null @@ -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 diff --git a/legacy/post_ops.py b/legacy/post_ops.py deleted file mode 100644 index 98f4b06..0000000 --- a/legacy/post_ops.py +++ /dev/null @@ -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 diff --git a/legacy/prompt_builder.py b/legacy/prompt_builder.py deleted file mode 100644 index 34b75b7..0000000 --- a/legacy/prompt_builder.py +++ /dev/null @@ -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 diff --git a/legacy/prompt_flow.py b/legacy/prompt_flow.py deleted file mode 100644 index 32318df..0000000 --- a/legacy/prompt_flow.py +++ /dev/null @@ -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) diff --git a/legacy/pydantic_ai_agent.py b/legacy/pydantic_ai_agent.py deleted file mode 100644 index 8074b22..0000000 --- a/legacy/pydantic_ai_agent.py +++ /dev/null @@ -1,1066 +0,0 @@ -"""PydanticAI Agent 模块 - -架构:单 Agent + 多 Tool 模式 -- Agent 负责对话逻辑和决策 -- Tool 负责具体能力:看图/查客户/转接 -- AI 自主决定何时调用哪个工具,时序自然,不需要外部协调 -""" -import os -import glob -import asyncio -import random -import hashlib -import re -import json -import logging -from pathlib import Path -from typing import Optional, Dict, List, Any, Tuple -from datetime import datetime -from pydantic import BaseModel, Field, model_validator -from pydantic_ai import Agent, RunContext -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openai import OpenAIProvider -from dotenv import load_dotenv -from utils.metrics_tracker import emit as metrics_emit -from utils.observability import emit_activity -from core.quote_state_machine import QuoteStateMachine -from services.risk_service import RiskService -from core.agent_pre_rules import AgentPreRuleService -from core.order_helpers import parse_order_info, order_instruction as build_order_instruction -from core.collection_intent_helpers import ( - append_requirement, - build_collect_ack, - build_collect_progress_reply, - build_collect_remind, - build_find_image_clarify_reply, - build_not_understood_reply, - classify_short_customer_text, - is_batch_finish_intent, - is_batch_finish_signal, - is_cross_image_composite_intent, - is_find_image_not_edit_conflict, - is_related_image_followup_intent, - is_result_followup_query, - needs_clarification_in_collecting, -) -from core.agent_prompts import ( - build_after_sale_prompt, - build_natural_reply_prompt, - build_order_prompt, - build_pricing_prompt, - build_processing_prompt, - build_risk_prompt, - build_similar_prompt, - build_system_prompt, -) -from core.risk_text_helpers import is_map_inquiry, is_political_inquiry -from core.context_helpers import ( - calc_avg_complexity, - get_conversation_context, - get_customer_profile_context, - get_intent_emotion_hint, - get_refusal_context_hint, -) -from core.batch_quote_helpers import ( - assess_batch_risk, - build_batch_pricing_plan, - build_batch_quote_reply, - calc_requirement_surcharge, - prepare_batch_intake, -) -from core.prompt_builder import build_prompt as build_agent_prompt, split_customer_text -from core.image_workflow_router import handle_image_workflow as route_image_workflow -from core.message_orchestrator import process_incoming_message -from core.conversation_state_store import ( - get_conversation_state as load_conversation_state, - mark_quote_ready as state_mark_quote_ready, - refresh_quote_phase as state_refresh_quote_phase, - should_defer_batch_quote as state_should_defer_batch_quote, - sync_pending_quote_state as state_sync_pending_quote_state, - restore_pending_quote_state as state_restore_pending_quote_state, - cleanup_inactive as state_cleanup_inactive, -) - -load_dotenv() - -from services.service_tuhui_upload import upload_to_tuhui -from core.workflow_router import get_workflow_router - -# ========== 企业微信通知 ========== -_WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "") -logger = logging.getLogger("cs_agent") - - -async def _notify_wechat(content: str, tag: str = "通知"): - """发送企业微信 markdown 通知,任何异常都发""" - if not _WECHAT_WEBHOOK: - logger.info("[%s] 未配置 WECHAT_WEBHOOK,跳过推送", tag) - 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("[%s] 企业微信推送成功", tag) - else: - logger.warning("[%s] 企业微信推送失败: %s", tag, data) - except Exception as e: - logger.exception("[%s] 企业微信发送异常: %s", tag, e) - - -async def _notify_wechat_overdue(): - """API 欠费时发企业微信通知""" - await _notify_wechat( - "⚠️ **火山引擎 API 欠费**,客服AI已停止响应,请立即充值!\n" - "地址:https://console.volcengine.com/ark" - ) - - -# ========== 转接常量 ========== -TRANSFER_MESSAGE = "话术|[转移会话],分组20252916034,无原因" -CASE_LIBRARY_LINK = "https://www.yuque.com/zuowei-dfvpq/kge0in/mynala0g35b8cec5" -TAOBAO_REPLY_TAILS = ("嗯", "哦", "好的", "嗯咯", "嗯啦") - - -def _is_ack_like_customer_text(text: str) -> bool: - """客户是否为确认型短句(好的/嗯/收到/ok 等)。""" - s = (text or "").strip().lower() - if not s: - return False - s = s.rstrip("。.!!?~~") - ack_set = { - "好", "好的", "嗯", "嗯嗯", "收到", "知道了", "明白了", - "ok", "okay", "行", "可以", "好嘞", "好的呢", - } - return s in ack_set - - -def _is_meaningless_short_text(text: str) -> bool: - """识别无意义短句:仅需简短承接,不进入复杂流程。""" - s = (text or "").strip().lower().rstrip("。.!!?~~") - if not s: - return False - meaningless = { - "好", "好的", "嗯", "嗯嗯", "哦", "哦哦", "收到", "知道了", "明白了", - "ok", "okay", "行", "可以", "好嘞", "好的呢", "在吗", "有人吗", "在不在", - } - return s in meaningless - - -# ========== 数据模型 ========== - - -class CustomerMessage(BaseModel): - """客户消息模型""" - msg_id: str - acc_id: str - msg: str - from_id: str - from_name: str - cy_id: str - acc_type: str - msg_type: int - cy_name: str - goods_name: Optional[str] = None - goods_order: Optional[str] = None - - -class ConversationState(BaseModel): - """对话状态""" - customer_id: str - stage: str = "售前" # 售前/售后 - last_price: Optional[int] = None # 最后报价 - last_min_price: Optional[int] = None # 最近图片的最低价 - last_order_id: Optional[str] = None # 订单号 - order_status: Optional[str] = None # 订单状态 - discount_count: int = 0 # 让价次数 - image_count: int = 0 # 图片数量 - pending_image_urls: List[str] = Field(default_factory=list) # 待统一报价图片 - pending_requirements: List[str] = Field(default_factory=list) # 待统一报价需求 - quote_phase: str = "idle" # idle/collecting/ready_to_quote/waiting_result - quote_ready_turns: int = 0 # ready_to_quote 阶段还需等待的消息轮次 - last_update: str = "" - last_reply_at: Optional[datetime] = None # 最后一次回复客户的时间 - - -class AgentDeps(BaseModel): - """Agent 依赖项 - 用于传递上下文""" - msg_id: str - acc_id: str - from_id: str - platform: str - - -class AgentResponse(BaseModel): - """Agent 回复模型""" - reply: str - should_reply: bool = True - need_transfer: bool = False # 是否需要转人工 - transfer_msg: str = "" # 转接消息 - - @model_validator(mode="after") - def _ensure_reply_tail(self): - # 统一在 process_message 中按客户输入决定是否补口语尾词 - return self - - -def _get_shop_type(acc_id: str = "", goods_name: str = "") -> str: - """根据 acc_id 或 goods_name 判断店铺类型,返回 gemini_api / find_image / default""" - try: - from config.config import CONFIG_DIR - import json - cfg_path = CONFIG_DIR / "shop_prompts.json" - if not cfg_path.exists(): - return "find_image" - with open(cfg_path, "r", encoding="utf-8") as f: - cfg = json.load(f) - shops = cfg.get("shops", {}) - goods_kw = cfg.get("goods_keywords", {}) - type_hints = cfg.get("type_hints", {}) - # 优先按 acc_id - if acc_id and acc_id in shops: - return shops[acc_id].get("type", "find_image") - # 按商品名关键词 - goods_lower = (goods_name or "").lower() - for kw, stype in goods_kw.items(): - if kw in goods_lower: - return stype - except Exception: - pass - return "find_image" - - -def _get_shop_persona(acc_id: str = "", goods_name: str = "") -> str: - """按店铺返回人设描述,优先级:shops.persona > type_personas > default_persona。""" - default_persona = "淘宝老店主,说话自然,像真人微信聊天,不官腔、不背模板。" - try: - from config.config import CONFIG_DIR - import json - - cfg_path = CONFIG_DIR / "shop_prompts.json" - if not cfg_path.exists(): - return default_persona - with open(cfg_path, "r", encoding="utf-8") as f: - cfg = json.load(f) - - shops = cfg.get("shops", {}) - if acc_id and acc_id in shops: - persona = str(shops[acc_id].get("persona", "")).strip() - if persona: - return persona - - shop_type = _get_shop_type(acc_id, goods_name) - type_personas = cfg.get("type_personas", {}) - persona = str(type_personas.get(shop_type, "")).strip() - if persona: - return persona - - cfg_default = str(cfg.get("default_persona", "")).strip() - return cfg_default or default_persona - except Exception: - return default_persona - - -def load_skill_map(skills_dir: str = "skills") -> Dict[str, str]: - """按技能目录名加载 SKILL.md,返回 {skill_name: content}。""" - skill_map: Dict[str, str] = {} - skill_files = glob.glob(os.path.join(skills_dir, "**/SKILL.md"), recursive=True) - for skill_file in skill_files: - try: - content = Path(skill_file).read_text(encoding="utf-8") - skill_name = Path(skill_file).parent.name.strip().lower() - if not skill_name: - continue - if skill_name in skill_map: - skill_map[skill_name] += "\n\n" + content - else: - skill_map[skill_name] = content - except Exception as e: - logger.warning("读取技能文件失败: %s | err=%s", skill_file, e) - return skill_map - - -class CustomerServiceAgent: - """客服 Agent - 支持 SKILL.md + 工作流""" - C_RESET = "\033[0m" - C_PROMPT = "\033[96m" # cyan - C_THINK = "\033[95m" # magenta - C_TOOL = "\033[93m" # yellow - C_REPLY = "\033[92m" # green - C_MUTED = "\033[90m" # gray - _DEFAULT_EVOLUTION_CANDIDATE = Path("config") / "evolution_candidate.json" - - @staticmethod - def _activity_log(event: str, **kwargs): - 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, - ) - - def __init__(self, skills_dir: str = "skills"): - self.api_key = os.getenv("OPENAI_API_KEY") - self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") - self.model_name = os.getenv("OPENAI_MODEL", "gpt-4o-mini") - self.reply_persona = os.getenv("AI_REPLY_PERSONA", "淘宝老店主,直爽利落,口语自然") - self.dynamic_collection_replies = os.getenv("AI_DYNAMIC_COLLECTION_REPLIES", "true").strip().lower() in {"1", "true", "yes", "on"} - self.rewrite_all_replies = os.getenv("AI_REWRITE_ALL_REPLIES", "true").strip().lower() in {"1", "true", "yes", "on"} - try: - self.batch_quote_delay_turns = max(0, int(os.getenv("BATCH_QUOTE_DELAY_TURNS", "1"))) - except Exception: - self.batch_quote_delay_turns = 1 - self.quote_state_machine = QuoteStateMachine(delay_turns=self.batch_quote_delay_turns) - self.risk_service = RiskService() - self.pre_rule_service = AgentPreRuleService(self, self.risk_service) - - if not self.api_key: - raise ValueError("请设置 OPENAI_API_KEY 环境变量") - - # 对话状态管理 - self.conversations: Dict[str, ConversationState] = {} - self.ConversationStateClass = ConversationState - # 多轮对话历史(PydanticAI ModelMessage 列表,按客户ID存储) - self.message_histories: Dict[str, list] = {} - self.evolution_candidate = self._load_evolution_candidate() - - # 加载技能并按角色拆分,避免所有 Agent 吃同一份大杂烩提示词 - self.skill_map = load_skill_map(skills_dir) - self.skill_style = self._compose_skill_content(["style-skill", "owner-style"]) - self.skill_pre_sales = self._compose_skill_content(["pre-sales-skill"]) - self.skill_pricing = self._compose_skill_content(["pricing-skill"]) - self.skill_after_sale = self._compose_skill_content(["after-sales-skill"]) - self.skill_risk = self._compose_skill_content(["risk-skill"]) - - # 创建 OpenAI 模型 - model = OpenAIChatModel( - model_name=self.model_name, - provider=OpenAIProvider( - api_key=self.api_key, - base_url=self.base_url - ) - ) - - try: - from config.config import MIN_PRICE_FLOOR - - min_price_floor = MIN_PRICE_FLOOR - except Exception: - min_price_floor = 15 - - self.agent = Agent( - model=model, - deps_type=AgentDeps, - system_prompt=build_system_prompt(self.reply_persona, self.skill_pre_sales, self.skill_style), - ) - self.agent_after_sale = Agent( - model=model, - deps_type=AgentDeps, - system_prompt=build_after_sale_prompt(self.skill_after_sale, self.skill_style), - ) - self.agent_pricing = Agent( - model=model, - deps_type=AgentDeps, - system_prompt=build_pricing_prompt( - min_price_floor=min_price_floor, - case_library_link=CASE_LIBRARY_LINK, - skill_pricing=self.skill_pricing, - skill_style=self.skill_style, - ), - ) - self.agent_processing = Agent( - model=model, - deps_type=AgentDeps, - system_prompt=build_processing_prompt(self.skill_after_sale, self.skill_style), - ) - self.agent_similar = Agent( - model=model, - deps_type=AgentDeps, - system_prompt=build_similar_prompt(self.skill_pre_sales, self.skill_style), - ) - self.agent_natural_reply = Agent( - model=model, - deps_type=AgentDeps, - system_prompt=build_natural_reply_prompt(self.reply_persona, self.skill_style), - ) - # 工作流程路由器 - self.workflow_router = get_workflow_router() - - self.agent_order = Agent( - model=model, - deps_type=AgentDeps, - system_prompt=build_order_prompt(self.skill_after_sale, self.skill_style), - ) - self.agent_risk = Agent( - model=model, - deps_type=AgentDeps, - system_prompt=build_risk_prompt(self.skill_risk, self.skill_style), - ) - - # 注册工具 - self._register_tools() - - def _compose_skill_content(self, names: List[str]) -> str: - """按技能名拼接技能文本,找不到则跳过。""" - parts: List[str] = [] - for name in names: - key = (name or "").strip().lower() - if key and key in self.skill_map: - parts.append(self.skill_map[key]) - return "\n\n".join(parts) - - def _load_evolution_candidate(self) -> Dict[str, Any]: - """读取自我进化候选配置(灰度策略),读取失败时返回空。""" - try: - path = Path(os.getenv("EVOLUTION_CANDIDATE_PATH", str(self._DEFAULT_EVOLUTION_CANDIDATE))) - if not path.exists(): - return {} - data = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(data, dict): - return {} - return data - except Exception: - return {} - - def _evolution_gray_percent(self) -> int: - """灰度比例,默认 5%。""" - try: - env_pct = os.getenv("EVOLUTION_GRAY_PERCENT", "").strip() - if env_pct: - pct = int(float(env_pct)) - else: - pct = int(((self.evolution_candidate or {}).get("gray_percent", 5))) - return max(0, min(100, pct)) - except Exception: - return 5 - - def _evolution_enabled_for_customer(self, customer_id: str) -> bool: - """按客户哈希稳定灰度命中,命中后启用候选策略。""" - cand = self.evolution_candidate or {} - if str(cand.get("status", "")).strip() != "ready_for_gray_5_percent": - return False - if not customer_id: - return False - pct = self._evolution_gray_percent() - if pct <= 0: - return False - digest = hashlib.md5(customer_id.encode("utf-8")).hexdigest() - bucket = int(digest[:8], 16) % 100 - hit = bucket < pct - if hit: - metrics_emit("evolution_gray_hit", customer_id=customer_id, percent=pct, version=str(cand.get("version", ""))) - return hit - - def _evolution_has_proposal(self, proposal_id: str) -> bool: - cand = self.evolution_candidate or {} - for p in cand.get("proposals", []) or []: - if str((p or {}).get("id", "")).strip() == proposal_id: - return True - return False - - @staticmethod - def _is_service_risk_inquiry(text: str) -> bool: - """识别退款/投诉等服务风险场景。""" - s = (text or "").strip().lower() - if not s: - return False - kw = ("退款", "退货", "投诉", "差评", "举报", "欺骗", "骗人", "起诉", "法院", "生气", "不满意") - return any(k in s for k in kw) - - @staticmethod - def _log_block(title: str, content: str): - """统一的控制台分层日志输出。""" - logger.info("[%s]\n%s\n--------------------", title, content) - - @staticmethod - def _normalize_reply_text(text: Optional[str]) -> str: - """清洗模型输出,避免把占位词直接发给客户。""" - if text is None: - return "" - cleaned = str(text).strip() - if cleaned.lower() in {"无", "none", "null", "n/a"}: - return "" - return cleaned - - @staticmethod - def _colloquialize_reply(text: str) -> str: - """把常见机械表达柔化为更口语的客服话术。""" - t = (text or "").strip() - if not t: - return t - repl = { - "确认我就安排": "你点头我就开做", - "可以的话我马上安排": "可以我就马上给你做", - "我这边马上安排": "我马上安排", - "立刻统一报价": "马上给你报价", - "统一报价": "一起给你报价", - "您": "你", - "请您": "你", - "可选:A": "可选:", - "流程完成": "已经安排好了", - } - for k, v in repl.items(): - t = t.replace(k, v) - return t - - async def _render_collection_reply_with_ai( - self, - *, - message: CustomerMessage, - state: ConversationState, - scene: str, - intent_hint: str, - fallback: str, - ) -> str: - """ - 收图阶段回复默认走 AI 改写,失败时回退到固定模板。 - """ - first_image_ack = "收到,我先看一下哈,稍等哈。" - if scene == "collect_ack" and len(state.pending_image_urls) == 1: - fallback = first_image_ack - if not self.dynamic_collection_replies: - return fallback - try: - deps = AgentDeps( - msg_id=message.msg_id, - acc_id=message.acc_id, - from_id=message.from_id, - platform=message.acc_type, - ) - history = self.message_histories.get(message.from_id, []) - pending_req = ";".join((state.pending_requirements or [])[-4:]) or "无" - persona = _get_shop_persona(message.acc_id or "", message.goods_name or "") - user_prompt = ( - "请按下面意图生成给客户的自然回复。\n" - f"场景: {scene}\n" - f"店铺人设: {persona}\n" - f"回复意图: {intent_hint}\n" - f"客户原话: {message.msg}\n" - f"当前已收图片数: {len(state.pending_image_urls)}\n" - f"当前需求摘要: {pending_req}\n" - "输出要求: 不超过2句话,像真人店主聊天;避免复用固定模板句。" - ) - result = await self.agent_natural_reply.run(user_prompt, deps=deps, message_history=history) - self.message_histories[message.from_id] = result.all_messages()[-30:] - text = self._colloquialize_reply(self._normalize_reply_text(result.output)) - if not text: - return fallback - transfer_keywords = ("TRANSFER_REQUESTED", "[转移会话]", "转移会话") - if any(k in text for k in transfer_keywords): - return fallback - return text - except Exception: - return fallback - - async def _rewrite_reply_with_ai( - self, - *, - message: CustomerMessage, - state: ConversationState, - reply: str, - scene: str = "final_reply", - ) -> str: - """ - 对最终回复做 AI 润色,统一口吻。失败时返回原文。 - """ - text = (reply or "").strip() - if not text or not self.rewrite_all_replies: - return text - transfer_keywords = ("TRANSFER_REQUESTED", "[转移会话]", "转移会话") - if any(k in text for k in transfer_keywords): - return text - try: - deps = AgentDeps( - msg_id=message.msg_id, - acc_id=message.acc_id, - from_id=message.from_id, - platform=message.acc_type, - ) - history = self.message_histories.get(message.from_id, []) - pending_req = ";".join((state.pending_requirements or [])[-4:]) or "无" - persona = _get_shop_persona(message.acc_id or "", message.goods_name or "") - prompt = ( - "请把下面这句客服回复润色成更自然的微信聊天口吻,语义必须保持一致。\n" - f"场景: {scene}\n" - f"店铺人设: {persona}\n" - f"客户原话: {message.msg}\n" - f"当前已收图: {len(state.pending_image_urls)}张\n" - f"当前需求摘要: {pending_req}\n" - f"原回复: {text}\n" - "要求: 不要新增承诺/价格/流程;不超过2句话;只输出润色后的最终回复。" - ) - result = await self.agent_natural_reply.run(prompt, deps=deps, message_history=history) - self.message_histories[message.from_id] = result.all_messages()[-30:] - polished = self._colloquialize_reply(self._normalize_reply_text(result.output)) - if not polished: - return text - if any(k in polished for k in transfer_keywords): - return text - return polished - except Exception: - return text - - def _register_tools(self): - """注册所有 Tool,让 Agent 可以主动调用""" - from core.agent_tools import register_tools - register_tools(self) - - # 对话状态超过多少小时后重置(避免昨天的售后状态影响今天) - CONVERSATION_TIMEOUT_HOURS = 12 - - def _get_conversation_state(self, customer_id: str) -> ConversationState: - return load_conversation_state(self, customer_id) - - def _cleanup_inactive(self, now: datetime): - state_cleanup_inactive(self.conversations, self.message_histories, now) - - def _sync_pending_quote_state(self, customer_id: str, state: ConversationState): - state_sync_pending_quote_state(self, customer_id, state) - - def _restore_pending_quote_state(self, customer_id: str, state: ConversationState): - state_restore_pending_quote_state(customer_id, state) - - @staticmethod - def _refresh_quote_phase(state: ConversationState, phase_hint: str = ""): - state_refresh_quote_phase(state, phase_hint=phase_hint) - - def _should_defer_batch_quote(self, state: ConversationState, mark_ready: bool = False) -> bool: - return state_should_defer_batch_quote(self, state, mark_ready=mark_ready) - - def _mark_quote_ready(self, state: ConversationState): - state_mark_quote_ready(self, state) - - def _build_reject_message(self, reason: str = "") -> str: - templates = [ - "这类图文字内容太密了,我们这边不接这单哈,建议精简后再发我看看。", - "这种密集文字/宣传栏类图片暂时做不了,抱歉啦,换一版简化内容我可以继续帮你看。", - "这张文字信息太多,处理风险高,我们先不接,您可以先筛重点文字再发我。", - ] - msg = random.choice(templates) - if reason: - msg += f"({reason})" - return msg - - def _is_batch_quote_enabled(self, customer_id: str, acc_id: str) -> bool: - """灰度开关:按店铺白名单 + 客户哈希百分比控制新策略是否生效。""" - try: - from config.config import ( - FEATURE_BATCH_QUOTE_ENABLED, - FEATURE_BATCH_QUOTE_PERCENT, - FEATURE_BATCH_QUOTE_SHOPS, - ) - if not FEATURE_BATCH_QUOTE_ENABLED: - return False - pct = max(0, min(100, int(FEATURE_BATCH_QUOTE_PERCENT))) - if pct == 0: - return False - shops = [s.strip() for s in (FEATURE_BATCH_QUOTE_SHOPS or "").split(",") if s.strip()] - if shops and (acc_id or "") not in shops: - return False - if pct >= 100: - return True - h = int(hashlib.md5((customer_id or "").encode("utf-8")).hexdigest()[:8], 16) % 100 - return h < pct - except Exception: - return True - - def _detect_stage(self, message: str) -> str: - """检测售前/售后""" - # 系统订单通知不属于售后,单独处理 - if "系统订单信息" in message: - return "订单通知" - - after_sale_keywords = ["已下单", "已付款", "催一下", "发文件", "要修改", "不满意", "退款", "退货"] - for keyword in after_sale_keywords: - if keyword in message: - return "售后" - return "售前" - - _is_political_inquiry = staticmethod(is_political_inquiry) - _is_map_inquiry = staticmethod(is_map_inquiry) - _get_shop_type = staticmethod(_get_shop_type) - _get_shop_persona = staticmethod(_get_shop_persona) - _notify_wechat = staticmethod(_notify_wechat) - _notify_wechat_overdue = staticmethod(_notify_wechat_overdue) - - _calc_avg_complexity = staticmethod(calc_avg_complexity) - _get_conversation_context = staticmethod(get_conversation_context) - _get_intent_emotion_hint = staticmethod(get_intent_emotion_hint) - - def _get_customer_profile_context(self, customer_id: str) -> str: - return get_customer_profile_context(self, customer_id) - - def _get_refusal_context_hint(self, customer_id: str, current_msg: str, profile_context: str) -> str: - return get_refusal_context_hint(self, customer_id, current_msg, profile_context) - - # 简单打招呼类消息(在近期已回复后无需再回) - _COOLDOWN_PATTERNS = [ - "你好", "您好", "在吗", "在么", "在不在", "有人吗", - "嗯", "嗯嗯", "好", "好的", "好哒", "ok", "OK", "okay", - "谢谢", "谢谢你", "感谢", "收到", "知道了", "明白了", - ] - _COOLDOWN_SECONDS = 5 * 60 # 5 分钟内不重复回复纯打招呼 - - def _in_cooldown(self, state: ConversationState, msg: str) -> bool: - """最近刚回复过 + 消息是纯打招呼 → True 静默""" - if not state.last_reply_at: - return False - elapsed = (datetime.now() - state.last_reply_at).total_seconds() - if elapsed > self._COOLDOWN_SECONDS: - return False - clean = msg.strip().rstrip("!!??。.~~") - return clean in self._COOLDOWN_PATTERNS - - def _should_handle_as_meaningless_short_text(self, state: ConversationState, msg: str) -> bool: - """ - 无意义短句仅在“非业务处理中”生效,避免误拦截真实推进消息。 - 例如:已在收图/待报价阶段时,客户发“好的/在吗”不应直接 ping。 - """ - customer_text, _ = self._split_customer_text(msg or "") - text = (customer_text or "").strip() - if not _is_meaningless_short_text(text): - return False - if self._extract_image_urls(text): - return False - if (getattr(state, "pending_image_urls", None) or []): - return False - if getattr(state, "quote_phase", "idle") in {"collecting", "ready_to_quote", "waiting_result"}: - return False - return True - - async def build_auto_quote_reply(self, state: ConversationState, message: CustomerMessage) -> AgentResponse: - """ - 自动报价内部入口:不走 process_message,避免伪造客户语句污染上下文。 - """ - quote_res = await self._quote_pending_images(state, message) - reply_text = self._colloquialize_reply(quote_res.get("reply", "")) - reply_text = await self._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() - return AgentResponse( - reply=reply_text, - should_reply=not need_transfer, - need_transfer=need_transfer, - transfer_msg=TRANSFER_MESSAGE if need_transfer else "", - ) - - async def process_message(self, message: CustomerMessage) -> AgentResponse: - """处理客户消息并生成回复。""" - return await process_incoming_message(self, message) - - async def _check_order_amount(self, customer_id: str, order: dict, acc_id: str): - """核查订单实付金额是否与报价一致,异常时企业微信预警""" - try: - import re - from db.customer_db import db - profile = db.get_customer(customer_id) - quoted = profile.last_price # 上次报价(元) - if not quoted: - return - - # 从订单解析实付金额 - raw_amount = order.get("amount", "") - m = re.search(r'[\d.]+', str(raw_amount)) - if not m: - return - paid = float(m.group()) - - logger.info("[Agent] 订单金额核查:报价 %s元 vs 实付 %s元(客户 %s)", quoted, paid, customer_id) - - # 实付金额明显低于报价(低于报价的 60%)才预警 - if paid < quoted * 0.6: - msg = ( - f"⚠️ **订单金额异常**\n" - f"店铺:{acc_id}\n" - f"客户:{customer_id}({profile.name or ''})\n" - f"报价:{quoted}元\n" - f"实付:{paid}元\n" - f"差额:{quoted - paid:.1f}元 — 请人工核查" - ) - logger.warning("[Agent] %s", msg) - await self._notify_wechat(msg) - except Exception as e: - logger.exception("[Agent] 订单金额核查失败: %s", e) - - def _extract_image_url(self, msg: str) -> str: - """从消息中提取图片URL,兼容纯URL和 text#*#url 两种格式""" - urls = self._extract_image_urls(msg) - return urls[0] if urls else "" - - def _extract_image_urls(self, msg: str) -> List[str]: - """提取消息中的所有图片URL(去重保序)。""" - import re - if not msg: - return [] - image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") - image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com", "suning.com") - candidates = re.findall(r'https?://[^\s#]+', msg) - urls: List[str] = [] - for u in candidates: - low = u.lower() - if any(ext in low for ext in image_exts) or any(h in low for h in image_hosts): - if u not in urls: - urls.append(u) - return urls - - def _strip_urls_from_text(self, msg: str) -> str: - """去掉 URL 后的纯文本,用于提取额外需求。""" - import re - if not msg: - return "" - text = re.sub(r'https?://\S+', ' ', msg) - text = text.replace("#*#", " ").strip() - text = re.sub(r'\s+', ' ', text) - return text.strip(",,。.!!??;;:: ") - - _is_batch_finish_signal = staticmethod(is_batch_finish_signal) - _is_batch_finish_intent = staticmethod(is_batch_finish_intent) - _is_cross_image_composite_intent = staticmethod(is_cross_image_composite_intent) - _is_related_image_followup_intent = staticmethod(is_related_image_followup_intent) - _is_result_followup_query = staticmethod(is_result_followup_query) - _classify_short_customer_text = staticmethod(classify_short_customer_text) - _build_collect_ack = staticmethod(build_collect_ack) - _build_collect_progress_reply = staticmethod(build_collect_progress_reply) - _build_collect_remind = staticmethod(build_collect_remind) - _is_find_image_not_edit_conflict = staticmethod(is_find_image_not_edit_conflict) - _needs_clarification_in_collecting = staticmethod(needs_clarification_in_collecting) - _build_find_image_clarify_reply = staticmethod(build_find_image_clarify_reply) - _build_not_understood_reply = staticmethod(build_not_understood_reply) - _append_requirement = staticmethod(append_requirement) - - async def _run_batch_feasibility(self, urls: List[str], concurrency: int) -> List[Tuple[str, Dict[str, Any]]]: - """Stage 2: 可做性分析(逐图)。""" - from image.image_analyzer import image_analyzer - - sem = asyncio.Semaphore(max(1, concurrency)) - - async def _analyze_one(url: str): - async with sem: - try: - r = await image_analyzer.analyze(url) - except Exception: - r = { - "complexity": "normal", - "reason": "识别异常,按常规估价", - "price_min": 15, - "price_max": 25, - "price_suggest": 20, - "success": False, - } - return url, r - - return list(await asyncio.gather(*[_analyze_one(u) for u in urls])) - - async def _sync_batch_analysis_to_workflow(self, results: List[Tuple[str, Dict[str, Any]]], message: CustomerMessage) -> None: - for url, r in results: - try: - from core.workflow import workflow - await workflow.image_analysis_result( - customer_id=message.from_id, - image_url=url, - complexity=r.get("complexity", "normal"), - acc_id=message.acc_id, - acc_type=message.acc_type, - gemini_prompt=r.get("gemini_prompt", ""), - aspect_ratio=r.get("aspect_ratio", "1:1"), - perspective=r.get("perspective", "no"), - proc_type=r.get("proc_type", ""), - subject=r.get("subject", ""), - quality=r.get("quality", ""), - ) - except Exception as e: - logger.exception("[Agent] Workflow 批量任务创建失败: %s", e) - - _calc_requirement_surcharge = staticmethod(calc_requirement_surcharge) - _build_batch_quote_reply = staticmethod(build_batch_quote_reply) - _prepare_batch_intake = staticmethod(prepare_batch_intake) - _assess_batch_risk = staticmethod(assess_batch_risk) - _build_batch_pricing_plan = staticmethod(build_batch_pricing_plan) - - async def _try_batch_auto_process( - self, - results: List[Tuple[str, Dict[str, Any]]], - message: CustomerMessage, - req_fee: Dict[str, Any], - ) -> Dict[str, Any]: - """Stage 4-A: 自动处理+图绘链接。失败时回退到需求澄清。""" - links = [] - try: - from image.image_processor import image_processor - from utils.image_queue import run_with_queue - for idx, (url, r) in enumerate(results, 1): - req_parts = [f"complexity:{r.get('complexity', 'normal')}"] - if r.get("gemini_prompt"): - req_parts.append(f"prompt:{r.get('gemini_prompt')}") - if r.get("aspect_ratio"): - req_parts.append(f"ratio:{r.get('aspect_ratio')}") - if r.get("perspective") and r.get("perspective") != "no": - req_parts.append(f"perspective:{r.get('perspective')}") - if r.get("proc_type"): - req_parts.append(f"proc_type:{r.get('proc_type')}") - if r.get("subject"): - req_parts.append(f"subject:{r.get('subject')}") - if r.get("quality"): - req_parts.append(f"quality:{r.get('quality')}") - - process_res = await run_with_queue(image_processor.process_image( - url, - "enhance", - requirements="|".join(req_parts), - gemini_prompt=r.get("gemini_prompt", ""), - aspect_ratio=r.get("aspect_ratio", "1:1"), - perspective=r.get("perspective", "no"), - proc_type=r.get("proc_type", ""), - subject=r.get("subject", ""), - quality=r.get("quality", ""), - )) - if not process_res.get("success"): - raise RuntimeError(process_res.get("message", "图片处理失败")) - - ok, link, _ = await upload_to_tuhui( - process_res["result_path"], - title=f"客户{message.from_id[-4:]}-图片{idx}", - description="AI自动处理结果", - price=max(10, int(r.get("price_suggest", 20) or 20) + int(req_fee.get("extra", 0) or 0) // max(1, len(results))), - ) - if not ok: - raise RuntimeError(str(link)) - links.append(link) - except Exception as e: - logger.exception("[Agent] 找图自动处理失败,回退需求澄清: %s", e) - return { - "reply": "这种可以做类似款。你先说下具体需求:要几张、是否改字、尺寸比例、交付格式(单图/打包链接),我按需求给你直接做。", - "need_transfer": False, - } - lines = ["找到了,链接如下:"] - for i, link in enumerate(links, 1): - lines.append(f"链接{i}:{link}") - return {"reply": "\n".join(lines), "need_transfer": False} - - def _finalize_batch_state(self, state: ConversationState, customer_id: str, final_price: int = 0): - if final_price > 0: - state.last_price = final_price - try: - from db.customer_db import db - db.update_last_price(customer_id, final_price) - except Exception: - pass - state.pending_image_urls.clear() - state.pending_requirements.clear() - self._refresh_quote_phase(state, "idle") - self._sync_pending_quote_state(customer_id, state) - - async def _quote_pending_images(self, state: ConversationState, message: CustomerMessage) -> Dict[str, Any]: - """ - 统一报价主流程(分层): - 1) Intake 收集 - 2) Feasibility 可做性 - 3) Pricing 报价 - 4) Router 自动处理/报价/转人工 - """ - intake = self._prepare_batch_intake(state) - if not intake.get("ok", False): - return {"reply": intake.get("reply", ""), "need_transfer": bool(intake.get("need_transfer", False))} - - urls = intake["urls"] - requirements = intake["requirements"] - analyze_concurrency = int(intake["analyze_concurrency"]) - - results = await self._run_batch_feasibility(urls=urls, concurrency=analyze_concurrency) - await self._sync_batch_analysis_to_workflow(results=results, message=message) - - risk = self._assess_batch_risk(results) - unsafe = risk["unsafe"] - dense_text_reject = risk["dense_text_reject"] - if unsafe: - self._finalize_batch_state(state, message.from_id, final_price=0) - if dense_text_reject and len(dense_text_reject) == len(unsafe): - return {"reply": self._build_reject_message("文字密集类图片暂不接单"), "need_transfer": False} - return { - "reply": f"这批里{'、'.join(unsafe)}处理风险较高,我先帮你转人工设计师跟进会更稳妥。", - "need_transfer": True, - } - - pricing = self._build_batch_pricing_plan(results=results, requirements=requirements) - total_suggest = int(pricing["total_suggest"]) - bundle_price = int(pricing["bundle_price"]) - req_fee = pricing["req_fee"] - - intent_text = (message.msg or "") + " " + " ".join(requirements[-5:]) - workflow_type, _ = self.workflow_router.detect_workflow(intent_text) - if workflow_type == "find_image": - route_res = await self._try_batch_auto_process( - results=results, - message=message, - req_fee=req_fee, - ) - self._finalize_batch_state(state, message.from_id, final_price=bundle_price) - return route_res - - reply_text = self._build_batch_quote_reply( - results=results, - total_suggest=total_suggest, - bundle_price=bundle_price, - req_fee=req_fee, - ) - self._finalize_batch_state(state, message.from_id, final_price=bundle_price) - return {"reply": reply_text, "need_transfer": False} - - _split_customer_text = staticmethod(split_customer_text) - - def _build_prompt(self, message: CustomerMessage, state: ConversationState) -> str: - return build_agent_prompt( - message=message, - state=state, - extract_image_url=self._extract_image_url, - shop_type_resolver=_get_shop_type, - shop_persona_resolver=_get_shop_persona, - parse_order_info=parse_order_info, - build_order_instruction=build_order_instruction, - ) - - async def _handle_image_workflow(self, message: str, data: dict, image_urls: list) -> bool: - return await route_image_workflow( - workflow_router=self.workflow_router, - message=message, - data=data, - image_urls=image_urls, - ) - - -async def test_agent(): - """测试 Agent""" - agent = CustomerServiceAgent(skills_dir="skills") - - test_msg = CustomerMessage( - msg_id="123", - acc_id="test_account", - msg="这张图可以做吗?", - from_id="customer001", - from_name="张三", - cy_id="customer001", - acc_type="AliWorkbench", - msg_type=0, - cy_name="张三", - goods_name="专业找图代找高清图片", - goods_order="" - ) - - response = await agent.process_message(test_msg) - logger.info("回复内容: %s", response.reply) - - -if __name__ == "__main__": - import asyncio - asyncio.run(test_agent()) diff --git a/legacy/reply_finalize_flow.py b/legacy/reply_finalize_flow.py deleted file mode 100644 index 0d2fc20..0000000 --- a/legacy/reply_finalize_flow.py +++ /dev/null @@ -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, - ) diff --git a/legacy/results/20260225211854.jpg b/legacy/results/20260225211854.jpg deleted file mode 100644 index 2751a2e..0000000 Binary files a/legacy/results/20260225211854.jpg and /dev/null differ diff --git a/legacy/results/debug_7debc0124b0441da9945feaeceef93b1.jpg b/legacy/results/debug_7debc0124b0441da9945feaeceef93b1.jpg deleted file mode 100644 index c2ba038..0000000 Binary files a/legacy/results/debug_7debc0124b0441da9945feaeceef93b1.jpg and /dev/null differ diff --git a/legacy/results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg b/legacy/results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg deleted file mode 100644 index 82e7d6e..0000000 Binary files a/legacy/results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg and /dev/null differ diff --git a/legacy/results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg b/legacy/results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg deleted file mode 100644 index 82fea33..0000000 Binary files a/legacy/results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg and /dev/null differ diff --git a/legacy/results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg b/legacy/results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg deleted file mode 100644 index 9e9feff..0000000 Binary files a/legacy/results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg and /dev/null differ diff --git a/legacy/results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg b/legacy/results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg deleted file mode 100644 index b8cf8a1..0000000 Binary files a/legacy/results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg and /dev/null differ diff --git a/legacy/results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg b/legacy/results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg deleted file mode 100644 index 0fed44d..0000000 Binary files a/legacy/results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg and /dev/null differ diff --git a/legacy/results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg b/legacy/results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg deleted file mode 100644 index 569e90e..0000000 Binary files a/legacy/results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg and /dev/null differ diff --git a/legacy/results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg b/legacy/results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg deleted file mode 100644 index da4a251..0000000 Binary files a/legacy/results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg and /dev/null differ diff --git a/legacy/results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg b/legacy/results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg deleted file mode 100644 index 058c84d..0000000 Binary files a/legacy/results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg and /dev/null differ diff --git a/legacy/results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg b/legacy/results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg deleted file mode 100644 index 701e07b..0000000 Binary files a/legacy/results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg and /dev/null differ diff --git a/legacy/results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg b/legacy/results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg deleted file mode 100644 index e1be6b8..0000000 Binary files a/legacy/results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg and /dev/null differ diff --git a/legacy/results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg b/legacy/results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg deleted file mode 100644 index d84338a..0000000 Binary files a/legacy/results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg and /dev/null differ diff --git a/legacy/results/result_90eaf777934445af81abbd60fe4778c5.jpg b/legacy/results/result_90eaf777934445af81abbd60fe4778c5.jpg deleted file mode 100644 index 745f3a3..0000000 Binary files a/legacy/results/result_90eaf777934445af81abbd60fe4778c5.jpg and /dev/null differ diff --git a/legacy/risk_text_helpers.py b/legacy/risk_text_helpers.py deleted file mode 100644 index 47d1746..0000000 --- a/legacy/risk_text_helpers.py +++ /dev/null @@ -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) diff --git a/legacy/rules/__init__.py b/legacy/rules/__init__.py deleted file mode 100644 index 6fcad2a..0000000 --- a/legacy/rules/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .engine import Rule, RuleContext, RuleEngine, RuleResult - -__all__ = ["Rule", "RuleContext", "RuleEngine", "RuleResult"] diff --git a/legacy/rules/engine.py b/legacy/rules/engine.py deleted file mode 100644 index 890328d..0000000 --- a/legacy/rules/engine.py +++ /dev/null @@ -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") diff --git a/legacy/scripts/chat_log_viewer.py b/legacy/scripts/chat_log_viewer.py deleted file mode 100644 index d626c41..0000000 --- a/legacy/scripts/chat_log_viewer.py +++ /dev/null @@ -1,362 +0,0 @@ -""" -聊天记录查看器 -用法: - python scripts/chat_log_viewer.py # 列出所有客户 - python scripts/chat_log_viewer.py <客户ID> # 查看某客户全部对话 - python scripts/chat_log_viewer.py -s <关键词> # 全局搜索 - python scripts/chat_log_viewer.py -t <客户ID> # 只看今天 - python scripts/chat_log_viewer.py -l # 实时监听最新消息(10条/刷新) -""" -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -import time -import os -from datetime import datetime - -# 强制 UTF-8 输出(Windows 终端需要) -if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": - import io - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") - -from db import chat_log_db as db - -# ========== ANSI 颜色 ========== -try: - import ctypes - ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7) -except Exception: - pass - -RESET = "\033[0m" -BOLD = "\033[1m" -DIM = "\033[2m" -GREEN = "\033[32m" -CYAN = "\033[36m" -YELLOW = "\033[33m" -BLUE = "\033[34m" -MAGENTA= "\033[35m" -RED = "\033[31m" -WHITE = "\033[97m" -BG_DARK= "\033[48;5;236m" - - -def clear(): - os.system("cls" if os.name == "nt" else "clear") - - -def header(text: str): - width = 60 - print(f"\n{BOLD}{CYAN}{'─' * width}{RESET}") - print(f"{BOLD}{CYAN} {text}{RESET}") - print(f"{BOLD}{CYAN}{'─' * width}{RESET}\n") - - -def fmt_time(ts: str) -> str: - """缩短时间戳显示""" - today = datetime.now().strftime("%Y-%m-%d") - if ts.startswith(today): - return ts[11:16] # 只显示 HH:MM - return ts[:16] - - -def platform_badge(platform: str) -> str: - badges = { - "AliWorkbench": f"{YELLOW}[淘宝]{RESET}", - "taobao": f"{YELLOW}[淘宝]{RESET}", - "pinduoduo": f"{RED}[拼多多]{RESET}", - "jd": f"{RED}[京东]{RESET}", - "wechat": f"{GREEN}[微信]{RESET}", - "email": f"{BLUE}[邮件]{RESET}", - } - return badges.get(platform, f"{DIM}[{platform}]{RESET}" if platform else "") - - -def print_bubble(direction: str, message: str, ts: str): - """打印聊天气泡""" - time_str = fmt_time(ts) - lines = [] - if "#*#" in (message or ""): - parts = [p.strip() for p in message.split("#*#") if p.strip()] - if parts: - lines = parts - if not lines: - lines = (message or "").split("\n") - - if direction == "in": # 客户来消息 → 左对齐 - print(f" {DIM}{time_str}{RESET} {WHITE}买家{RESET}") - for line in lines: - print(f" {BG_DARK} {line} {RESET}") - else: # 客服回复 → 右对齐(缩进) - print(f" {DIM}{time_str}{RESET} {GREEN}客服{RESET}") - for line in lines: - print(f" {GREEN}> {line}{RESET}") - print() - - -def cmd_list_customers(): - """列出所有客户""" - customers = db.get_customers(limit=100) - if not customers: - print(f"{YELLOW}暂无聊天记录。{RESET}") - return - - header(f"客户列表 共 {len(customers)} 人") - print(f" {'#':<4} {'客户ID':<24} {'姓名':<12} {'平台':<10} {'消息数':>6} {'最后活跃'}") - print(f" {'─'*4} {'─'*24} {'─'*12} {'─'*10} {'─'*6} {'─'*16}") - for i, c in enumerate(customers, 1): - badge = platform_badge(c.get("platform", "")) - name = (c.get("customer_name") or "")[:10] - cid = c["customer_id"] - total = c["total_msgs"] - last = c.get("last_time", "")[:16] - print(f" {i:<4} {CYAN}{cid:<24}{RESET} {name:<12} {badge:<18} {total:>6}条 {DIM}{last}{RESET}") - - print(f"\n{DIM}用法:python chat_log_viewer.py <客户ID>{RESET}\n") - - -def cmd_show_conversation(customer_id: str, today_only: bool = False): - """显示某客户对话""" - if today_only: - messages = db.get_conversation_today(customer_id) - title = f"今日对话 {customer_id}" - else: - messages = db.get_conversation(customer_id, limit=300) - title = f"对话记录 {customer_id}" - - if not messages: - print(f"{YELLOW}该客户暂无记录:{customer_id}{RESET}") - return - - header(f"{title} ({len(messages)} 条)") - - last_date = "" - for m in messages: - ts = m.get("timestamp", "") - date = ts[:10] - if date != last_date: - print(f" {DIM}{'─'*20} {date} {'─'*20}{RESET}") - last_date = date - print_bubble(m["direction"], m["message"], ts) - - print(f"{DIM} ── 以上共 {len(messages)} 条 ──{RESET}\n") - - -def cmd_search(keyword: str, customer_id: str = None): - """搜索关键词""" - results = db.search_messages(keyword, customer_id=customer_id, limit=50) - title = f"搜索 [{keyword}]" - if customer_id: - title += f" 客户:{customer_id}" - header(f"{title} 共 {len(results)} 条") - - if not results: - print(f"{YELLOW}未找到包含 [{keyword}] 的消息。{RESET}") - return - - last_cid = "" - for r in results: - cid = r["customer_id"] - if cid != last_cid: - print(f" {CYAN}{cid}{RESET} {r.get('customer_name','')}") - last_cid = cid - direction = "买家" if r["direction"] == "in" else "客服" - color = WHITE if r["direction"] == "in" else GREEN - # 高亮关键词 - msg = r["message"].replace(keyword, f"{RED}{BOLD}{keyword}{RESET}{color}") - print(f" {DIM}{r['timestamp'][:16]}{RESET} {color}[{direction}] {msg}{RESET}") - print() - - -def cmd_live(refresh: int = 3): - """实时监听最新消息""" - header("实时消息监听 Ctrl+C 退出") - seen_ids = set() - - try: - while True: - rows = db.get_latest_messages(20) - new_rows = [r for r in rows if r["id"] not in seen_ids] - if new_rows: - new_rows.reverse() - for r in new_rows: - seen_ids.add(r["id"]) - cid = r["customer_id"] - name = r.get("customer_name") or "" - label = f"{CYAN}{cid}{RESET}" + (f" {DIM}({name}){RESET}" if name else "") - print(f"\n{label}") - print_bubble(r["direction"], r["message"], r["timestamp"]) - else: - print(f"\r {DIM}等待新消息... {datetime.now().strftime('%H:%M:%S')}{RESET}", end="", flush=True) - - time.sleep(refresh) - except KeyboardInterrupt: - print(f"\n{DIM}已退出监听。{RESET}") - - -def _extract_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): - import re as _re - tokens = _re.findall(r'(https?://\S+)', msg) - for t in tokens: - tl = t.lower() - if any(ext in tl for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]): - urls.append(t) - return urls - - -def _msg_refers_images(msg: str) -> bool: - if not msg: - return False - refs = ("图一", "图二", "第一张", "第二张", "这张", "那张", "上面那张", "下面那张", "刚才那张", "上一张", "下一张") - return any(r in msg for r in refs) - - -def _parse_ts(ts: str): - try: - from datetime import datetime as _dt - return _dt.fromisoformat(ts.replace("Z","")) - except Exception: - return None - - -def analyze_conversation(messages: list) -> list: - issues = [] - n = len(messages) - for i, m in enumerate(messages): - msg = m.get("message") or "" - dir = m.get("direction") - ts = _parse_ts(m.get("timestamp","")) - # 图片后未及时回复 - if dir == "in" and _extract_urls(msg): - replied = False - delay_ok = True - for j in range(i+1, min(i+6, n)): - mj = messages[j] - if mj.get("direction") == "out": - replied = True - tsj = _parse_ts(mj.get("timestamp","")) - if ts and tsj and (tsj - ts).total_seconds() > 180: - delay_ok = False - break - if not replied: - issues.append("图片消息后未回复") - elif not delay_ok: - issues.append("图片消息后回复延迟超过3分钟") - # 引用图片但找不到历史图片 - if dir == "in" and _msg_refers_images(msg): - has_prev_img = False - for k in range(max(0, i-10), i): - if messages[k].get("direction") == "in" and _extract_urls(messages[k].get("message","")): - has_prev_img = True - break - if not has_prev_img: - issues.append("引用图片但历史中未找到对应图片") - # 订单后未确认/引导 - if dir == "in" and ("买家已付款" in msg or "[系统订单信息]" in msg): - confirmed = False - for j in range(i+1, min(i+6, n)): - if messages[j].get("direction") == "out": - confirmed = True - break - if not confirmed: - issues.append("订单消息后未进行确认或引导付款") - # 合成需求未报价格 - if dir == "in" and any(k in msg for k in ("抓到", "放到", "合成", "融合", "嵌到", "替换", "P到", "抠出来放到")): - priced = False - for j in range(i+1, min(i+6, n)): - mj = messages[j] - if mj.get("direction") == "out": - rm = mj.get("message","") - if "元" in rm: - priced = True - break - if not priced: - issues.append("客户提出合成需求但未给出价格") - # 去重 - dedup = [] - seen = set() - for it in issues: - if it not in seen: - seen.add(it) - dedup.append(it) - return dedup - - -def cmd_analyze_all(): - customers = db.get_customers(limit=200) - if not customers: - print(f"{YELLOW}暂无聊天记录。{RESET}") - return - header("聊天记录上下文分析") - total_issues = 0 - for c in customers: - cid = c["customer_id"] - msgs = db.get_conversation(cid, limit=500) - issues = analyze_conversation(msgs) - if issues: - total_issues += len(issues) - print(f"{CYAN}{cid}{RESET} {c.get('customer_name','')}") - for s in issues: - print(f" - {RED}{s}{RESET}") - print() - if total_issues == 0: - print(f"{GREEN}未发现明显异常。{RESET}") - else: - print(f"{YELLOW}共发现 {total_issues} 项问题(按客户汇总)。{RESET}") - - -def print_help(): - print(f""" -{BOLD}聊天记录查看器{RESET} - - {CYAN}python chat_log_viewer.py{RESET} 列出所有客户 - {CYAN}python chat_log_viewer.py <客户ID>{RESET} 查看该客户全部对话 - {CYAN}python chat_log_viewer.py -t <客户ID>{RESET} 只看今天的对话 - {CYAN}python chat_log_viewer.py -s <关键词>{RESET} 全局搜索 - {CYAN}python chat_log_viewer.py -l{RESET} 实时监听新消息 - {CYAN}python chat_log_viewer.py -a{RESET} 分析上下文,输出异常项 - {CYAN}python chat_log_viewer.py -h{RESET} 显示帮助 -""") - - -if __name__ == "__main__": - args = sys.argv[1:] - - if not args: - cmd_list_customers() - - elif args[0] in ("-h", "--help"): - print_help() - - elif args[0] == "-s": - keyword = args[1] if len(args) > 1 else "" - if not keyword: - print(f"{RED}请提供搜索关键词:python chat_log_viewer.py -s <关键词>{RESET}") - else: - cmd_search(keyword) - - elif args[0] == "-t": - cid = args[1] if len(args) > 1 else "" - if not cid: - print(f"{RED}请提供客户ID:python chat_log_viewer.py -t <客户ID>{RESET}") - else: - cmd_show_conversation(cid, today_only=True) - - elif args[0] == "-l": - cmd_live() - - elif args[0] == "-a": - cmd_analyze_all() - - else: - cmd_show_conversation(args[0]) diff --git a/legacy/scripts/chat_ui.py b/legacy/scripts/chat_ui.py deleted file mode 100644 index ea3b2a1..0000000 --- a/legacy/scripts/chat_ui.py +++ /dev/null @@ -1,520 +0,0 @@ -# -*- coding: utf-8 -*- -""" -聊天记录 Web UI -运行: python scripts/chat_ui.py -访问: http://localhost:5678 -""" -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from flask import Flask, jsonify, render_template_string, request -import asyncio -from core.pydantic_ai_agent import CustomerServiceAgent, AgentDeps -from db import chat_log_db as db - -app = Flask(__name__) -pricing_agent = None -try: - pricing_agent = CustomerServiceAgent() -except Exception as e: - print(f"[ChatUI] 初始化报价Agent失败: {e}") - -HTML = r""" - - - - - -聊天记录 - - - - -
-

💬 聊天记录

- - ● 实时 -
- -
- -
- - - - - -
-
-
💬
-

选择一位客户查看对话记录

-
- - -
-
- - - - -""" - -PRICING_HTML = r""" - - - - - -AI 报价测试 - - - -
-
🧪 AI 报价测试
-
-
- -
-
- -
-
-
-
- -
-
-
- -
- -
提示:含图片URL时,Agent会自动调用图片分析并结合复杂度、尺寸、人脸与风险给出建议价;文本砍价低于最近图片底线会被礼貌拒绝。
-
- - - - -""" - - -@app.route("/") -def index(): - return render_template_string(HTML) - -@app.route("/pricing") -def pricing_index(): - return render_template_string(PRICING_HTML) - - -@app.route("/api/customers") -def api_customers(): - return jsonify(db.get_customers(limit=200)) - - -@app.route("/api/conversation/") -def api_conversation(customer_id): - return jsonify(db.get_conversation(customer_id, limit=500)) - - -@app.route("/api/search") -def api_search(): - kw = request.args.get("q", "").strip() - if not kw: - return jsonify([]) - return jsonify(db.search_messages(kw, limit=60)) - - -if __name__ == "__main__": - print("聊天记录 UI 启动中...") - print("访问 → http://localhost:5678") - app.run(host="0.0.0.0", port=5678, debug=False) - -@app.route("/api/pricing/run", methods=["POST"]) -def api_pricing_run(): - global pricing_agent - if pricing_agent is None: - return jsonify({"error":"报价Agent未初始化"}) - data = request.get_json(force=True) or {} - from_id = (data.get("from_id") or "").strip() - acc_id = (data.get("acc_id") or "").strip() - msg = (data.get("msg") or "").strip() - if not from_id or not msg: - return jsonify({"error":"缺少参数 from_id 或 msg"}) - # 构造提示词:直接使用用户输入,保持与正式场景一致 - user_prompt = msg - deps = AgentDeps( - msg_id="pricing-test", - acc_id=acc_id or "TEST_SHOP", - from_id=from_id, - platform="taobao" - ) - try: - # 强制使用报价Agent - result = asyncio.run(pricing_agent.agent_pricing.run(user_prompt, deps=deps, message_history=[])) - # 读取底线 - try: - from config.config import MIN_PRICE_FLOOR - st = pricing_agent._get_conversation_state(from_id) - floor = st.last_min_price if isinstance(st.last_min_price,int) and st.last_min_price>0 else MIN_PRICE_FLOOR - except Exception: - floor = None - return jsonify({ - "reply": result.output, - "should_reply": True, - "agent": "pricing", - "floor": floor - }) - except Exception as e: - return jsonify({"error": str(e)}) diff --git a/legacy/scripts/evolution_cycle.py b/legacy/scripts/evolution_cycle.py deleted file mode 100644 index 15b9aef..0000000 --- a/legacy/scripts/evolution_cycle.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -Self-evolution MVP cycle runner. -""" -from __future__ import annotations - -import argparse -import json -import os -import sys -from pathlib import Path - -from dotenv import load_dotenv - -PROJECT_ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(PROJECT_ROOT)) -load_dotenv(dotenv_path=PROJECT_ROOT / ".env") - -from evolution.mvp import ChatSourceConfig, DEFAULT_CANDIDATE_PATH, DEFAULT_POLICY_PATH, run_cycle - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run self-evolution MVP cycle") - parser.add_argument( - "--source", - type=str, - default="mysql", - choices=["auto", "sqlite", "mysql"], - help="Chat data source, default mysql (online)", - ) - parser.add_argument("--hours", type=int, default=24, help="Lookback window for chat samples") - parser.add_argument("--max-customers", type=int, default=200, help="Max customers sampled") - parser.add_argument( - "--max-messages-per-customer", - type=int, - default=80, - help="Max messages loaded per customer", - ) - parser.add_argument("--runtime-hours", type=int, default=24, help="Runtime metric window") - parser.add_argument( - "--publish", - action="store_true", - help="Write config/evolution_candidate.json when gate passes", - ) - parser.add_argument( - "--policy-path", - type=str, - default=str(DEFAULT_POLICY_PATH), - help="Path to evolution gate policy file", - ) - parser.add_argument( - "--candidate-path", - type=str, - default=str(DEFAULT_CANDIDATE_PATH), - help="Path to candidate output file", - ) - parser.add_argument("--db-path", type=str, default="", help="SQLite path when --source sqlite") - parser.add_argument("--mysql-host", type=str, default=os.getenv("MYSQL_HOST", "127.0.0.1")) - parser.add_argument("--mysql-port", type=int, default=int(os.getenv("MYSQL_PORT", "3306"))) - parser.add_argument("--mysql-user", type=str, default=os.getenv("MYSQL_USER", "root")) - parser.add_argument("--mysql-password", type=str, default=os.getenv("MYSQL_PASSWORD", "")) - parser.add_argument("--mysql-database", type=str, default=os.getenv("MYSQL_DATABASE", "ai_cs")) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - os.environ.setdefault("PYTHONUTF8", "1") - chat_source = ChatSourceConfig( - source=args.source, - sqlite_path=args.db_path or str(PROJECT_ROOT / "db" / "chat_log_db" / "chats.db"), - mysql_host=args.mysql_host, - mysql_port=args.mysql_port, - mysql_user=args.mysql_user, - mysql_password=args.mysql_password, - mysql_database=args.mysql_database, - ) - - result = run_cycle( - hours=args.hours, - max_customers=args.max_customers, - max_messages_per_customer=args.max_messages_per_customer, - runtime_hours=args.runtime_hours, - publish=args.publish, - chat_source=chat_source, - policy_path=Path(args.policy_path), - candidate_path=Path(args.candidate_path), - ) - print(json.dumps(result, ensure_ascii=False, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/legacy/scripts/init_designer_roster.py b/legacy/scripts/init_designer_roster.py deleted file mode 100644 index 9601e9a..0000000 --- a/legacy/scripts/init_designer_roster.py +++ /dev/null @@ -1,45 +0,0 @@ -# -*- coding: utf-8 -*- -""" -初始化设计师派单数据(SQLite) - -同一设计师在不同店铺对应不同 group_id。 -用法: - python scripts/init_designer_roster.py - # 按提示添加设计师和店铺分组,或直接修改下方示例后运行 -""" -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from db.designer_roster_db import add_designer, set_designer_shop, list_designers, update_online - - -def init_example(): - """示例:添加设计师,同一人在不同店铺不同分组""" - # 设计师A:在 小威哥1216 用分组 20252916034,在 另一店铺 用 12345678 - aid = add_designer("设计师A", "user_a") - set_designer_shop(aid, "小威哥1216", "20252916034") - set_designer_shop(aid, "另一店铺", "12345678") - - # 设计师B:只在 小威哥1216 - bid = add_designer("设计师B", "user_b") - set_designer_shop(bid, "小威哥1216", "99998888") - - # 可选:手动标记上线(否则等企微群解析) - update_online("user_a", True) - update_online("user_b", True) - - print("示例数据已写入") - - -if __name__ == "__main__": - if len(sys.argv) > 1 and sys.argv[1] == "example": - init_example() - elif len(sys.argv) > 1 and sys.argv[1] == "list": - for d in list_designers(): - print(f"{d['name']} ({d['wechat_user_id']}) 在线={d['is_online']}") - for shop, gid in d["shops"].items(): - print(f" - {shop} -> {gid}") - else: - print("用法: python scripts/init_designer_roster.py example # 写入示例") - print(" python scripts/init_designer_roster.py list # 查看当前数据") diff --git a/legacy/scripts/migrate_chat_logs_to_mysql.py b/legacy/scripts/migrate_chat_logs_to_mysql.py deleted file mode 100644 index 57905e4..0000000 --- a/legacy/scripts/migrate_chat_logs_to_mysql.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -把本地 SQLite 聊天记录迁移到 MySQL: - source: db/chat_log_db/chats.db -> table chat_logs - -用法示例: - python scripts/migrate_chat_logs_to_mysql.py --host xinhui.cloud --port 3306 \ - --user ai_cs_user --password xxx --database ai_cs --batch-size 2000 --truncate-target -""" - -from __future__ import annotations - -import argparse -import os -import sqlite3 -import time -from pathlib import Path - -import pymysql - - -def ensure_mysql_table(conn): - with conn.cursor() as cur: - cur.execute( - """ - CREATE TABLE IF NOT EXISTS chat_logs ( - id INTEGER PRIMARY KEY AUTO_INCREMENT, - customer_id VARCHAR(128) NOT NULL, - customer_name VARCHAR(255) DEFAULT '', - acc_id VARCHAR(128) DEFAULT '', - platform VARCHAR(64) DEFAULT '', - direction VARCHAR(8) NOT NULL, - message TEXT NOT NULL, - msg_type INTEGER DEFAULT 0, - timestamp DATETIME NOT NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """ - ) - cur.execute("SHOW INDEX FROM chat_logs") - exists = {str(r.get("Key_name", "")) for r in cur.fetchall()} - if "idx_customer" not in exists: - cur.execute("CREATE INDEX idx_customer ON chat_logs(customer_id)") - if "idx_ts" not in exists: - cur.execute("CREATE INDEX idx_ts ON chat_logs(timestamp)") - if "idx_acc" not in exists: - cur.execute("CREATE INDEX idx_acc ON chat_logs(acc_id)") - conn.commit() - - -def get_sqlite_conn(path: Path): - conn = sqlite3.connect(str(path)) - conn.row_factory = sqlite3.Row - return conn - - -def get_mysql_conn(host: str, port: int, user: str, password: str, database: str): - return pymysql.connect( - host=host, - port=port, - user=user, - password=password, - database=database, - charset="utf8mb4", - autocommit=False, - cursorclass=pymysql.cursors.DictCursor, - ) - - -def migrate(sqlite_path: Path, host: str, port: int, user: str, password: str, database: str, batch_size: int, truncate_target: bool): - if not sqlite_path.exists(): - raise FileNotFoundError(f"SQLite 文件不存在: {sqlite_path}") - - s_conn = get_sqlite_conn(sqlite_path) - m_conn = get_mysql_conn(host, port, user, password, database) - try: - ensure_mysql_table(m_conn) - if truncate_target: - with m_conn.cursor() as cur: - cur.execute("TRUNCATE TABLE chat_logs") - m_conn.commit() - - total = s_conn.execute("SELECT COUNT(*) AS c FROM chat_logs").fetchone()["c"] - print(f"[MIGRATE] SQLite 源总行数: {total}") - if total == 0: - return 0 - - migrated = 0 - last_id = 0 - started = time.time() - - insert_sql = ( - "INSERT INTO chat_logs " - "(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp) " - "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)" - ) - - while True: - rows = s_conn.execute( - """ - SELECT id, customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp - FROM chat_logs - WHERE id > ? - ORDER BY id ASC - LIMIT ? - """, - (last_id, batch_size), - ).fetchall() - if not rows: - break - - vals = [] - for r in rows: - vals.append( - ( - r["customer_id"] or "", - r["customer_name"] or "", - r["acc_id"] or "", - r["platform"] or "", - r["direction"] or "in", - r["message"] or "", - int(r["msg_type"] or 0), - r["timestamp"], - ) - ) - last_id = r["id"] - - with m_conn.cursor() as cur: - cur.executemany(insert_sql, vals) - m_conn.commit() - - migrated += len(vals) - elapsed = time.time() - started - print(f"[MIGRATE] {migrated}/{total} ({(migrated/total)*100:.1f}%) elapsed={elapsed:.1f}s") - - return migrated - finally: - try: - s_conn.close() - except Exception: - pass - try: - m_conn.close() - except Exception: - pass - - -def main(): - parser = argparse.ArgumentParser(description="迁移 chat_logs: SQLite -> MySQL") - parser.add_argument("--sqlite-path", default=str(Path("db") / "chat_log_db" / "chats.db")) - parser.add_argument("--host", required=True) - parser.add_argument("--port", type=int, default=3306) - parser.add_argument("--user", required=True) - parser.add_argument("--password", required=True) - parser.add_argument("--database", required=True) - parser.add_argument("--batch-size", type=int, default=2000) - parser.add_argument("--truncate-target", action="store_true") - args = parser.parse_args() - - sqlite_path = Path(args.sqlite_path) - migrated = migrate( - sqlite_path=sqlite_path, - host=args.host, - port=args.port, - user=args.user, - password=args.password, - database=args.database, - batch_size=max(100, int(args.batch_size)), - truncate_target=bool(args.truncate_target), - ) - print(f"[DONE] 迁移完成,写入 {migrated} 条") - - -if __name__ == "__main__": - main() diff --git a/legacy/scripts/migrate_customers_json_to_mysql.py b/legacy/scripts/migrate_customers_json_to_mysql.py deleted file mode 100644 index 268ca8c..0000000 --- a/legacy/scripts/migrate_customers_json_to_mysql.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -迁移 customer_db/customers.json -> MySQL customer_profiles -""" - -from __future__ import annotations - -import argparse -import json -from datetime import datetime -from pathlib import Path - -import pymysql - - -def get_conn(host: str, port: int, user: str, password: str, database: str): - return pymysql.connect( - host=host, - port=port, - user=user, - password=password, - database=database, - charset="utf8mb4", - autocommit=False, - cursorclass=pymysql.cursors.DictCursor, - ) - - -def ensure_table(conn): - with conn.cursor() as cur: - cur.execute( - """ - CREATE TABLE IF NOT EXISTS customer_profiles ( - customer_id VARCHAR(128) PRIMARY KEY, - profile_json LONGTEXT NOT NULL, - last_update DATETIME NOT NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - """ - ) - cur.execute("SHOW INDEX FROM customer_profiles") - exists = {str(r.get("Key_name", "")) for r in cur.fetchall()} - if "idx_last_update" not in exists: - cur.execute("CREATE INDEX idx_last_update ON customer_profiles(last_update)") - conn.commit() - - -def migrate(json_path: Path, host: str, port: int, user: str, password: str, database: str, truncate_target: bool): - if not json_path.exists(): - raise FileNotFoundError(f"customers.json 不存在: {json_path}") - customers = json.loads(json_path.read_text(encoding="utf-8") or "{}") - if not isinstance(customers, dict): - raise RuntimeError("customers.json 格式错误,期望对象映射") - - conn = get_conn(host, port, user, password, database) - try: - ensure_table(conn) - if truncate_target: - with conn.cursor() as cur: - cur.execute("TRUNCATE TABLE customer_profiles") - conn.commit() - - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - sql = ( - "REPLACE INTO customer_profiles (customer_id, profile_json, last_update) " - "VALUES (%s, %s, %s)" - ) - total = 0 - with conn.cursor() as cur: - for cid, profile in customers.items(): - cur.execute(sql, (str(cid), json.dumps(profile, ensure_ascii=False), now)) - total += 1 - conn.commit() - return total - finally: - conn.close() - - -def main(): - parser = argparse.ArgumentParser(description="迁移 customers.json 到 MySQL") - parser.add_argument("--json-path", default=str(Path("customer_db") / "customers.json")) - parser.add_argument("--host", required=True) - parser.add_argument("--port", type=int, default=3306) - parser.add_argument("--user", required=True) - parser.add_argument("--password", required=True) - parser.add_argument("--database", required=True) - parser.add_argument("--truncate-target", action="store_true") - args = parser.parse_args() - - total = migrate( - json_path=Path(args.json_path), - host=args.host, - port=args.port, - user=args.user, - password=args.password, - database=args.database, - truncate_target=bool(args.truncate_target), - ) - print(f"[DONE] customer_profiles 写入 {total} 条") - - -if __name__ == "__main__": - main() diff --git a/legacy/scripts/migrate_remaining_sqlite_to_mysql.py b/legacy/scripts/migrate_remaining_sqlite_to_mysql.py deleted file mode 100644 index 98f49bb..0000000 --- a/legacy/scripts/migrate_remaining_sqlite_to_mysql.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -迁移其余 SQLite 业务库到 MySQL(保留主键): -- deal_outcome_db/outcomes.db -> deal_outcomes -- designer_roster_db/roster.db -> designers/designer_shops/designer_online/round_robin -- image_tasks.db -> image_tasks/requirement_history -- task_db/tasks.db -> tasks/task_logs -""" - -from __future__ import annotations - -import argparse -import sqlite3 -from pathlib import Path -from typing import List, Dict - -import pymysql - - -MAPPINGS = [ - {"sqlite": Path("db/deal_outcome_db/outcomes.db"), "tables": ["deal_outcomes"]}, - {"sqlite": Path("db/designer_roster_db/roster.db"), "tables": ["designers", "designer_shops", "designer_online", "round_robin"]}, - {"sqlite": Path("db/image_tasks.db"), "tables": ["image_tasks", "task_requirement_changes"]}, - {"sqlite": Path("db/task_db/tasks.db"), "tables": ["tasks"]}, -] - - -def mysql_conn(host: str, port: int, user: str, password: str, database: str): - return pymysql.connect( - host=host, - port=port, - user=user, - password=password, - database=database, - charset="utf8mb4", - autocommit=False, - cursorclass=pymysql.cursors.DictCursor, - ) - - -def sqlite_table_exists(conn: sqlite3.Connection, table: str) -> bool: - row = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name=?", - (table,), - ).fetchone() - return row is not None - - -def sqlite_fetch_all(conn: sqlite3.Connection, table: str) -> List[sqlite3.Row]: - conn.row_factory = sqlite3.Row - return conn.execute(f"SELECT * FROM {table}").fetchall() - - -def migrate_table(mysql, rows: List[sqlite3.Row], table: str, truncate_target: bool) -> int: - if not rows: - return 0 - cols = list(rows[0].keys()) - col_sql = ", ".join(cols) - val_sql = ", ".join(["%s"] * len(cols)) - sql = f"REPLACE INTO {table} ({col_sql}) VALUES ({val_sql})" - if truncate_target: - with mysql.cursor() as cur: - try: - cur.execute(f"TRUNCATE TABLE {table}") - except Exception: - try: - cur.execute(f"DELETE FROM {table}") - except Exception: - return 0 - values = [tuple(r[c] for c in cols) for r in rows] - with mysql.cursor() as cur: - cur.executemany(sql, values) - mysql.commit() - return len(values) - - -def main(): - p = argparse.ArgumentParser(description="迁移剩余 SQLite 业务库到 MySQL") - p.add_argument("--host", required=True) - p.add_argument("--port", type=int, default=3306) - p.add_argument("--user", required=True) - p.add_argument("--password", required=True) - p.add_argument("--database", required=True) - p.add_argument("--truncate-target", action="store_true") - args = p.parse_args() - - total = 0 - with mysql_conn(args.host, args.port, args.user, args.password, args.database) as mconn: - for item in MAPPINGS: - sp = item["sqlite"] - if not sp.exists(): - continue - sconn = sqlite3.connect(str(sp)) - try: - for table in item["tables"]: - if not sqlite_table_exists(sconn, table): - continue - rows = sqlite_fetch_all(sconn, table) - n = migrate_table(mconn, rows, table, truncate_target=bool(args.truncate_target)) - total += n - print(f"[MIGRATE] {sp}::{table} -> {n}") - finally: - sconn.close() - print(f"[DONE] migrated total rows: {total}") - - -if __name__ == "__main__": - main() diff --git a/legacy/scripts/run_test_ai_chat.ps1 b/legacy/scripts/run_test_ai_chat.ps1 deleted file mode 100644 index 48ceab1..0000000 --- a/legacy/scripts/run_test_ai_chat.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$ErrorActionPreference = "Stop" - -# Use a writable uv cache path on Windows to avoid permission issues -# with default cache locations in restricted environments. -$env:UV_CACHE_DIR = Join-Path $env:TEMP "uv-cache-tw-runtime" -New-Item -ItemType Directory -Force $env:UV_CACHE_DIR | Out-Null - -uv run tests\test_ai_chat.py diff --git a/legacy/websocket_agent_reply_flow.py b/legacy/websocket_agent_reply_flow.py deleted file mode 100644 index ffff588..0000000 --- a/legacy/websocket_agent_reply_flow.py +++ /dev/null @@ -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), - ) diff --git a/legacy/websocket_auto_quote_flow.py b/legacy/websocket_auto_quote_flow.py deleted file mode 100644 index a15cd7d..0000000 --- a/legacy/websocket_auto_quote_flow.py +++ /dev/null @@ -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)) diff --git a/legacy/websocket_brain_flow.py b/legacy/websocket_brain_flow.py deleted file mode 100644 index b61b599..0000000 --- a/legacy/websocket_brain_flow.py +++ /dev/null @@ -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": "", - }, - ) - ) diff --git a/legacy/websocket_callback_flow.py b/legacy/websocket_callback_flow.py deleted file mode 100644 index e27e17c..0000000 --- a/legacy/websocket_callback_flow.py +++ /dev/null @@ -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), - ) diff --git a/legacy/websocket_client.py b/legacy/websocket_client.py deleted file mode 100644 index 4ef551c..0000000 --- a/legacy/websocket_client.py +++ /dev/null @@ -1,556 +0,0 @@ -import asyncio -import json -import hashlib -from collections import deque -from datetime import datetime -from typing import Optional, Dict, Any, List -from utils.observability import emit_activity -from core.websocket_agent_reply_flow import handle_agent_reply_flow -from core.websocket_quote_flow import handle_single_image_quote, handle_multi_image_quote -from core.websocket_debounce_flow import ( - debounce_agent_reply, - pick_debounce_seconds, - guess_intent_for_debounce, - looks_like_requirement_text, - rand_between, - msg_has_image_url, - msg_refers_images, - extract_image_urls, - collect_recent_image_urls, -) -from core.websocket_auto_quote_flow import ( - cancel_auto_quote_task, - build_auto_quote_signature, - schedule_auto_quote, -) -from core.websocket_system_inquiry_flow import ( - load_system_inquiry_rules, - normalize_kw_list, - resolve_system_inquiry_policy, - match_system_inquiry, - handle_system_inquiry, -) -from core.websocket_transfer_flow import transfer_to_human_flow -from core.websocket_outbound_arbiter_flow import ( - normalize_reply_semantic_key, - classify_outbound_reply, - template_family, - outbound_arbiter, -) -from core.websocket_followup_flow import ( - unreplied_followup_loop, - scan_and_send_unreplied_followups, - compose_ai_scene_reply, -) -from core.websocket_outbound_flow import ( - send_reply_flow, - ai_generate_outbound_reply, - ai_guard_outbound_reply, - colloquialize_outbound_reply, -) -from core.websocket_runtime_flow import command_handler_flow, run_client_flow -from core.websocket_workflow_flow import workflow_agent_notify_flow, workflow_send_flow -from core.websocket_connection_flow import connect_flow, receive_messages_flow, handle_message_flow -from core.websocket_send_flow import send_text_flow, send_image_flow, send_message_flow -from core.websocket_callback_flow import post_tianwang_callback_flow -from core.websocket_customer_profile_flow import extract_and_save_customer_info_flow -from core.websocket_message_utils_flow import ( - is_transfer_msg, - pick_transfer_greeting, - is_shop_card, - extract_customer_text_from_shop_card_msg, - has_chat_history, - should_ignore, - get_msg_type_name, - to_chinese_text, -) -from core.websocket_dispatch_flow import dispatch_assign_once_flow -from core.websocket_image_entry_flow import handle_image_message_flow -from core.websocket_misc_rules_flow import ( - msg_is_price_inquiry, - detect_order_status, - msg_requests_external_contact, - extract_size_pairs_m, - oversize_reply_if_needed, -) -from core.websocket_summary_flow import save_conversation_summary_flow -from core.websocket_helpers_flow import ( - fire_and_forget, - prune_seen, - log_inbound_once, - log_outbound_once, - build_customer_message, - touch_customer_last_contact, - push_chat_to_wechat_safe, -) -from core.websocket_logger_setup import setup_logger - -# ========== 转接分组映射 ========== -def _get_transfer_group(acc_id: str) -> str: - """根据店铺 acc_id 获取转接分组 ID。不同店铺对应不同客服分组。""" - from config.config import CONFIG_DIR - config_path = CONFIG_DIR / "transfer_groups.json" - default_group = "20252916034" - try: - if config_path.exists(): - with open(config_path, "r", encoding="utf-8") as f: - cfg = json.load(f) - return cfg.get(acc_id, cfg.get("default", default_group)) - except Exception: - logger.debug("读取转接分组配置失败,使用默认分组", exc_info=True) - return default_group - -import os -logger = setup_logger() - -from db.chat_log_db import log_message as _chat_log -from utils.metrics_tracker import emit as metrics_emit - -# 导入 Agent 模块 -try: - from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage, AgentDeps, _get_shop_type - from db.customer_db import db - from core.workflow import workflow - AGENT_AVAILABLE = True -except Exception as e: - AGENT_AVAILABLE = False - workflow = None - AgentDeps = None - _get_shop_type = lambda acc_id, goods_name: "find_image" - import traceback - logger.info(f"警告: Agent 模块导入失败: {e}") - traceback.print_exc() - logger.info("将使用基础回复功能") - - -class QingjianAPIClient: - """轻简API WebSocket客户端""" - - def __init__(self, uri=None, enable_agent: bool = True): - from config.config import QINGJIAN_WS_URI - from config.config import IMAGE_MODULE_ENABLED - from config.config import MESSAGE_DEBOUNCE_SECONDS - self.uri = uri or QINGJIAN_WS_URI - self.websocket = None - self.running = True - self.reply_id = "tb001" # 回复时使用的from_id - self.last_msg = None # 保存最后一条消息 - self.enable_agent = enable_agent and AGENT_AVAILABLE - self.logger = logger - self.AgentDeps = AgentDeps - self.agent = None - self._replied_msg_ids: deque = deque(maxlen=200) # 已回复消息ID,FIFO去重 - - # 消息防抖:同一客户连续发消息时,等待 N 秒后合并处理 - self._DEBOUNCE_SECONDS = MESSAGE_DEBOUNCE_SECONDS if isinstance(MESSAGE_DEBOUNCE_SECONDS, int) else 8 - self._adaptive_debounce_enabled = os.getenv("ADAPTIVE_DEBOUNCE_ENABLED", "true").lower() in ("1", "true", "yes") - self._debounce_tasks: dict = {} # customer_key -> asyncio.Task - self._pending_msgs: dict = {} # customer_key -> list[data] - self._image_enabled = IMAGE_MODULE_ENABLED - - # 同客户消息串行:保证「发图→这个高清」等顺序,避免误判 - self._customer_locks: dict = {} # customer_key -> asyncio.Lock - # agent_reply 并发上限,防止 API 打满 - self._agent_semaphore = asyncio.Semaphore(8) - self._pending_images: dict = {} - self._pending_image_tasks: dict = {} - self._auto_quote_tasks: dict = {} # customer_key -> asyncio.Task - self._auto_quote_done_sig: dict = {} # customer_key -> signature(同一批内容仅自动触发一次) - # 旧版“看图即报价”快速链路(默认关闭,避免与 Agent 批量收集逻辑并发打架) - self._legacy_fast_quote_enabled = os.getenv("LEGACY_FAST_IMAGE_QUOTE", "false").lower() in ("1", "true", "yes") - self._system_inquiry_rules = self._load_system_inquiry_rules() - self._last_reply_sent_at: dict = {} # customer_key -> monotonic ts - self._outbound_semantic_seen: dict = {} # customer_key -> {semantic_key: ts} - self._outbound_class_seen: dict = {} # customer_key -> {reply_class: ts} - self._outbound_template_seen: dict = {} # customer_key -> {template_family: ts} - self._unreplied_followup_sent: dict = {} # customer_key -> monotonic ts(补偿消息节流) - self._inbound_log_seen: dict = {} # signature -> monotonic ts(防重复写入) - self._outbound_log_seen: dict = {} # signature -> monotonic ts(防重复写入) - self._tianwang_callback_url = ( - os.getenv("TIANWANG_CALLBACK_URL", "").strip() - or "http://139.199.3.75:18789/api/callback" - ) - self._tianwang_agent_name = os.getenv("TIANWANG_AGENT_NAME", "终结者").strip() or "终结者" - self._reply_guard_enabled = os.getenv("AI_REPLY_GUARD_ENABLED", "true").lower() in ("1", "true", "yes") - self._reply_guard_verbose = os.getenv("AI_REPLY_GUARD_VERBOSE", "false").lower() in ("1", "true", "yes") - self._force_ai_generate_reply = os.getenv("FORCE_AI_GENERATE_ALL_REPLIES", "true").lower() in ("1", "true", "yes") - - # 延迟加载任务模块(避免循环导入) - self.task_scheduler = None - self.task_manager = None - self.trigger_engine = None - - # 多进程分片支持 - self.shard_keys: set = set() # 本进程负责的客户 key 集合 - self.worker_id = int(os.getenv('AI_CS_WORKER_ID', '0')) - self.worker_count = max(1, int(os.getenv('AI_CS_WORKER_COUNT', '1'))) - - # 初始化 Agent - if self.enable_agent: - try: - self.agent = CustomerServiceAgent() - logger.info(f"[{self.get_time()}] Agent 初始化成功") - except Exception as e: - logger.info(f"[{self.get_time()}] Agent 初始化失败: {e}") - self.enable_agent = False - - # 注册 workflow 消息发送回调(供图片AI完成后推送消息用) - if workflow: - workflow.register_send_callback(self._workflow_send) - workflow.register_agent_notify_callback(self._workflow_agent_notify) - - def _activity_log(self, event: str, **kwargs): - """统一活动日志,便于按 event 检索完整链路。""" - emit_activity( - logger, - event=event, - trace_id=str(kwargs.pop("trace_id", "")), - customer_id=str(kwargs.pop("customer_id", "")), - result=str(kwargs.pop("result", "ok")), - **kwargs, - ) - - async def _post_tianwang_callback(self, event: str, data: dict, extra: Optional[Dict[str, Any]] = None): - await post_tianwang_callback_flow(self, event, data, extra=extra) - - - async def connect(self): - await connect_flow(self) - - def _customer_key(self, data: dict) -> str: - """同一店铺+客户 = 同一会话""" - return f"{data.get('acc_id','')}:{data.get('from_id','')}" - - def _get_customer_lock(self, key: str) -> asyncio.Lock: - if key not in self._customer_locks: - self._customer_locks[key] = asyncio.Lock() - return self._customer_locks[key] - - def _is_owned_by_this_worker(self, customer_key: str) -> bool: - """ - 多进程兜底路由: - - 若显式分片存在,用显式分片; - - 否则按 customer_key 哈希到固定 worker,避免多进程重复处理同一消息。 - """ - if self.shard_keys: - return customer_key in self.shard_keys - if self.worker_count <= 1: - return True - try: - h = int(hashlib.md5(customer_key.encode("utf-8")).hexdigest()[:8], 16) - return (h % self.worker_count) == self.worker_id - except Exception: - return self.worker_id == 0 - - async def _agent_reply_serialized(self, data: dict): - """同客户串行 + 全局并发限制,再执行 agent_reply""" - key = self._customer_key(data) - async with self._get_customer_lock(key): - async with self._agent_semaphore: - await self.agent_reply(data) - - def _fire_and_forget(self, coro): - fire_and_forget(self, coro) - - @staticmethod - def _prune_seen(seen: dict, now_mono: float, ttl_sec: float = 8.0): - prune_seen(seen, now_mono, ttl_sec=ttl_sec) - - def _log_inbound_once(self, data: dict): - log_inbound_once(self, data, _chat_log) - - def _log_outbound_once(self, original_msg: dict, reply_content: str): - log_outbound_once(self, original_msg, reply_content, _chat_log) - - def _build_customer_message(self, data: dict) -> CustomerMessage: - return build_customer_message(self, data, CustomerMessage) - - def _touch_customer_last_contact(self, customer_id: str): - touch_customer_last_contact(self, customer_id, db) - - def _push_chat_to_wechat_safe( - self, - *, - data: dict, - customer_msg: str, - reply_msg: str, - tag: str, - goods_name: str = "", - ) -> None: - push_chat_to_wechat_safe( - self, - data=data, - customer_msg=customer_msg, - reply_msg=reply_msg, - tag=tag, - goods_name=goods_name, - ) - - @staticmethod - def _normalize_reply_semantic_key(text: str) -> str: - return normalize_reply_semantic_key(text) - - @staticmethod - def _classify_outbound_reply(text: str) -> str: - return classify_outbound_reply(text) - - @staticmethod - def _template_family(reply: str) -> str: - return template_family(reply) - - def _outbound_arbiter(self, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]: - return outbound_arbiter(self, original_msg, reply_content, trace_id) - - async def _unreplied_followup_loop(self): - await unreplied_followup_loop(self) - - async def _scan_and_send_unreplied_followups(self): - await scan_and_send_unreplied_followups(self) - - async def _compose_ai_scene_reply( - self, - *, - original_msg: dict, - scene: str, - intent_hint: str, - fallback: str, - ) -> str: - return await compose_ai_scene_reply( - self, - original_msg=original_msg, - scene=scene, - intent_hint=intent_hint, - fallback=fallback, - ) - - async def receive_messages(self): - await receive_messages_flow(self) - - async def handle_message(self, message): - await handle_message_flow(self, message, shop_type_resolver=_get_shop_type) - - async def _debounce_agent_reply(self, data: dict): - await debounce_agent_reply(self, data) - - @staticmethod - def _rand_between(low: float, high: float) -> float: - return rand_between(low, high) - - def _guess_intent_for_debounce(self, msg: str) -> str: - return guess_intent_for_debounce(self, msg) - - @staticmethod - def _looks_like_requirement_text(msg: str) -> bool: - return looks_like_requirement_text(msg) - - def _pick_debounce_seconds(self, data: dict, msg: str) -> float: - return pick_debounce_seconds(self, data, msg) - - def _msg_has_image_url(self, msg: str) -> bool: - return msg_has_image_url(msg) - - def _msg_refers_images(self, msg: str) -> bool: - return msg_refers_images(msg) - - def _extract_image_urls(self, msg: str) -> list: - return extract_image_urls(msg) - - def _collect_recent_image_urls(self, customer_id: str, acc_id: str, max_count: int = 6) -> list: - return collect_recent_image_urls(self, customer_id, acc_id, max_count=max_count) - - def _msg_is_requirement(self, msg: str) -> bool: - if not msg: - return False - kws = ( - "要", "抓到", "放到", "合成", "替换", "抠", "修", "高清", "尺寸", "横", "竖", "颜色", "去背景", "排版", "一样", "类似", "同款", - "能不能做", "能做吗", "可以做吗", "做不做", "这个能做吗", "这个能不能做", - ) - return any(k in msg for k in kws) - - def _add_pending_images(self, key: str, urls: list, limit: int = 12): - if not urls: - return - cur = self._pending_images.get(key) or [] - for u in urls: - if u not in cur: - cur.append(u) - if len(cur) >= limit: - break - self._pending_images[key] = cur - - async def _flush_pending_images(self, key: str, data: dict): - urls = self._pending_images.get(key) or [] - if not urls: - return - self._pending_images[key] = [] - if len(urls) == 1: - await self._analyze_single_and_reply(data, urls[0]) - else: - await self._analyze_multi_and_reply(data, urls) - - def _msg_is_price_inquiry(self, msg: str) -> bool: - return msg_is_price_inquiry(msg) - - def _detect_order_status(self, msg: str) -> str: - return detect_order_status(msg) - - async def _analyze_single_and_reply(self, data: dict, url: str): - await handle_single_image_quote(self, data, url) - - async def agent_reply(self, data: dict): - """使用 Agent 处理消息并回复""" - await handle_agent_reply_flow( - self, - data, - workflow=workflow, - shop_type_resolver=_get_shop_type, - ) - - def _cancel_auto_quote_task(self, key: str, reason: str = ""): - cancel_auto_quote_task(self, key, reason=reason) - - @staticmethod - def _build_auto_quote_signature(state: Any) -> str: - return build_auto_quote_signature(state) - - async def _maybe_schedule_auto_quote(self, data: dict): - await schedule_auto_quote(self, data, shop_type_resolver=_get_shop_type) - - async def _analyze_multi_and_reply(self, data: dict, urls: list): - await handle_multi_image_quote(self, data, urls) - def _msg_requests_external_contact(self, msg: str) -> bool: - return msg_requests_external_contact(msg) - - @staticmethod - def _extract_size_pairs_m(msg: str) -> list[tuple[float, float]]: - return extract_size_pairs_m(msg) - - def _oversize_reply_if_needed(self, msg: str) -> str: - return oversize_reply_if_needed(msg) - def _is_transfer_msg(self, data: dict) -> bool: - return is_transfer_msg(self, data) - - def _pick_transfer_greeting(self) -> str: - return pick_transfer_greeting() - - def _is_shop_card(self, data: dict) -> bool: - return is_shop_card(self, data) - - def _extract_customer_text_from_shop_card_msg(self, msg: str) -> str: - return extract_customer_text_from_shop_card_msg(self, msg) - - def _has_chat_history(self, customer_id: str, acc_id: str = "") -> bool: - return has_chat_history(customer_id, acc_id=acc_id) - - def _load_system_inquiry_rules(self) -> Dict[str, Any]: - return load_system_inquiry_rules() - - @staticmethod - def _normalize_kw_list(v: Any) -> List[str]: - return normalize_kw_list(v) - - def _resolve_system_inquiry_policy(self, acc_id: str) -> Dict[str, Any]: - return resolve_system_inquiry_policy(self, acc_id) - - def _match_system_inquiry(self, data: dict, policy: Dict[str, Any]) -> bool: - return match_system_inquiry(self, data, policy) - - async def _handle_system_inquiry(self, data: dict) -> bool: - return await handle_system_inquiry(self, data) - - def _should_ignore(self, data: dict) -> bool: - return should_ignore(self, data) - - def get_msg_type_name(self, msg_type): - return get_msg_type_name(msg_type) - - def _extract_and_save_customer_info(self, message: str, customer_id: str): - extract_and_save_customer_info_flow(self, message, customer_id, db) - - def to_chinese(self, text): - return to_chinese_text(text) - - async def handle_image_message(self, data: dict): - await handle_image_message_flow(self, data) - - async def _dispatch_assign_once(self) -> Dict[str, Any]: - return await dispatch_assign_once_flow(self) - - async def transfer_to_human(self, data: dict, transfer_msg: str = ""): - await transfer_to_human_flow( - self, - data, - transfer_msg=transfer_msg, - transfer_group_resolver=_get_transfer_group, - ) - - async def _save_conversation_summary(self, customer_id: str, buyer_msg: str, agent_reply: str): - await save_conversation_summary_flow(self, customer_id, buyer_msg, agent_reply) - - async def _workflow_agent_notify( - self, - customer_id: str, - acc_id: str, - acc_type: str, - system_hint: str, - ): - await workflow_agent_notify_flow(self, customer_id, acc_id, acc_type, system_hint) - - async def _workflow_send( - self, - customer_id: str, - acc_id: str, - acc_type: str, - content: str, - msg_type: int = 0 - ): - await workflow_send_flow(self, customer_id, acc_id, acc_type, content, msg_type=msg_type) - - async def send_reply(self, original_msg, reply_content): - await send_reply_flow(self, original_msg, reply_content) - - async def _ai_generate_outbound_reply(self, original_msg: dict, reply_content: str) -> str: - return await ai_generate_outbound_reply(self, original_msg, reply_content) - - def _colloquialize_outbound_reply(self, text: Any) -> Any: - return colloquialize_outbound_reply(text) - - async def _ai_guard_outbound_reply(self, original_msg: dict, reply_content: str) -> tuple[bool, str, str]: - return await ai_guard_outbound_reply(self, original_msg, reply_content) - - async def send_text(self, cy_id, acc_type, content): - await send_text_flow(self, cy_id, acc_type, content) - - async def send_image(self, cy_id, acc_type, image_path): - await send_image_flow(self, cy_id, acc_type, image_path) - - async def send_message(self, message): - await send_message_flow(self, message) - - async def auto_reply(self, data): - """自动回复示例(已弃用,使用 agent_reply 替代)""" - pass - - async def command_handler(self): - await command_handler_flow(self) - - def get_time(self): - """获取当前时间字符串""" - return datetime.now().strftime("%H:%M:%S") - - async def run(self): - await run_client_flow(self) - - -if __name__ == "__main__": - import sys - - # 检查是否有 --no-agent 参数 - enable_agent = "--no-agent" not in sys.argv - - client = QingjianAPIClient(enable_agent=enable_agent) - try: - asyncio.run(client.run()) - except KeyboardInterrupt: - logger.info("\n已停止") - diff --git a/legacy/websocket_customer_profile_flow.py b/legacy/websocket_customer_profile_flow.py deleted file mode 100644 index e06422b..0000000 --- a/legacy/websocket_customer_profile_flow.py +++ /dev/null @@ -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) diff --git a/legacy/websocket_debounce_flow.py b/legacy/websocket_debounce_flow.py deleted file mode 100644 index fe54a66..0000000 --- a/legacy/websocket_debounce_flow.py +++ /dev/null @@ -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 diff --git a/legacy/websocket_dispatch_flow.py b/legacy/websocket_dispatch_flow.py deleted file mode 100644 index e77769b..0000000 --- a/legacy/websocket_dispatch_flow.py +++ /dev/null @@ -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)} diff --git a/legacy/websocket_followup_flow.py b/legacy/websocket_followup_flow.py deleted file mode 100644 index 54644c4..0000000 --- a/legacy/websocket_followup_flow.py +++ /dev/null @@ -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 diff --git a/legacy/websocket_helpers_flow.py b/legacy/websocket_helpers_flow.py deleted file mode 100644 index a33d542..0000000 --- a/legacy/websocket_helpers_flow.py +++ /dev/null @@ -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) diff --git a/legacy/websocket_image_entry_flow.py b/legacy/websocket_image_entry_flow.py deleted file mode 100644 index b0ed30b..0000000 --- a/legacy/websocket_image_entry_flow.py +++ /dev/null @@ -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)) diff --git a/legacy/websocket_inbound_flow.py b/legacy/websocket_inbound_flow.py deleted file mode 100644 index 31d4efa..0000000 --- a/legacy/websocket_inbound_flow.py +++ /dev/null @@ -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}") diff --git a/legacy/websocket_message_utils_flow.py b/legacy/websocket_message_utils_flow.py deleted file mode 100644 index 9db0c6a..0000000 --- a/legacy/websocket_message_utils_flow.py +++ /dev/null @@ -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})") diff --git a/legacy/websocket_misc_rules_flow.py b/legacy/websocket_misc_rules_flow.py deleted file mode 100644 index 27bf161..0000000 --- a/legacy/websocket_misc_rules_flow.py +++ /dev/null @@ -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) diff --git a/legacy/websocket_outbound_arbiter_flow.py b/legacy/websocket_outbound_arbiter_flow.py deleted file mode 100644 index 90e6168..0000000 --- a/legacy/websocket_outbound_arbiter_flow.py +++ /dev/null @@ -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" diff --git a/legacy/websocket_outbound_flow.py b/legacy/websocket_outbound_flow.py deleted file mode 100644 index 3e93e37..0000000 --- a/legacy/websocket_outbound_flow.py +++ /dev/null @@ -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}" diff --git a/legacy/websocket_quote_flow.py b/legacy/websocket_quote_flow.py deleted file mode 100644 index e510035..0000000 --- a/legacy/websocket_quote_flow.py +++ /dev/null @@ -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) diff --git a/legacy/websocket_summary_flow.py b/legacy/websocket_summary_flow.py deleted file mode 100644 index 9742694..0000000 --- a/legacy/websocket_summary_flow.py +++ /dev/null @@ -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) diff --git a/legacy/websocket_system_inquiry_flow.py b/legacy/websocket_system_inquiry_flow.py deleted file mode 100644 index 6689bf3..0000000 --- a/legacy/websocket_system_inquiry_flow.py +++ /dev/null @@ -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 diff --git a/legacy/websocket_transfer_flow.py b/legacy/websocket_transfer_flow.py deleted file mode 100644 index 0d6ef4a..0000000 --- a/legacy/websocket_transfer_flow.py +++ /dev/null @@ -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) diff --git a/legacy/websocket_workflow_flow.py b/legacy/websocket_workflow_flow.py deleted file mode 100644 index cb47f88..0000000 --- a/legacy/websocket_workflow_flow.py +++ /dev/null @@ -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) diff --git a/legacy/wechat_chat_log.py b/legacy/wechat_chat_log.py deleted file mode 100644 index 6e94147..0000000 --- a/legacy/wechat_chat_log.py +++ /dev/null @@ -1,150 +0,0 @@ -# -*- coding: utf-8 -*- -""" -客服对话推送到企业微信群 - 客户消息与AI回复成对发送,保持上下文 -""" -import asyncio -import os -import logging -from datetime import datetime - -import httpx -from dotenv import load_dotenv - -load_dotenv() -logger = logging.getLogger("cs_agent") - -_last_push: dict[tuple[str, str], tuple[str, str, float]] = {} - -def _get_webhook() -> str: - """优先从 config 读取,与健康检查/日报保持一致""" - try: - from config.config import WECHAT_WEBHOOK - return WECHAT_WEBHOOK or os.getenv("WECHAT_WEBHOOK", "") - except Exception: - return os.getenv("WECHAT_WEBHOOK", "") - - -def _truncate(text: str, max_len: int = 200) -> str: - """截断过长内容""" - if not text: - return "" - text = str(text).strip() - if len(text) > max_len: - return text[:max_len] + "..." - return text - - -def _get_recent_conversation(customer_id: str, acc_id: str, last_n: int = 8) -> list: - """获取近期对话(同店铺),保持连贯上下文""" - try: - from db.chat_log_db import get_recent_conversation - return get_recent_conversation(customer_id, acc_id, limit=last_n) - except Exception: - logger.debug("[WechatChatLog] 获取近期对话失败,返回空列表", exc_info=True) - return [] - - -async def push_chat_to_wechat( - customer_name: str, - customer_id: str, - acc_id: str, - customer_msg: str, - reply_msg: str, - goods_name: str = "", -): - """ - 将客户消息与AI回复推送到企业微信群,附带近期对话保持连贯。 - """ - webhook = _get_webhook() - if not webhook: - return - # 去重:同一客户+店铺,若客户消息与回复完全相同且在窗口期内,则跳过 - try: - import time - key = (customer_id or "", acc_id or "") - now = time.time() - last = _last_push.get(key) - if last: - last_customer_msg, last_reply_msg, last_ts = last - if (last_customer_msg or "") == (customer_msg or "") and (last_reply_msg or "") == (reply_msg or ""): - if now - last_ts < 30: - return - _last_push[key] = ((customer_msg or ""), (reply_msg or ""), now) - except Exception: - logger.debug("[WechatChatLog] 去重检查异常,忽略本次去重", exc_info=True) - reply_msg = _truncate(reply_msg, 300) - ts = datetime.now().strftime("%H:%M") - shop = acc_id or "未知店铺" - name = (customer_name or customer_id or "客户")[:12] - - lines = [f"**📩 {ts} | {shop}**"] - if goods_name: - lines.append(f"**商品** {_truncate(goods_name, 80)}") - if customer_id: - lines.append(f"**客户ID** {customer_id}") - lines.append("") - - # 附带近期对话,保持连贯 - recent = _get_recent_conversation(customer_id, acc_id, last_n=8) - last_line = None - for m in recent: - role = customer_id if m.get("direction") == "in" else "客服" - msg = _truncate((m.get("message") or "").strip(), 120) - if msg: - line = f"{role}:{msg}" - # 防止日志中的重复记录在企微里连续刷屏 - if line == last_line: - continue - lines.append(line) - last_line = line - # 当前回复(可能已在 recent 中有客户消息,客服回复是新的) - lines.append(f"客服:{reply_msg or '(无回复)'}") - - content = "\n".join(lines) - enc = content.encode("utf-8") - if len(enc) > 3800: - content = enc[:3750].decode("utf-8", errors="ignore") + "\n...(略)" - try: - async with httpx.AsyncClient(timeout=8) as client: - resp = await client.post( - webhook, - json={"msgtype": "markdown", "markdown": {"content": content}}, - ) - data = resp.json() - if data.get("errcode") == 0: - return - else: - logger.warning("[WechatChatLog] 推送失败: %s", data) - except Exception as e: - logger.exception("[WechatChatLog] 推送异常: %s", e) - - -async def send_morning_startup(): - """每天早上8点发送客服启动消息到企微群""" - webhook = _get_webhook() - if not webhook: - return - ts = datetime.now().strftime("%Y-%m-%d %H:%M") - content = f"**☀️ 客服已启动**\n{ts}" - try: - async with httpx.AsyncClient(timeout=8) as client: - await client.post( - webhook, - json={"msgtype": "markdown", "markdown": {"content": content}}, - ) - logger.info("[WechatChatLog] 早8点启动消息已发送") - except Exception as e: - logger.exception("[WechatChatLog] 启动消息发送失败: %s", e) - - -async def morning_startup_scheduler(): - """每天 8:00 发送启动消息""" - logger.info("[WechatChatLog] 早8点启动消息定时任务已启动") - sent_today = None - while True: - now = datetime.now() - today = now.strftime("%Y-%m-%d") - if now.hour == 8 and now.minute == 0 and sent_today != today: - sent_today = today - await send_morning_startup() - await asyncio.sleep(30) diff --git a/legacy/workflow.py b/legacy/workflow.py deleted file mode 100644 index 15c3cdd..0000000 --- a/legacy/workflow.py +++ /dev/null @@ -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() - diff --git a/legacy/部署文档.md b/legacy/部署文档.md deleted file mode 100644 index 079e355..0000000 --- a/legacy/部署文档.md +++ /dev/null @@ -1,540 +0,0 @@ -# AI 客服系统 - 部署与运维文档 - -**版本**: v1.0 | **更新日期**: 2026-02-28 - ---- - -## 目录 - -1. [系统架构](#系统架构) -2. [快速部署](#快速部署) -3. [启动方式](#启动方式) -4. [生产环境部署](#生产环境部署) -5. [多进程架构](#多进程架构) -6. [API 接口文档](#api-接口文档) -7. [触发条件详解](#触发条件详解) -8. [数据库](#数据库) -9. [配置说明](#配置说明) -10. [监控与日志](#监控与日志) -11. [故障排查](#故障排查) - ---- - -## 系统架构 - -``` -┌─────────────┐ ┌──────────────┐ ┌─────────────┐ -│ 天网服务器 │ ───→ │ AI 客服 API │ ───→ │ 企业微信 │ -│ (公网 IP) │ │ (127.0.0.1:6060)│ │ (轻简软件) │ -└─────────────┘ └──────────────┘ └─────────────┘ - ↑ │ - └─────────────────────┘ - ┌──────────────┐ - │ SQLite │ - │ 任务数据库 │ - └──────────────┘ -``` - -### 核心组件 - -| 组件 | 地址 | 说明 | -|------|------|------| -| AI 客服 HTTP API | `http://127.0.0.1:6060` | 接收天网任务 | -| 天网服务器 | 公网 IP | 任务调度中心 | -| 轻简软件 | `ws://127.0.0.1:9528` | 企业微信连接 | -| 任务数据库 | SQLite 本地存储 | 任务持久化 | - ---- - -## 快速部署 - -### 步骤 1:环境检查 - -```bash -python3 --version # 需要 3.8+ -cd /root/ai_customer_service/ai_cs -pip3 install -r requirements.txt -``` - -### 步骤 2:启动服务 - -```bash -cd /root/ai_customer_service/ai_cs - -# 前台运行(测试用) -python3 run.py --api-only - -# 后台运行(生产用) -nohup python3 run.py --api-only > /tmp/tianwang.log 2>&1 & -``` - -### 步骤 3:验证 - -```bash -curl http://localhost:6060/api/health -# 预期: {"code":200,"data":{"service":"ai-cs-tianwang-bridge",...},"message":"OK"} -``` - ---- - -## 启动方式 - -统一入口 `run.py`,通过参数切换模式: - -```bash -# 仅 HTTP API(天网简化版,推荐) -python3 run.py --api-only - -# 完整版(HTTP API + WebSocket + AI Agent) -python3 run.py --tianwang - -# WebSocket 客服模式(默认) -python3 run.py - -# 多进程模式 -python3 run.py --multi --workers 4 - -# 不启用 AI Agent -python3 run.py --no-agent - -# 指定 HTTP 端口 -python3 run.py --api-only --port 8080 -``` - ---- - -## 生产环境部署 - -### 方式 1:systemd 服务(推荐) - -```bash -cat > /etc/systemd/system/ai-cs-tianwang.service << 'SERVICE' -[Unit] -Description=AI Customer Service with Tianwang -After=network.target - -[Service] -Type=simple -User=root -WorkingDirectory=/root/ai_customer_service/ai_cs -ExecStart=/usr/bin/python3 run.py --api-only -Restart=always -RestartSec=10 -LimitNOFILE=65535 -Environment="HTTP_API_PORT=6060" -StandardOutput=journal -StandardError=journal -SyslogIdentifier=ai-cs-tianwang - -[Install] -WantedBy=multi-user.target -SERVICE - -systemctl daemon-reload -systemctl enable ai-cs-tianwang -systemctl start ai-cs-tianwang -systemctl status ai-cs-tianwang -journalctl -u ai-cs-tianwang -f -``` - -### 方式 2:Docker 部署 - -```dockerfile -FROM python:3.11-slim -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -COPY . . -EXPOSE 6060 -CMD ["python3", "run.py", "--api-only"] -``` - -```bash -docker build -t ai-cs-tianwang . -docker run -d \ - --name ai-cs \ - -p 6060:6060 \ - -v /root/ai_customer_service/ai_cs/db:/app/db \ - --restart unless-stopped \ - ai-cs-tianwang -``` - -### 方式 3:后台运行(简单场景) - -```bash -nohup python3 run.py --api-only > /tmp/tianwang.log 2>&1 & -ps aux | grep "run.py" -tail -f /tmp/tianwang.log -pkill -f "run.py" # 停止 -``` - ---- - -## 多进程架构 - -### 架构说明 - -``` -单进程(默认) 多进程(可选) -┌─────────────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ -│ Python 进程 │ │进程 1 │ │进程 2 │ │进程 3 │ -│ asyncio Loop │ │客户 A,B │ │客户 C,D │ │客户 E,F │ -│ 所有客户 + Agent │ └─────────┘ └─────────┘ └─────────┘ -└─────────────────┘ -``` - -### 使用方法 - -```bash -# 多进程模式(默认 CPU 核心数) -python3 run.py --multi - -# 指定进程数 -python3 run.py --multi --workers 4 - -# 或使用专用启动器 -python3 scripts/multi_process_launcher.py --workers 4 -``` - -### 分片算法 - -客户按 `acc_id:from_id` 的 MD5 hash 值分配到不同进程,同一客户始终在同一进程。 - -### 性能对比 - -| 指标 | 单进程 | 多进程 (4 核) | -|------|--------|-------------| -| 并发客户数 | ~50 | ~200 | -| CPU 使用率 | 25% | 80% | -| 故障影响 | 全局 | 局部 | - ---- - -## API 接口文档 - -### 1. 接收任务 - -**POST** `/api/task/receive` - -```bash -curl -X POST http://localhost:6060/api/task/receive \ - -H "Content-Type: application/json" \ - -d '{ - "task_id": "TASK_20260227_001", - "type": "send_file_after_reply", - "customer": {"id": "customer_123", "name": "小明"}, - "trigger": { - "type": "specified_customer_reply", - "customer_id": "customer_123", - "customer_name": "小明", - "keyword": "好的", - "exact_match": false - }, - "action": {"type": "send_message", "message": "这是您要的文件"}, - "priority": "normal", - "timeout_hours": 24, - "created_by": "设计师 lz" - }' -``` - -**响应**: -```json -{"code": 200, "message": "任务接收成功", "data": {"task_id": "TASK_20260227_001", "status": "pending"}} -``` - -### 2. 查询任务状态 - -**GET** `/api/task/status/:task_id` - -```bash -curl http://localhost:6060/api/task/status/TASK_20260227_001 -``` - -### 3. 取消任务 - -**POST** `/api/task/cancel` - -```bash -curl -X POST http://localhost:6060/api/task/cancel \ - -H "Content-Type: application/json" \ - -d '{"task_id": "TASK_20260227_001", "reason": "客户取消订单"}' -``` - -### 4. 任务列表 - -**GET** `/api/task/list` - -参数: `customer_id`(可选)、`status`(可选)、`page`(默认1)、`page_size`(默认20) - -```bash -curl "http://localhost:6060/api/task/list?status=pending&page=1&page_size=10" -``` - -### 5. 健康检查 - -**GET** `/api/health` - -```bash -curl http://localhost:6060/api/health -``` - ---- - -## 触发条件详解 - -### 1. specified_customer_reply(推荐) - -指定客户回复指定内容时触发。 - -```json -{ - "trigger": { - "type": "specified_customer_reply", - "customer_id": "customer_123", - "customer_name": "小明", - "keyword": "好的", - "exact_match": false - } -} -``` - -| 字段 | 必填 | 说明 | -|------|------|------| -| `customer_id` | 是 | 指定客户 ID | -| `customer_name` | 否 | 指定客户名称 | -| `keyword` | 是 | 回复关键词 | -| `exact_match` | 否 | 是否精确匹配(默认 false)| - -**exact_match 说明**: -- `false`: 消息**包含**关键词即触发("好的谢谢" 匹配 "好的") -- `true`: 消息**完全等于**关键词才触发 - -**匹配逻辑**: -``` -客户发送消息 → 检查客户 ID → 检查客户名称(可选) → 检查关键词 → 触发 -``` - -### 2. customer_reply - -任意客户回复指定内容。 - -```json -{"trigger": {"type": "customer_reply", "keyword": "好的"}} -``` - -### 3. customer_keyword - -任意客户说某关键词(支持多个)。 - -```json -{"trigger": {"type": "customer_keyword", "keywords": ["好的", "可以", "行"]}} -``` - -### 4. customer_payment - -客户付款时触发。 - -```json -{"trigger": {"type": "customer_payment", "keywords": ["已付款", "拍下了"]}} -``` - -### 5. time_reach - -到达指定时间触发。 - -```json -{"trigger": {"type": "time_reach", "time": "2026-02-28 09:00:00"}} -``` - ---- - -## 数据库 - -### 天网任务数据库 - -路径: `db/task_db/tasks.db` - -```sql -CREATE TABLE tasks ( - task_id TEXT PRIMARY KEY, - specified_customer_id TEXT, - specified_customer_name TEXT, - type TEXT NOT NULL, - customer_name TEXT, - customer_id TEXT, - trigger_type TEXT, - trigger_keyword TEXT, - trigger_keywords TEXT, - action_type TEXT, - action_file_url TEXT, - action_message TEXT, - priority TEXT DEFAULT 'normal', - timeout_hours INTEGER DEFAULT 24, - status TEXT DEFAULT 'pending', - retry_count INTEGER DEFAULT 0, - max_retry INTEGER DEFAULT 3, - created_at TEXT, - created_by TEXT, - triggered_at TEXT, - completed_at TEXT, - error_message TEXT, - result TEXT -); -``` - -**任务状态流转**: `pending → waiting → running → completed / failed` - -### 图片任务数据库 - -路径: `db/image_tasks.db`(详见 **项目功能汇总.md - 图片任务数据库**) - ---- - -## 配置说明 - -### 环境变量 - -文件: `.env.tianwang` - -```bash -AI_CS_HOST=127.0.0.1 -AI_CS_PORT=6060 -AI_CS_API_URL=http://127.0.0.1:6060 -TIANWANG_CALLBACK_URL=http://127.0.0.1:6060/api/task/callback -``` - -### 天网回调配置 - -在 `core/task_scheduler.py` 中修改回调 URL: - -```python -await client.post('http://tianwang-server/api/task/callback', json={...}) -``` - -### 端口说明 - -| 端口 | 用途 | -|------|------| -| 6060 | HTTP API 服务器 | -| 9528 | 轻简软件 WebSocket(外部)| - -**防火墙**: -```bash -firewall-cmd --add-port=6060/tcp --permanent && firewall-cmd --reload -``` - ---- - -## 监控与日志 - -### 查看进程状态 - -```bash -ps aux | grep "run.py" -netstat -tlnp | grep 6060 -systemctl status ai-cs-tianwang # systemd 方式 -``` - -### 查看日志 - -```bash -tail -f /tmp/tianwang.log # 文件方式 -journalctl -u ai-cs-tianwang -f # systemd 方式 -grep "任务" /tmp/tianwang.log # 搜索任务日志 -grep "派单" /tmp/tianwang.log # 搜索派单日志 -grep "转接人工" /tmp/tianwang.log # 搜索转接日志 -``` - -### 查看数据库 - -```bash -sqlite3 /root/ai_customer_service/ai_cs/db/task_db/tasks.db - -SELECT task_id, type, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 10; -SELECT * FROM tasks WHERE status='pending'; -SELECT task_id, error_message FROM tasks WHERE status='failed'; -SELECT status, COUNT(*) as count FROM tasks GROUP BY status; -.exit -``` - ---- - -## 故障排查 - -### API 无法访问 - -```bash -ps aux | grep "run.py" # 检查进程 -netstat -tlnp | grep 6060 # 检查端口 -pkill -f "run.py" # 停止 -nohup python3 run.py --api-only > /tmp/tianwang.log 2>&1 & -tail -f /tmp/tianwang.log # 查看日志 -``` - -### 任务接收失败(500 错误) - -```bash -tail -f /tmp/tianwang.log | grep "ERROR" -sqlite3 db/task_db/tasks.db ".schema tasks" # 检查数据库 -# 如果数据库损坏:rm db/task_db/tasks.db 然后重启(自动重建) -``` - -### 任务未触发 - -```bash -curl http://localhost:6060/api/task/status/TASK_ID # 检查状态 -grep "任务触发" /tmp/tianwang.log # 查看触发日志 -# 确认客户消息包含触发关键词 -``` - -### 内存占用过高 - -```bash -ps aux | grep run_tianwang | awk '{print $6/1024 " MB"}' -# 建议每天定时重启 -crontab -e -# 添加: 0 3 * * * pkill -f "run.py" && sleep 2 && nohup python3 /root/ai_customer_service/ai_cs/run.py --api-only > /tmp/tianwang.log 2>&1 & -``` - -### Worker 进程退出(多进程模式) - -```bash -journalctl -u ai-cs-multi -f | grep "Worker.*退出" -systemctl restart ai-cs-multi -``` - ---- - -## 文件位置速查 - -| 文件 | 路径 | -|------|------| -| 启动脚本 | `run.py`(通过 `--api-only` / `--tianwang` 切换模式)| -| HTTP API | `api/http_server.py` | -| 任务调度 | `core/task_scheduler.py` | -| 数据模型 | `db/task_db/task_model.py` | -| 配置文件 | `.env.tianwang` | -| 日志文件 | `/tmp/tianwang.log` | -| 任务数据库 | `db/task_db/tasks.db` | - ---- - -## 快速参考 - -``` -┌─────────────────────────────────────────────┐ -│ AI 客服 API - 快速参考 │ -├─────────────────────────────────────────────┤ -│ 地址:http://localhost:6060 │ -│ │ -│ POST /api/task/receive - 接收任务 │ -│ GET /api/task/status/:id - 查询状态 │ -│ POST /api/task/cancel - 取消任务 │ -│ GET /api/task/list - 任务列表 │ -│ GET /api/health - 健康检查 │ -│ │ -│ 启动:python3 run.py --api-only │ -│ 日志:tail -f /tmp/tianwang.log │ -│ 数据库:sqlite3 db/task_db/tasks.db │ -└─────────────────────────────────────────────┘ -``` diff --git a/legacy/项目功能汇总.md b/legacy/项目功能汇总.md deleted file mode 100644 index 29bf794..0000000 --- a/legacy/项目功能汇总.md +++ /dev/null @@ -1,476 +0,0 @@ -# AI 客服系统 - 完整功能汇总 - -**版本**: v1.0 | **更新日期**: 2026-02-28 | **服务器**: 1.12.50.92 - ---- - -## 目录 - -1. [天网协作系统](#天网协作系统) -2. [三种工作流](#三种工作流) -3. [文字检测与加价](#文字检测与加价) -4. [风险评估与接单判断](#风险评估与接单判断) -5. [作图失败转接人工](#作图失败转接人工) -6. [图片任务数据库](#图片任务数据库) -7. [图绘派单系统](#图绘派单系统) -8. [价格策略总览](#价格策略总览) -9. [技术架构](#技术架构) - ---- - -## 天网协作系统 - -**说明**: 接收天网下发的任务,支持指定客户回复触发。 - -**API 地址**: `http://127.0.0.1:6060` - -**接口列表**: - -| 接口 | 方法 | 说明 | -|------|------|------| -| `/api/task/receive` | POST | 接收任务 | -| `/api/task/status/:id` | GET | 查询任务状态 | -| `/api/task/cancel` | POST | 取消任务 | -| `/api/task/list` | GET | 任务列表 | -| `/api/health` | GET | 健康检查 | - -**触发类型**: - -| 类型 | 说明 | -|------|------| -| `specified_customer_reply` | 指定客户回复指定内容(推荐) | -| `customer_reply` | 任意客户回复指定内容 | -| `customer_keyword` | 任意客户说某关键词 | -| `customer_payment` | 客户付款 | -| `time_reach` | 到达指定时间 | - -> 详细的 API 接口文档、请求示例、数据库结构等见 **部署文档.md**。 - ---- - -## 三种工作流 - -根据客户说的话,自动判断执行不同的工作流程。 - -### 工作流 1:查找图片 - -**触发词**: "找一下"、"找图"、"找原图"、"帮我找"、"能找到吗"、"有吗"、"有没有" - -``` -客户:找一下这个图 [图片] - ↓ -AI 检测到"找一下"关键词 → 执行查找图片工作流 - ↓ -1. 创建任务(operation=find) -2. 上传图片到图绘平台 -3. 更新任务状态为 completed - ↓ -AI: 找到了!图片在这里:http://tuhui.cloud/works/123 -``` - -### 工作流 2:处理图片 - -**触发词**: "做一下"、"处理一下"、"安排"、"开始做"、"弄一下"、"修一下"、"P一下"、"P图" - -``` -客户:做一下 [图片] - ↓ -AI 检测到"做一下"关键词 → 执行处理图片工作流 - ↓ -1. 创建任务(operation=enhance) -2. 回复"稍等,我看看...好的,可以做,马上处理" -3. 启动图片处理流程 - ↓ -AI: 做好了,请查看 [结果图] -``` - -### 工作流 3:转人工派单 - -**触发词**: "做不了"、"处理不了"、"弄不了"、"无法处理"、"做不到"、"搞不定" - -``` -AI 判断无法处理 / 客户说"做不了" - ↓ -执行转人工派单工作流 - ↓ -1. 创建任务(operation=manual) -2. 查询在线设计师 -3. 有人在线 → 派单;无人 → 通知稍后联系 - ↓ -AI: 好的,已帮您安排设计师处理,请稍候 -``` - -### 技术实现 - -| 组件 | 文件 | 说明 | -|------|------|------| -| 工作流路由器 | `core/workflow_router.py` | 关键词检测与匹配 | -| 工作流执行器 | `core/workflow.py` | 三种工作流的具体实现 | -| 消息处理器 | `core/pydantic_ai_agent.py` | `_handle_image_workflow()` 方法 | - -**注意事项**: -- 关键词匹配支持多种说法,自动识别 -- 置信度 >0.9 才执行对应工作流 -- 无人在线时通知客户稍后联系,企业微信预警 -- 所有工作流都保存到数据库 - ---- - -## 文字检测与加价 - -AI 客服自动分析图片中的文字数量,根据文字数量和分层需求自动加价。 - -### 文字数量加价 - -| 文字数量 | 加价 | -|----------|------| -| none | +0 元 | -| 少量 (1-10 字) | +5 元 | -| 中量 (11-50 字) | +15 元 | -| 大量 (51-200 字) | +30 元 | -| 极多 (200 字以上) | +50 元 | - -### 文字分层需求加价 - -| 分层需求 | 加价 | -|----------|------| -| no | +0 元 | -| yes(有文字)| +50 元起 | -| yes(无文字)| +30 元 | - -### 特殊价格 - -**条件**: 文字数量=大量/极多 且 分层需求=yes → **60-80 元** - -### 使用场景 - -**场景 1**: 少量文字,不分层 -- 复杂度 simple + 少量文字 → 15 + 5 = **20 元** -- AI: "这张图比较简单,不过有少量文字需要处理,20 元。" - -**场景 2**: 大量文字,需要分层 -- 复杂度 complex + 大量文字 + 分层 → 调整到 **80 元** -- AI: "这张图文字比较多,有 100 多字,需要分层文件,80 元。" - -### 价格计算流程 - -``` -客户发送图片 → 判断基础复杂度 → 检测文字数量 → 询问分层需求 - → 计算总价(基础+文字+分层)→ 特殊价格处理(60-80 元)→ 报价 -``` - -### 配置位置 - -修改价格规则: `image/image_analyzer.py`(查找文字加价相关代码) - -**注意事项**: -- 文字数量通过视觉 AI 自动识别 -- 分层需求需从对话中识别 -- 最终价格必须是 5 的倍数 - ---- - -## 风险评估与接单判断 - -AI 客服自动分析图片风险,判断是否可以接单。 - -### 敏感内容检测(一票否决) - -**敏感内容 = yes → 直接拒绝,不接单** - -检测内容: 色情/黄色/擦边/裸露、性暗示、涉政/政治敏感、暴力/血腥、违禁品 - -**话术**: "这类不做哦" / "不好意思,这个接不了" - -**禁止说**: "发图来看看"、过多解释 - -### 风险等级 - -| 风险 | 是否接单 | 说明 | -|------|----------|------| -| **none** | ✅ 接单 | 印花/图案/logo/风景/产品,效果稳定 | -| **low** | ✅ 接单 | 有人脸但清晰,需说明风险(相似度 70-90%)| -| **high** | ⚠️ 谨慎 | 严重模糊/老照片人像/需打印,需说明限制 | - -### 可做判断 - -| 可做 | 是否接单 | 说明 | -|------|----------|------| -| **yes** | ✅ 接单 | 效果有把握 | -| **partial** | ⚠️ 可接 | 能处理但有限制,需说明风险 | -| **no** | ❌ 不接 | 无法处理(纯黑/纯白/完全损坏/敏感内容)| - -### 分析流程 - -``` -客户发送图片 → 敏感内容检测(yes→拒绝)→ 风险评估(none/low/high) - → 可做判断(yes/partial/no)→ 决策(接单/谨慎/拒绝)→ 回复客户 -``` - -### 话术模板 - -**高风险提示**: -- "这张比较模糊,修复后清晰了但人脸可能跟原来有差异" -- "老照片修复后人脸可能有轻微变化" -- "建议先看效果确认再打印" - -**正常接单**: -- "这个没问题,XX 元" -- "可以处理,XX 元,满意再付" - -### 配置位置 - -- 风险判断规则: `image/image_analyzer.py`(查找"风险评估""敏感内容检测") -- 拒绝话术: `core/pydantic_ai_agent.py`(查找"拒绝") - ---- - -## 作图失败转接人工 - -当 AI 作图失败或效果不佳时,系统自动转接人工客服。 - -### 触发场景 - -| 场景 | 触发条件 | 话术 | -|------|----------|------| -| AI 作图失败 | API 报错/超时/质量不达标 | "处理遇到点问题,我帮您转接人工" | -| 客户不满意 | 说"效果不好"/"不满意"/要求重做 | "好的,我帮您转接人工客服处理" | -| 特殊要求 | AI 无法处理的复杂需求 | "这个需求比较特殊,帮您转接人工" | - -### 转接流程 - -``` -作图失败/客户不满意 → 通知客户 → 转接人工客服 → 企业微信预警 -``` - -### 技术实现 - -- 失败检测: `core/pydantic_ai_agent.py` 中的 `process_image_gemini` 函数 -- 转接工具: `transfer_to_human` tool(标记 `need_transfer=True`) - -**注意事项**: -- 作图失败必须转人工,不自动重试超过 2 次 -- 转接前告知客户原因 -- 记录转接原因便于后续优化 - ---- - -## 图片任务数据库 - -图片任务保存到 SQLite 数据库,支持持久化和需求变更。 - -### 数据库表 - -**image_tasks(图片任务表)**: - -| 字段 | 类型 | 说明 | -|------|------|------| -| task_id | TEXT | 任务 ID(主键)| -| customer_id | TEXT | 客户 ID | -| original_image | TEXT | 原图 URL | -| operation | TEXT | 操作类型(enhance/remove_bg/vectorize)| -| requirements | TEXT | 需求 JSON | -| customer_notes | TEXT | 客户备注/需求细节 | -| status | TEXT | 状态 | -| result_image | TEXT | 结果图 URL | -| error_message | TEXT | 错误信息 | -| retry_count | INTEGER | 重试次数 | -| acc_id / acc_type | TEXT | 店铺 ID / 平台类型 | -| created_at / paid_at / completed_at | TEXT | 时间戳 | - -**task_requirement_changes(需求变更表)**: - -| 字段 | 类型 | 说明 | -|------|------|------| -| task_id | TEXT | 任务 ID(外键)| -| change_type | TEXT | 变更类型(add_note/modify_operation/add_requirement)| -| old_value / new_value | TEXT | 变更前后值 | -| changed_at | TEXT | 变更时间 | -| changed_by | TEXT | 变更者(customer/staff)| - -### 任务状态流转 - -``` -pending(待付款)→ paid(已付款)→ processing(处理中)→ awaiting_confirm(待确认)→ completed(已完成) - ↘ failed(失败) -``` - -### API 接口 - -```python -# 创建任务 -workflow.create_image_task(customer_id, original_image, operation) - -# 添加需求 -await workflow.add_customer_requirement(task_id, customer_id, requirement) - -# 修改操作类型 -await workflow.modify_operation(task_id, customer_id, new_operation) - -# 查询任务 -task = workflow.get_task(task_id) -tasks = workflow.get_customer_tasks(customer_id) - -# 查询需求变更历史 -history = workflow.get_task_requirement_history(task_id) -``` - -### 数据库操作 - -```bash -sqlite3 /root/ai_customer_service/ai_cs/db/image_tasks.db - -# 查询所有任务 -SELECT task_id, customer_id, status, created_at FROM image_tasks ORDER BY created_at DESC LIMIT 10; - -# 查询待处理任务 -SELECT * FROM image_tasks WHERE status='pending'; - -# 查询需求变更 -SELECT task_id, change_type, old_value, new_value, changed_at FROM task_requirement_changes WHERE task_id='TASK_001'; -``` - -**注意事项**: -- 所有任务自动保存,重启不丢失 -- 付款前可修改操作类型,付款后不允许 -- 所有变更都有历史记录 - ---- - -## 图绘派单系统 - -AI 客服系统接入图绘派单系统 API,实现自动派单给在线设计师。 - -### API 信息 - -| 项目 | 值 | -|------|------| -| API 地址 | `http://1.12.50.92:8005` | -| API Key | `tuhui_dispatch_key_2026` | -| 认证方式 | Header: `X-API-Key` | - -### 核心接口 - -| 接口 | 方法 | 说明 | -|------|------|------| -| `/dispatch/queue` | GET | 获取派单队列 | -| `/online/designers` | GET | 获取在线设计师 | -| `/tasks` | POST | 创建任务 | -| `/tasks/{id}/assign` | POST | 分配任务 | -| `/tasks/{id}` | GET | 查询任务状态 | -| `/tasks/{id}/complete` | POST | 完成任务 | - -### 转人工派单流程 - -``` -AI 判断做不了 - ↓ -1. 查询在线设计师 → GET /online/designers → ["橘子", "婷婷"] - ↓ -2. 创建派单任务 → POST /tasks → {"task_id": "ea853bd9"} - ↓ -3. 分配给设计师 → POST /tasks/ea853bd9/assign → {"designer_name": "橘子"} - ↓ -4. 企业微信通知设计师 - ↓ -5. 回复客户:"好的,已帮您安排设计师处理,请稍候" -``` - -### 代码调用示例 - -```python -from services.service_tuhui_dispatch import get_tuhui_dispatch_client - -client = get_tuhui_dispatch_client() - -# 查询在线设计师 -designers = await client.get_online_designers() # ["橘子", "婷婷"] - -# 创建任务 -task_id = await client.create_task( - task_name="图片处理-1234", - description="客户需要做高清修复", - task_type="image_process", - priority=2 -) - -# 分配任务 -await client.assign_task(task_id, designer_name="橘子", notes="AI 客服自动派单") - -# 完成任务 -await client.complete_task(task_id, notes="客户已确认") -``` - -### 设计师在线状态 API - -``` -GET http://huichang.online:8001/online # 查询在线设计师 -POST http://huichang.online:8001/update-status # 更新设计师状态 -``` - -### 相关代码位置 - -| 组件 | 文件 | -|------|------| -| 派单客户端 | `services/service_tuhui_dispatch.py`(`TuhuiDispatchClient` 类)| -| 工作流集成 | `core/workflow.py`(`transfer_to_designer_workflow()` 方法)| - ---- - -## 价格策略总览 - -### 基础价格 - -| 复杂度 | 价格区间 | 说明 | -|--------|----------|------| -| simple | 10-15 元 | 画面简单干净 | -| normal | 15-20 元 | 一般复杂度 | -| complex | 20-25 元 | 细节偏多 | -| hard | 25-30 元 | 非常复杂 | - -### 加价规则 - -| 项目 | 条件 | 加价 | -|------|------|------| -| 文字少量 | 1-10 字 | +5 元 | -| 文字中量 | 11-50 字 | +15 元 | -| 文字大量 | 51-200 字 | +30 元 | -| 文字极多 | 200+ 字 | +50 元 | -| 分层(有文字)| 需要 PSD 分层 | +50 元起 | -| 分层(无文字)| 仅需分层 | +30 元 | - -### 高价值订单 - -**文字分层 + 大量文字** → 特殊价格 **60-80 元**(封顶) - ---- - -## 技术架构 - -### 核心组件 - -| 组件 | 文件 | 说明 | -|------|------|------| -| 天网协作 | `api/http_server.py` | HTTP API 服务器 | -| 工作流程 | `core/workflow.py` | 工作流执行器 | -| AI Agent | `core/pydantic_ai_agent.py` | AI 对话引擎 | -| 图片分析 | `image/image_analyzer.py` | 图片复杂度识别 | -| 派单客户端 | `services/service_tuhui_dispatch.py` | 图绘派单 API | -| 任务数据库 | `db/image_tasks_db.py` | 任务持久化 | - -### 数据库 - -| 数据库 | 位置 | 说明 | -|--------|------|------| -| 任务数据库 | `db/image_tasks.db` | 图片任务 | -| 客户档案 | `db/customer.db` | 客户画像 | -| 聊天记录 | `chat_log_db/chat_log.db` | 聊天历史 | -| 天网任务 | `db/task_db/tasks.db` | 天网任务调度 | - -### API 端口 - -| 服务 | 端口 | 说明 | -|------|------|------| -| AI 客服 API | 6060 | 天网任务接收 | -| 派单系统 | 8005 | 设计师派单 | -| 图绘平台 | 8002 | 图片上传 | diff --git a/requirements.txt b/requirements.txt index 4b20c0c..62d31d9 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ flask>=3.0.0 websockets>=12.0 -pydantic-ai>=0.0.20 +pydantic-ai>=0.0.20,<2.0.0 pydantic>=2.0.0 python-dotenv>=1.0.0 Pillow>=10.0.0 diff --git a/run.py b/run.py index 5e1ee70..7e849fe 100755 --- a/run.py +++ b/run.py @@ -23,11 +23,9 @@ _root = Path(__file__).resolve().parent if str(_root) not in sys.path: sys.path.insert(0, str(_root)) -logging.basicConfig( - level=logging.INFO, - format='[%(asctime)s] %(levelname)s: %(message)s' -) -logger = logging.getLogger(__name__) +from core.websocket_logger_setup import setup_logger + +logger = setup_logger() DEFAULT_HTTP_PORT = 6060 DEFAULT_HTTP_HOST = "127.0.0.1" @@ -65,7 +63,7 @@ def run_tianwang(enable_agent: bool, host: str, port: int): """完整版: HTTP API + WebSocket + AI Agent""" import asyncio from api.http_server import start_http_server - from core.websocket_client import QingjianAPIClient + from core.websocket_client_v2 import QingjianAPIClient logger.info("=" * 60) logger.info("AI 客服系统 - 天网协作版(完整)") diff --git a/image/__init__.py b/scripts/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from image/__init__.py rename to scripts/__init__.py diff --git a/scripts/check_garbled.py b/scripts/check_garbled.py new file mode 100644 index 0000000..a24204f --- /dev/null +++ b/scripts/check_garbled.py @@ -0,0 +1,42 @@ +import pymysql +import re + +conn = pymysql.connect( + host='1.12.50.92', + port=3306, + user='ai_cs_user', + password='Zuowei1216', + database='ai_cs', + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor +) +cur = conn.cursor() + +# 查昨晚18:00到今早的AI回复 +cur.execute(""" + SELECT customer_name, message, timestamp FROM chat_logs + WHERE timestamp >= '2026-03-05 18:00:00' AND timestamp <= '2026-03-06 09:00:00' + AND direction = 'out' + ORDER BY timestamp +""") +rows = cur.fetchall() + +print(f"共 {len(rows)} 条AI回复,检查乱码...") + +for r in rows: + msg = r['message'] or '' + # 检查是否有乱码特征 + has_code = any([ + re.search(r'\[\]<\|', msg), + re.search(r'<\|[A-Za-z]+\|>', msg), + 'Function' in msg, + '```' in msg, + re.search(r'\{["\']', msg), # JSON格式 + re.search(r'\\n', msg), # 转义字符 + ]) + if has_code: + print(f"\n[{r['timestamp']}] {r['customer_name']}:") + print(f" {msg[:150]}") + +conn.close() +print("\n检查完成") diff --git a/legacy/scripts/multi_process_launcher.py b/scripts/multi_process_launcher.py similarity index 94% rename from legacy/scripts/multi_process_launcher.py rename to scripts/multi_process_launcher.py index 1e6688f..93cab6e 100644 --- a/legacy/scripts/multi_process_launcher.py +++ b/scripts/multi_process_launcher.py @@ -50,17 +50,18 @@ class WorkerProcess: os.environ['AI_CS_SHARD_KEYS'] = ','.join(shard_keys) # 导入并启动 WebSocket 客户端 - from core.websocket_client import QingjianAPIClient + from core.websocket_client_v2 import QingjianAPIClient - logger.info(f"Worker {worker_id} 初始化 Agent...") - client = QingjianAPIClient(enable_agent=enable_agent) - - # 只处理分配给这个 worker 的客户 - client.shard_keys = set(shard_keys) + logger.info(f"Worker {worker_id} 初始化 Client (分片 {worker_id}/{num_workers})...") + client = QingjianAPIClient( + enable_agent=enable_agent, + worker_id=worker_id, + worker_count=num_workers + ) logger.info(f"Worker {worker_id} 开始处理消息...") import asyncio - asyncio.run(client.connect()) + asyncio.run(client.run()) except KeyboardInterrupt: logger.info(f"Worker {worker_id} 收到退出信号") diff --git a/services/dispatch_service.py b/services/dispatch_service.py index 06f706b..542e8f6 100644 --- a/services/dispatch_service.py +++ b/services/dispatch_service.py @@ -1,7 +1,8 @@ import os import logging import httpx -from typing import Optional, Dict, Any +import asyncio +from typing import Optional logger = logging.getLogger("cs_agent") @@ -11,38 +12,63 @@ class DispatchService: """ def __init__(self): - # 严格按照老大提供的最新对齐信息 - self.base_url = "http://1.12.50.92:8006" - self.api_key = "tuhui_dispatch_key_2026" - self.timeout = 10.0 + self.base_url = os.getenv("DISPATCH_BASE_URL", "http://1.12.50.92:8006").rstrip("/") + self.api_key = os.getenv("DISPATCH_API_KEY", "tuhui_dispatch_key_2026") + self.timeout = float(os.getenv("DISPATCH_TIMEOUT_SECONDS", "10")) + self.max_retries = int(os.getenv("DISPATCH_MAX_RETRIES", "3")) - async def assign_designer(self) -> Optional[str]: + @staticmethod + def _clip_text(text: str, limit: int = 500) -> str: + if text is None: + return "" + if len(text) <= limit: + return text + return f"{text[:limit]}...(截断, 共{len(text)}字)" + + async def assign_designer(self, user_id: str = "") -> Optional[str]: """ - 调用 /assign 接口,一键获取当前可用的设计师名字 + 调用 /assign 接口,一键获取当前可用持有的设计师名字 """ url = f"{self.base_url}/assign" - # 严格使用 X-API-Key 请求头和带下划线的 Key headers = {"X-API-Key": self.api_key} - - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.get(url, headers=headers) - - if response.status_code == 200: - data = response.json() - if data.get("success"): - designer = data.get("assigned_to") - logger.info(f"[Dispatch] 派单成功!设计师: {designer}") - return designer - else: - logger.warning(f"[Dispatch] 派单被拒: {data.get('reason')}") - elif response.status_code == 401: - logger.error(f"[Dispatch] 授权失败(401)!请检查 Key 是否为: {self.api_key}") - else: - logger.error(f"[Dispatch] API 异常,状态码: {response.status_code}") - except Exception as e: - logger.error(f"[Dispatch] 网络请求崩溃: {e}") - + + u_tag = f" user={user_id}" if user_id else "" + logger.info(f"[Dispatch]{u_tag} 请求开始: GET {url}") + async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client: + for attempt in range(1, self.max_retries + 1): + try: + response = await client.get(url, headers=headers) + body = self._clip_text(response.text) + + if response.status_code == 200: + try: + data = response.json() + except Exception: + logger.error(f"[Dispatch]{u_tag} 200但响应非JSON: {body}") + return None + + if data.get("success"): + designer = data.get("assigned_to") + logger.info(f"[Dispatch]{u_tag} 派单成功!设计师: {designer}") + return designer + + logger.warning(f"[Dispatch]{u_tag} 派单被拒: {data.get('reason')} body={body}") + return None + + if response.status_code == 401: + logger.error(f"[Dispatch]{u_tag} 授权失败(401),请检查 DISPATCH_API_KEY。Body={body}") + return None + + logger.error(f"[Dispatch]{u_tag} API 异常,状态码: {response.status_code} body={body}") + if response.status_code < 500: + return None + + except Exception as e: + logger.error(f"[Dispatch]{u_tag} 网络请求失败 attempt={attempt}/{self.max_retries}: {e}") + + if attempt < self.max_retries: + await asyncio.sleep(0.8 * attempt) + return None # 全局单例 diff --git a/services/service_image_analyzer.py b/services/service_image_analyzer.py new file mode 100644 index 0000000..73f5e34 --- /dev/null +++ b/services/service_image_analyzer.py @@ -0,0 +1,319 @@ +""" +图片分析服务 - 后台静默分析图片,用于数据标定 +使用智谱 GLM-4V 视觉模型分析客户发来的图片 +""" +import os +import asyncio +import base64 +import time +import json +import logging +from typing import Optional, Dict, Any +from openai import AsyncOpenAI +from dotenv import load_dotenv +import aiohttp +from PIL import Image +from io import BytesIO + +load_dotenv() +logger = logging.getLogger("cs_agent") + + +ANALYSIS_PROMPT = """你是一个电商图片处理评估专家。 +请仔细分析这张图片,输出以下字段,每行一个,不要多余内容: + +敏感内容: +平整度: +含文字: +文字量: +含人脸: +人脸清晰度: <无|清晰|模糊|遮挡> +阴影: +复杂度: +原因: <15字以内,说明复杂度判断依据> +主体: <图片核心内容,如:印花图案/logo/人物/产品/老照片/风景/文字/其他> +类型: <处理类型,如:印花提取/高清修复/去背景/老照片修复/logo提取/人像修复/其他> +质量: <原图质量,如:清晰/轻微模糊/严重模糊/低分辨率/截图/扫描件> +分辨率评估: <足够|偏低|严重不足> +色彩: <单色|双色|多色|渐变|全彩> +主色调: <白/黑/红/蓝/绿/黄/棕/灰/多彩等> +细节层级: <简约|中等|精细|极精细> +边缘清晰度: <清晰|模糊|毛糙> +背景: <纯色|简单|复杂|透明> +可做: +风险: +透视: +比例: <从以下选一个最合适的:1:1 / 9:16 / 16:9 / 3:4 / 4:3 / 3:2 / 2:3 / 5:4 / 4:5> +预估工时: <5分钟/15分钟/30分钟/1小时/2小时以上> +难点: <具体说明处理难点,如:细节多/透视矫正/文字提取等,15字内> +建议方案: +提示词: <为 Gemini 写处理指令,中文,80字以内,要详细具体> +备注: <给客服AI的特别提示,没有则填无> + +判断规则: +- 平整度 flat:画面平整、无褶皱、无透视 → 便宜 +- 平整度 mild:轻微褶皱/透视 → 中等 +- 平整度 rough:有褶皱/透视/曲面 → 贵 +- 含文字 yes:有小字要精细保留 +- 含人脸 yes:有人脸需处理 → 加价 +- 敏感内容=yes 时,可做必须填 no +- 细节层级影响工时和价格 +""" + + +class ImageAnalyzerService: + """图片分析服务 - 后台静默运行,不影响主流程""" + + _CACHE_TTL_SECONDS = 300 + _analysis_cache: Dict[str, tuple] = {} + + PRICE_MAP = { + "simple": (10, 15), + "normal": (15, 20), + "complex": (20, 25), + "hard": (25, 30), + } + + def __init__(self): + self.api_key = os.getenv("OPENAI_API_KEY") + self.base_url = os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4") + self.vision_model = os.getenv("VISION_MODEL", "glm-4v-flash") + + def _is_url(self, path: str) -> bool: + return path.startswith("http://") or path.startswith("https://") + + async def _get_image_size(self, image_path: str) -> tuple: + """获取图片尺寸""" + try: + if self._is_url(image_path): + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(image_path) as resp: + if resp.status != 200: + return (0, 0) + data = await resp.read() + with Image.open(BytesIO(data)) as img: + return img.size + else: + with Image.open(image_path) as img: + return img.size + except Exception as e: + logger.debug(f"[ImageAnalyzer] 获取尺寸失败: {e}") + return (0, 0) + + async def analyze(self, image_url: str) -> Dict[str, Any]: + """ + 异步分析图片,返回结构化结果 + + Returns: + { + "url": 图片URL, + "complexity": simple|normal|complex|hard, + "subject": 主体描述, + "proc_type": 处理类型, + "quality": 质量评估, + "flatness": flat|mild|rough, + "has_text": yes|no, + "has_face": yes|no, + "has_shadow": yes|no, + "risk": none|low|high, + "feasibility": yes|partial|no, + "perspective": no|mild|strong, + "aspect_ratio": 比例, + "gemini_prompt": 处理提示词, + "note": 备注, + "price_suggest": 建议价格, + "width": 宽度, + "height": 高度, + "analyzed_at": 分析时间, + "success": True/False + } + """ + if not self.api_key: + return self._fallback(image_url, "未配置 API Key") + + # 缓存检查 + cache_key = image_url + now = time.monotonic() + cached = self._analysis_cache.get(cache_key) + if cached: + result, cached_at = cached + if now - cached_at < self._CACHE_TTL_SECONDS: + logger.debug(f"[ImageAnalyzer] 缓存命中: {image_url[:50]}...") + return dict(result) + else: + del self._analysis_cache[cache_key] + + start = time.monotonic() + + try: + client = AsyncOpenAI(base_url=self.base_url, api_key=self.api_key) + + response = await asyncio.wait_for( + client.chat.completions.create( + model=self.vision_model, + messages=[{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": image_url}}, + {"type": "text", "text": ANALYSIS_PROMPT} + ] + }], + max_tokens=500 + ), + timeout=30 + ) + + content = response.choices[0].message.content + elapsed = time.monotonic() - start + + result = self._parse_result(image_url, content) + result["elapsed"] = round(elapsed, 2) + + # 获取尺寸 + w, h = await self._get_image_size(image_url) + result["width"] = w + result["height"] = h + + # 缓存 + self._analysis_cache[cache_key] = (result, now) + + # 详细日志 + log_parts = [ + f"主体={result.get('subject', '?')}", + f"类型={result.get('proc_type', '?')}", + f"复杂度={result.get('complexity', '?')}", + f"色彩={result.get('color', '?')}", + f"细节={result.get('detail_level', '?')}", + f"工时={result.get('est_time', '?')}", + f"方案={result.get('suggest_method', '?')}", + f"¥{result.get('price_suggest', 0)}", + ] + logger.info(f"[ImageAnalyzer] 分析完成: {' | '.join(log_parts)}") + if result.get('difficulty'): + logger.info(f"[ImageAnalyzer] 难点: {result.get('difficulty')}") + if result.get('gemini_prompt'): + logger.info(f"[ImageAnalyzer] Gemini提示词: {result.get('gemini_prompt')}") + return result + + except asyncio.TimeoutError: + logger.warning(f"[ImageAnalyzer] 分析超时: {image_url[:50]}...") + return self._fallback(image_url, "分析超时") + except Exception as e: + logger.warning(f"[ImageAnalyzer] 分析失败: {e}") + return self._fallback(image_url, str(e)) + + def _parse_result(self, url: str, content: str) -> Dict[str, Any]: + """解析视觉模型返回的文本""" + import re + from datetime import datetime + + def extract(key: str, default: str = "") -> str: + pattern = rf"{key}:\s*(.+)" + match = re.search(pattern, content, re.IGNORECASE) + return match.group(1).strip() if match else default + + complexity = extract("复杂度", "normal").lower() + if complexity not in ("simple", "normal", "complex", "hard"): + complexity = "normal" + + price_min, price_max = self.PRICE_MAP.get(complexity, (15, 20)) + price_suggest = round((price_min + price_max) / 2 / 5) * 5 + + # 文字加价 + has_text = extract("含文字", "no").lower() + if has_text in ("yes", "partial") and complexity in ("simple", "normal"): + price_suggest += 5 + + # 人脸加价 + has_face = extract("含人脸", "no").lower() + if has_face == "yes": + price_suggest += 5 + + # 精细度加价 + detail_level = extract("细节层级", "中等") + if detail_level == "极精细": + price_suggest += 10 + elif detail_level == "精细": + price_suggest += 5 + + return { + "url": url, + "complexity": complexity, + "reason": extract("原因"), + "subject": extract("主体"), + "proc_type": extract("类型"), + "quality": extract("质量"), + "resolution": extract("分辨率评估", "足够"), + "flatness": extract("平整度", "mild").lower(), + "has_text": has_text, + "text_amount": extract("文字量", "none"), + "has_face": has_face, + "face_clarity": extract("人脸清晰度", "无"), + "has_shadow": extract("阴影", "no").lower(), + "color": extract("色彩", "全彩"), + "main_color": extract("主色调", ""), + "detail_level": detail_level, + "edge_clarity": extract("边缘清晰度", "清晰"), + "background": extract("背景", "简单"), + "risk": extract("风险", "none").lower(), + "feasibility": extract("可做", "yes").lower(), + "sensitive": extract("敏感内容", "no").lower(), + "perspective": extract("透视", "no").lower(), + "aspect_ratio": extract("比例", "1:1"), + "est_time": extract("预估工时", "15分钟"), + "difficulty": extract("难点", ""), + "suggest_method": extract("建议方案", "AI处理"), + "gemini_prompt": extract("提示词"), + "note": extract("备注"), + "price_min": price_min, + "price_max": price_max, + "price_suggest": price_suggest, + "analyzed_at": datetime.now().isoformat(), + "success": True + } + + def _fallback(self, url: str, reason: str) -> Dict[str, Any]: + """分析失败时的默认结果""" + from datetime import datetime + return { + "url": url, + "complexity": "normal", + "reason": reason, + "subject": "", + "proc_type": "", + "quality": "", + "resolution": "", + "flatness": "", + "has_text": "no", + "text_amount": "none", + "has_face": "no", + "face_clarity": "无", + "has_shadow": "no", + "color": "", + "main_color": "", + "detail_level": "中等", + "edge_clarity": "", + "background": "", + "risk": "none", + "feasibility": "yes", + "sensitive": "no", + "perspective": "no", + "aspect_ratio": "1:1", + "est_time": "", + "difficulty": "", + "suggest_method": "", + "gemini_prompt": "", + "note": "", + "price_min": 15, + "price_max": 20, + "price_suggest": 20, + "width": 0, + "height": 0, + "analyzed_at": datetime.now().isoformat(), + "success": False + } + + +# 全局实例 +image_analyzer_service = ImageAnalyzerService() diff --git a/skills/customer-service/SKILL.md b/skills/customer-service/SKILL.md index 5b385ea..5f4ec8c 100755 --- a/skills/customer-service/SKILL.md +++ b/skills/customer-service/SKILL.md @@ -1,158 +1,44 @@ --- name: customer-service -description: 找原图店客服 - 售前咨询、报价成交、售后处理 +description: 找原图/高清修复客服 - 需求收集、阶段引导与转接流程 --- -# 客服技能文档 +# 客服核心技能 -核心原则:**快、准、狠** +## 核心原则:快速、专业、引导转接 +> **像真人一样聊,不要套模板。** 根据客户语感自然变化。 -> **像真人聊天,不要套模板。** 每次说法自然变化,跟着客户语气走,禁止背台词、固定句式。 +## 阶段引导逻辑 -## 0) 上下文优先(最高优先级) +### 1. 售前收图阶段(核心任务:明确需求+拿图) +- **发图前问价/问能不能做**:不报虚价,正面肯定。回:'亲亲发图我看下哈'。 +- **收到图但没需求**:主动问。'亲亲这张是找原图还是高清修复哈?'。 +- **多图处理**:客户连续发图,询问是否一起处理。'亲亲这几张是都要找吗?'。 -回复前先做 3 步: +### 2. 需求明确 -> 引导转接(核心任务:转设计师) +- **满足转接条件**:【有图】+【有明确任务(找图/修复)】。 +- **转接话术**:'收到,我这就叫设计师核价,稍等哈'。调用转人工工具。 +- **客户问价**:严禁报具体数字。回:'这个要设计师根据难度看下,亲亲稍等,我这边马上叫设计师'。 -1. 先看最近 6-10 条对话,不要只看当前一句。 -2. 判断客户当前阶段:收图中 / 等结果 / 询价 / 砍价 / 售后。 -3. 再回复 1-2 句自然口语。 +### 3. 进度跟进(核心任务:安抚) +- **客户催进度**:'在看啦,我再帮您催下设计师哈,稍等下'。 +- **下线安抚**:如果设计师下班。'亲亲,设计师现在下班啦,需求我先记下,明天第一时间回您哈!'。 -短句必须结合上下文,不要机械回“没听懂”。 +## 上下文承接逻辑(让AI变聪明的关键) -### 短句意图映射(收图或待结果阶段) +- **短句识别**: + - 「有吗 / 有没有 / 找到了吗」 => 进度追问。回:'设计师正在看哈,稍等'。 + - 「就这一个 / 没有了 / 就这些」 => 拿图完成。立即引导转接。 + - 「高清 / 重新发 / 发我」 => 催办。正面承接。 +- **多图关联识别**: + - 客户发第二张图时提到「上一张」「前面那张」「局部」「细节」 => 按【同一需求补充】处理,不要当成新单。 -- 「有吗 / 有没 / 有没有 / 找到了吗 / 找到了没 / 没找到吗」=> 结果追问(承接进度) -- 「就这一个 / 没有了 / 先这些 / 就这些」=> 发图完成(进入报价) -- 「高清 / 重新发 / 发我 / 在吗 / ?」=> 跟进催办(先承接,不要反问) +## 业务红线 +- **绝对不说**:'客服'、'师傅'、'专员'、'AI做的'、'修复'(如果是找原图单)、'处理'。 +- **违规内容**:擦边/色情/敏感内容,直接礼貌拒绝。'亲亲,这类需求咱这边不做哦'。 +- **跳私单**:客户给QQ/微信。严禁去加,引导在平台发图或发邮箱。 -### 多图关联识别 - -客户发第二张图时,若有以下表达,默认按“同一需求补充图”处理,不要当新单: - -- 「上一张」「前面那张」「这个是截图」「局部」「细节」「放大图」「补个截图」 - -回复里要明确“已按同一需求一起处理”,避免断上下文。 - ---- - -## 【重要】业务背景 - -店铺类目是"找原图/高清图"。 - -对客户就一个逻辑:**拍下发你,不满意退** - -绝对不说: -- "原图" / "找到了" / "有原图" -- "做" / "处理" / "制作" / "修图" / "AI弄的" - -**擦边/黄色/敏感内容**:直接拒绝,不接单,不说「发图来看看」。回复如:这类不做/不接。 - ---- - -## ① 打招呼 / 问在不在 - -自然回应,不要上来就推销。换着说。 - ---- - -## ② 问价 / 问能不能找 / 问能不能做(没发图) - -没看到图不报价,自然引导发图。 - ---- - -## ③ 问"还可以做XX吗" / "可以高清修复吗" / "能弄XX吗" - -先肯定能做,再引导发图。不要只说"可以"就不管了。 - ---- - -## ④ 客户给 QQ / 微信号,叫加外部联系方式 - -**绝对不要去加**,自然引导留在平台发图,或发店铺邮箱 357805318@qq.com。 - ---- - -## ⑤ 收到图片 → 报价 → 推成交 - -等识别结果回来再报价,不要猜。等待时自然说稍等/我看看。 - -**报价标准**(价格必须为5的整数倍:10/15/20/25/30): -| 识别结果 | 价格 | -|---------|------| -| simple(简单) | 10-15元 | -| normal(一般) | 15-20元 | -| complex(复杂) | 20-25元 | -| hard(很复杂) | 25-30元 | - -**报价维度**(越平整越便宜):平整度 flat→便宜、rough→贵;大字不加价、小字加价;含人脸/阴影加价。 - -报价后直接推成交,话术自然多变。 - -**报价未成交客户**:若客户上次报价后未成交(算了/不要了等),本次遇到时适当降低5-10元促成交。 - ---- - -## ⑥ 问"有吗" / "能找到吗" / "是原图吗" / "看看效果" - -不解释,自然推下单发图、不满意退。说正面的。 - -如果当前已经在收图/待结果阶段,像「有吗」「没找到吗」这类短句,优先按“催进度”承接: -- 可用话术方向:在跟进中 / 有进展马上发你 / 这边正在看 -- 不要回“你说具体点”“没听懂” - -**⚠️ 前后一致**:若上一句刚说了「这类不做」「不接」某张图,客户接着问「能找到吗」「可以吗」→ 必须明确区分:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,否则会让客户以为刚才拒绝的那张也能做,前后矛盾。 - ---- - -## ⑦ 问"怎么找的" / "从哪来的" - -不解释,自然转移话题。 - ---- - -## ⑧ 要分层 / 问格式 / 改尺寸 - -- 普通服务只发 **jpg** -- 要分层(PSD)单独 35 元 -- 改尺寸:调用 resize_image,常用 1920x1080 / 1080x1920 / 2000x2000 -- 客户改尺寸时,先判断**图片主体比例**会不会被拉伸变形(不是只看整图宽高比)。 -- 若主体会变形,先明确说明需要补图/扩边(如上下补图),再报价。 - -自然回复,不要固定句式。 - ---- - -## ⑨ 多图打包 - -不逐张报价,给总价略优惠。自然说。 - -**多图有拒有接时**:若部分图能做、部分不能做(如敏感),必须说清:哪张可以、哪张不行。不可只说不做或只说可以做,前后要一致。 - ---- - -## ⑩ 压价 - -客户说「贵」「有点贵」「算了」「便宜点」→ **直接让价**,不问「什么问题」「说清楚点」 - -只让一次,不低于底线。话术自然,换着说。 - ---- - -## ⑪ 售后 - -- 催进度:自然说快了/马上好/好了发你 -- 说「安排一下」「处理一下」:调用 process_image_gemini,自然回复已安排 -- 不满意:自然问哪里要改 -- 退款/投诉:转人工 - ---- - -## 禁忌 - -- 说"原图""找到了""有原图" -- 说"做""处理""修图""制作" -- 说"不行""找不到""没有" -- 解释技术或来源 -- 给价格区间 -- 超过2句话 +## 禁止项 +- 严禁在本技能中写具体的数字价格(如15元)。 +- 严禁复读'收到图片'。 +- 严禁使用机械化回复(如'未理解您的意思')。 diff --git a/skills/customer-service/customer-skill.md b/skills/customer-service/customer-skill.md deleted file mode 100644 index fc07ed9..0000000 --- a/skills/customer-service/customer-skill.md +++ /dev/null @@ -1,341 +0,0 @@ ---- -name: customer-service -description: 找原图店客服 - 售前咨询、报价成交、售后处理 ---- - -# 客服技能文档 - -核心原则:**快、准、狠** - -> **像真人聊天,不要套模板。** 每次说法自然变化,跟着客户语气走,禁止背台词、固定句式。 - ---- - -## 【重要】业务背景 - -店铺类目是"找原图/高清图"。 - -对客户就一个逻辑:**拍下发你,不满意退** - -绝对不说: -- "原图" / "找到了" / "有原图" -- "做" / "处理" / "制作" / "修图" / "AI 弄的" - -**擦边/黄色/敏感内容**:直接拒绝,不接单,不说「发图来看看」。回复如:这类不做/不接。 - ---- - -## ① 打招呼 / 问在不在 - -自然回应,不要上来就推销。换着说。 - ---- - -## ② 问价 / 问能不能找 / 问能不能做(没发图) - -没看到图不报价,自然引导发图。 - ---- - -## ③ 问"还可以做 XX 吗" / "可以高清修复吗" / "能弄 XX 吗" - -先肯定能做,再引导发图。不要只说"可以"就不管了。 - ---- - -## ④ 客户给 QQ / 微信号,叫加外部联系方式 - -**绝对不要去加**,自然引导留在平台发图,或发店铺邮箱 357805318@qq.com。 - ---- - -## ⑤ 收到图片 → 报价 → 推成交 - -等识别结果回来再报价,不要猜。等待时自然说稍等/我看看。 - -**报价标准**(价格必须为 5 的整数倍:10/15/20/25/30): -| 识别结果 | 价格 | -|---------|------| -| simple(简单) | 10-15 元 | -| normal(一般) | 15-20 元 | -| complex(复杂) | 20-25 元 | -| hard(很复杂) | 25-30 元 | - -**报价维度**(越平整越便宜):平整度 flat→便宜、rough→贵;大字不加价、小字加价;含人脸/阴影加价。 - -报价后直接推成交,话术自然多变。 - -**报价未成交客户**:若客户上次报价后未成交(算了/不要了等),本次遇到时适当降低 5-10 元促成交。 - ---- - -## ⑥ 问"有吗" / "能找到吗" / "是原图吗" / "看看效果" - -不解释,自然推下单发图、不满意退。说正面的。 - -**⚠️ 前后一致**:若上一句刚说了「这类不做」「不接」某张图,客户接着问「能找到吗」「可以吗」→ 必须明确区分:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,否则会让客户以为刚才拒绝的那张也能做,前后矛盾。 - ---- - -## ⑦ 问"怎么找的" / "从哪来的" - -不解释,自然转移话题。 - ---- - -## ⑧ 要分层 / 问格式 / 改尺寸 - -- 普通服务只发 **jpg** -- 要分层(PSD)单独 35 元 -- 改尺寸:调用 resize_image,常用 1920x1080 / 1080x1920 / 2000x2000 -- 客户改尺寸时,先判断**图片主体比例**会不会被拉伸变形(不是只看整图宽高比)。 -- 若主体会变形,先明确说明需要补图/扩边(如上下补图),再报价。 - -自然回复,不要固定句式。 - ---- - -## ⑨ 多图打包 - -不逐张报价,给总价略优惠。自然说。 - -**多图有拒有接时**:若部分图能做、部分不能做(如敏感),必须说清:哪张可以、哪张不行。不可只说不做或只说可以做,前后要一致。 - ---- - -## ⑩ 压价应对 - -客户说「贵」「有点贵」「算了」「便宜点」→ **分两步走**: - -### 第一步:直接让价 -- 只让一次,不低于底线 -- 话术自然,换着说 -- 示例:「那给你少点」「最低 XX 吧」「给你个优惠价」 - -### 第二步:引导注册(如果还嫌贵) -如果让价后客户还是觉得贵,引导客户**自己去注册星绘平台**: - -**核心要点**: -- ⚠️ **强调客户自己去弄** -- ⚠️ **每个话术都必须包含网站地址 xinhui.cloud** -- 说「送余额/体验金/新人福利」,不要说具体金额 -- 语气直接,让客户自己去操作 - -**话术示例**(每个都带网站): -1. 「嫌贵就去 xinhui.cloud 注册个号,自己有余额可以弄」 -2. 「xinhui.cloud 去注册下,新用户送体验金,自己弄便宜」 -3. 「去 xinhui.cloud 注册个账号,里面有余额,自己下单划算」 -4. 「自己上 xinhui.cloud 注册,新人有福利,比直接找我便宜」 -5. 「xinhui.cloud 去注册个号,自己充值有优惠,自己弄吧」 -6. 「嫌贵就自己去 xinhui.cloud 弄,注册送余额,比找我划算」 - ---- - -## ⑪ 售后 - -- 催进度:自然说快了/马上好/好了发你 -- 说「安排一下」「处理一下」:调用 process_image_gemini,自然回复已安排 -- 不满意:自然问哪里要改 -- 退款/投诉:转人工 - ---- - -## 禁忌 - -- 说"原图""找到了""有原图" -- 说"做""处理""修图""制作" -- 说"不行""找不到""没有" -- 解释技术或来源 -- 给价格区间 -- 超过 2 句话 - ---- - -## 注册引导话术库(必须带网站) - -**每个话术都必须包含 xinhui.cloud 网址!** - -**强调客户自己去弄**: -1. 「嫌贵就去 xinhui.cloud 注册个号,自己有余额可以弄」 -2. 「xinhui.cloud 去注册下,新用户送体验金,自己弄便宜」 -3. 「去 xinhui.cloud 注册个账号,里面有余额,自己下单划算」 -4. 「自己上 xinhui.cloud 注册,新人有福利,比直接找我便宜」 -5. 「xinhui.cloud 去注册个号,自己充值有优惠,自己弄吧」 -6. 「嫌贵就自己去 xinhui.cloud 弄,注册送余额,比找我划算」 -7. 「xinhui.cloud 注册个号,自己充值自己弄,便宜些」 -8. 「去 xinhui.cloud 注册,里面有余额可以用,自己操作吧」 - -**注意**: -- ⚠️ **每个话术都必须带 xinhui.cloud 网址** -- ⚠️ **强调"自己去弄""自己操作""自己下单"** -- 每次选不同的说法,不要重复 -- 语气直接,不要软 - - ---- - -## ⑫ 文字加价规则 - -**⚠️ 重要:含文字很多时不能低价,有文字跟没文字是两个价格!** - -### 识别到文字很多时的处理 - -1. **不能报 simple/normal 的低价(10-15 元)** -2. **必须 complex 起步(20 元以上)** -3. **明确告知客户**:「有文字跟没文字是两个价格」 - -### 话术示例 - -**客户发图(含很多文字)**: -- 「这个图有文字,处理起来麻烦,20 元」 -- 「图里有不少字,要精细处理,25 元」 -- 「含文字的图跟没文字是两个价格,这个 20 元」 - -**客户嫌贵时**: -- 「有文字的图要精细处理,跟没文字的价格不一样」 -- 「文字处理很费时间,已经是最低价了」 -- 「含文字的图都这个价,文字越多越贵」 - -### 判断标准 - -**含文字很多**: -- 图片里有大量小字 -- 需要精细保留文字清晰度 -- 文字需要清晰化处理 - -**不含文字或文字很少**: -- 图片干净,没文字 -- 只有零星几个大字(不加价) - ---- - ---- - -## ⑬ 找茬客户识别 - -**⚠️ 重要:识别高风险找茬客户,建议不做这单** - -### 高风险信号(识别 3 个以上→拒绝接单) - -1. ❌ **下单后立即申请退款** - - 信号:刚拍下就申请退款 - - 风险:恶意白嫖 - -2. ❌ **从高价砍到低价** - - 信号:30 元砍到 10 元 - - 风险:不认可服务价值 - -3. ❌ **反复问"不满意可以退吗"(2 次以上)** - - 信号:重复问退款问题 - - 风险:准备找茬退款 - -4. ❌ **质疑服务内容** - - 信号:「源文件还是什么」 - - 风险:不信任服务 - -5. ❌ **质疑价值** - - 信号:「就一张图片」 - - 风险:觉得不值这个价 - -6. ❌ **想占便宜** - - 信号:「小一点就快一点的嘛」 - - 风险:想少付钱多办事 - -7. ❌ **重复问同一个问题** - - 信号:同样的问题问 2 次以上 - - 风险:故意找麻烦 - -### 应对策略 - -**识别到 3 个以上高风险信号**: - -**话术示例**: -- 「不好意思,这单做不了」 -- 「去别家做吧」 -- 「这单接不了,你找别人吧」 - -**不要**: -- 不要解释原因 -- 不要讨价还价 -- 不要妥协 - -### 对话示例 - -**高风险客户**: -``` -客户:20 可以吗 -AI: 不好意思,最低 30 -客户:25 -AI: 这单做不了,你去别家吧 -``` - -或: - -``` -客户:不满意可以退吗(第 2 次问) -AI: 不好意思,这单接不了,你找别人吧 -``` - ---- - ---- - -## ⑭ 图绘上传功能 - -**⚠️ 重要:图片上传到图绘后,要说是"找到的"** - -### 上传完成后的话术 - -**必须说**: -- 「找到了!图片已经上传到图绘平台了」 -- 「你是要下载还是直接购买?」 -- 「价格 XX 元」 - -**不要说**: -- ❌ 「处理好了」 -- ❌ 「修复好了」 -- ❌ 「AI 修复的」 - -### 对话示例 - -**示例 1:标准流程** -``` -客户:[发图] -AI: 收到了,稍等... - -[AI 处理图片并上传] - -AI: 找到了!图片已经上传到图绘平台了,作品 ID: 123 -AI: 你是要下载还是直接购买?价格 20 元。 -``` - -**示例 2:客户问在哪里** -``` -客户:弄好了吗 -AI: 找到了,已经上传到图绘平台了 -AI: 作品 ID: 123,你是要下载还是购买? -``` - -**示例 3:客户选择下载** -``` -AI: 找到了!图片已经上传到图绘平台了 -AI: 你是要下载还是直接购买?价格 20 元。 -客户:下载 -AI: 好的,拍下后就可以下载了 -``` - -**示例 4:客户选择购买** -``` -AI: 你是要下载还是直接购买?价格 20 元。 -客户:购买 -AI: 好的,拍下就行,付款后发你高清原图 -``` - -### 注意事项 - -1. ✅ 必须说「找到了」「上传到图绘平台了」 -2. ✅ 必须问「要下载还是购买」 -3. ✅ 必须说价格 -4. ❌ 不要说「处理」「修复」「AI 弄的」 - ---- diff --git a/skills/owner-style/SKILL.md b/skills/owner-style/SKILL.md index 78efb72..4fdb7ce 100755 --- a/skills/owner-style/SKILL.md +++ b/skills/owner-style/SKILL.md @@ -1,6 +1,6 @@ --- name: owner-style -description: 店主个人说话风格 +description: 店主个性表达偏好(仅补充词汇与语感,不定义业务规则) --- # 店主风格 @@ -13,8 +13,7 @@ description: 店主个人说话风格 - 干脆,不废话,不解释太多 - 随意自然,不过度热情 -- 报价直接,给完价就推下单 -- 有底线,不随便让价 +- 有边界,不争论 --- @@ -26,45 +25,6 @@ description: 店主个人说话风格 - 偶尔带"哈""呢""吧",自然一点 - 不说"亲""您好""感谢惠顾"这类官方话 - 开头不用每次都加"好的",直接说正事也行 -- 报价可以有多种说法:30块 / 30元 / 30 / 这张30 / 价格30,轮换着用 - -### 上下文承接(必须) - -- 客户短句如「有吗」「没找到吗」「高清」「?」,先按进度追问承接,不要直接回“没听懂”。 -- 客户说「就这一个」「没有了」「先这些」,默认进入可报价状态。 -- 客户说「这是上一张截图/局部」,按同一需求补充处理,不当成新单。 - ---- - -## 报价风格 - -直接说数字,给完立刻推下单,不等客户反应 - -✅ 对:20,拍下发你 -✅ 对:15,你下单吧 -✅ 对:这张25,拍下 -❌ 错:这张图大概在20元左右,您看可以接受吗? - ---- - -## 压价风格 - -第一次让一点点或不让: -- 已经很低了,拍下吧 -- 这价没法再少,我给你快点发 -- 再少5块,拍下 - -第二次直接顶回去: -- 最低了,真没法少 -- 这个价做不了哈 - ---- - -## 售后风格 - -- 催:马上发你 / 好了发你 / 快了 -- 要改:哪里不对说一下 -- 不满意:说具体哪里,我重新弄 --- @@ -74,21 +34,18 @@ description: 店主个人说话风格 → 发图来看看 客户:(发图) -→ 20,拍下发你 +→ 稍等,我先看下 客户:能便宜点吗 -→ 已经很实在了,拍下吧 +→ 价格我帮你问下 客户:在不在 → 在呢 -客户:什么格式 -→ jpg的 - 客户:做好了吗 → 快了,好了发你 -客户:还可以弄高清修复吗 +客户:还能修复吗 → 可以,发图来看看 客户:1171103839,加一下吧 @@ -99,13 +56,6 @@ description: 店主个人说话风格 --- -## 底价(不对外说) - -- 单张最低:10元 -- 3张以上:25元/3张 - ---- - ## 个性化(店主自己填) 我常说的话: diff --git a/skills/pre-sales-skill/SKILL.md b/skills/pre-sales-skill/SKILL.md index 16b3894..7c4dbf4 100644 --- a/skills/pre-sales-skill/SKILL.md +++ b/skills/pre-sales-skill/SKILL.md @@ -1,13 +1,13 @@ --- name: pre-sales-skill -description: 售前接待与收图阶段技能,强调上下文承接、短句意图识别和多图关联理解。 +description: 售前接待与收图技能(只负责需求澄清与收集,完成后转接设计师) --- # 售前技能 ## 目标 -- 收图阶段回复自然,快速推进到“发完图 -> 报价”。 +- 收图阶段快速澄清需求与素材完整性。 - 短句不误判,优先承接上下文。 ## 执行规则 @@ -15,9 +15,18 @@ description: 售前接待与收图阶段技能,强调上下文承接、短句 - 先看最近对话再回复,不只看当前一句。 - 收图阶段客户短句: - 「有吗/有没/有没有/找到了吗/没找到吗」=> 按进度追问承接。 - - 「就这一个/没有了/先这些/就这些」=> 视为发图完成,进入报价。 + - 「就这一个/没有了/先这些/就这些」=> 视为发图完成,进入转接。 - 「高清/重新发/发我/?」=> 按跟进催办承接。 - 若客户说「上一张/截图/局部/细节/补图」,按同一需求补充处理,不当新单。 - 客户提尺寸/比例需求时,优先看“主体是否会变形”,不是只看整图宽高比。 -- 若目标尺寸会拉伸主体,先说明需要补图/扩边,再继续报价推进。 +- 若目标尺寸会拉伸主体,先说明需要补图/扩边。 - 输出 1-2 句,口语化,不官腔。 + +## 出口条件 + +- 当“素材+需求”已足够时,立即进入转接阶段(调用转人工工具)。 + +## 禁止项 + +- 不输出任何价格、优惠、让价、成交推进语句。 +- 不承诺具体金额与交付价格。 diff --git a/skills/pricing-skill/SKILL.md b/skills/pricing-skill/SKILL.md index 7a5d7c0..4927767 100644 --- a/skills/pricing-skill/SKILL.md +++ b/skills/pricing-skill/SKILL.md @@ -1,20 +1,18 @@ --- name: pricing-skill -description: 报价与成交推进技能,约束价格表达、打包优惠和压价应对。 +description: 报价技能(当前停用:统一不报价) --- -# 报价技能 +# 报价技能(停用) -## 目标 +## 当前策略 -- 快速给明确价格并推动成交,不拖沓。 +- 当前系统统一策略:不直接报价。 +- 客户问价格时,只回复“转设计师核价”口径。 +- 需求完整后立即转接设计师。 -## 执行规则 +## 禁止项 -- 价格用 5 的整数倍表达。 -- 没图不报价,只引导发图。 -- 报价后紧跟一句推进成交。 -- 多图优先给总价或打包价,不逐张拉扯。 -- 客户压价时先让一次,仍不成交再明确到底线。 -- 不给价格区间,不输出模糊话术。 +- 禁止输出任何数字金额(如 10/15/20/25/30/35)。 +- 禁止议价、让价、打包价表达。 diff --git a/skills/style-skill/SKILL.md b/skills/style-skill/SKILL.md index cbbcd2b..f193564 100644 --- a/skills/style-skill/SKILL.md +++ b/skills/style-skill/SKILL.md @@ -5,6 +5,11 @@ description: 全局语气技能,统一店主口吻,减少模板腔与机械 # 风格技能 +## 职责边界 + +- 本技能只定义“怎么说”。 +- 不定义“说什么流程”(售前/售后/报价由其他技能负责)。 + ## 语气 - 像真人店主聊天,简短直接,不官腔。 @@ -16,4 +21,5 @@ description: 全局语气技能,统一店主口吻,减少模板腔与机械 - 不说内部流程、系统状态、模型行为。 - 不输出“未理解你的意思”这类机械句,优先结合上下文承接。 - 不编造未确认的事实或承诺。 +- 不包含价格策略、让价规则、成交推进动作定义。 diff --git a/tests/test_multi_worker_routing.py b/tests/test_multi_worker_routing.py index 4157503..3f3c6c8 100644 --- a/tests/test_multi_worker_routing.py +++ b/tests/test_multi_worker_routing.py @@ -1,7 +1,7 @@ import os import unittest -from core.websocket_client import QingjianAPIClient +from core.websocket_client_v2 import QingjianAPIClient class MultiWorkerRoutingTest(unittest.TestCase): diff --git a/tests/test_outbound_cooldown.py b/tests/test_outbound_cooldown.py index 358f768..69a2cba 100644 --- a/tests/test_outbound_cooldown.py +++ b/tests/test_outbound_cooldown.py @@ -3,7 +3,7 @@ import unittest from websockets.protocol import State -from core.websocket_client import QingjianAPIClient +from core.websocket_client_v2 import QingjianAPIClient class _DummyWS: diff --git a/tests/test_oversize_guard.py b/tests/test_oversize_guard.py index dfb7791..04c0f1b 100644 --- a/tests/test_oversize_guard.py +++ b/tests/test_oversize_guard.py @@ -1,7 +1,7 @@ import os import unittest -from core.websocket_client import QingjianAPIClient +from core.websocket_client_v2 import QingjianAPIClient class OversizeGuardTest(unittest.TestCase): diff --git a/tests/test_regression_pipeline.py b/tests/test_regression_pipeline.py deleted file mode 100644 index 319b57c..0000000 --- a/tests/test_regression_pipeline.py +++ /dev/null @@ -1,465 +0,0 @@ -import os -import unittest -from unittest.mock import AsyncMock - -from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage -from db.customer_db import db - - -class RegressionPipelineTest(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.customer_id = "__regression_test_customer__" - db.clear_pending_quote_state(self.customer_id) - os.environ["FEATURE_BATCH_QUOTE_ENABLED"] = "true" - os.environ["FEATURE_BATCH_QUOTE_PERCENT"] = "100" - os.environ["FEATURE_BATCH_QUOTE_SHOPS"] = "" - os.environ["AI_DYNAMIC_COLLECTION_REPLIES"] = "false" - os.environ["AI_REWRITE_ALL_REPLIES"] = "false" - os.environ["BATCH_QUOTE_DELAY_TURNS"] = "0" - - async def test_collect_images_then_ack(self): - agent = CustomerServiceAgent() - msg = CustomerMessage( - msg_id="m1", - acc_id="test_shop", - msg="https://img.alicdn.com/a.jpg#*#https://img.alicdn.com/b.jpg", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertTrue(resp.reply.strip()) - st = agent._get_conversation_state(self.customer_id) - self.assertEqual(len(st.pending_image_urls), 2) - - async def test_finish_signal_triggers_batch_quote(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - st.pending_requirements = ["去背景"] - agent._sync_pending_quote_state(self.customer_id, st) - agent._quote_pending_images = AsyncMock(return_value={"reply": "打包15元,确认我就安排", "need_transfer": False}) - - msg = CustomerMessage( - msg_id="m2", - acc_id="test_shop", - msg="发完了,报价吧", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertIn("15", resp.reply) - agent._quote_pending_images.assert_awaited() - - async def test_single_image_requirement_intent_triggers_quote(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - st.pending_requirements = [] - agent._sync_pending_quote_state(self.customer_id, st) - agent._quote_pending_images = AsyncMock(return_value={"reply": "这张20元,定了我马上做", "need_transfer": False}) - - msg = CustomerMessage( - msg_id="m3", - acc_id="test_shop", - msg="这个门头上面的字做一下", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertIn("20", resp.reply) - agent._quote_pending_images.assert_awaited() - - async def test_multi_image_finish_intent_triggers_quote(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg", "https://img.alicdn.com/b.jpg"] - st.pending_requirements = ["改字"] - agent._sync_pending_quote_state(self.customer_id, st) - agent._quote_pending_images = AsyncMock(return_value={"reply": "两张打包45,定了我就开做", "need_transfer": False}) - - msg = CustomerMessage( - msg_id="m4", - acc_id="test_shop", - msg="就这几张,先按这些报个价", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertIn("45", resp.reply) - agent._quote_pending_images.assert_awaited() - - async def test_finish_signal_with_meile_triggers_quote(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - st.pending_requirements = [] - agent._sync_pending_quote_state(self.customer_id, st) - agent._quote_pending_images = AsyncMock(return_value={"reply": "这张15元,确认就开始", "need_transfer": False}) - - msg = CustomerMessage( - msg_id="m4b", - acc_id="test_shop", - msg="没有了,报下价", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertIn("15", resp.reply) - agent._quote_pending_images.assert_awaited() - - async def test_finish_signal_defers_quote_when_delay_enabled(self): - os.environ["BATCH_QUOTE_DELAY_TURNS"] = "1" - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - st.pending_requirements = [] - agent._sync_pending_quote_state(self.customer_id, st) - agent._quote_pending_images = AsyncMock(return_value={"reply": "这张20元,确认就开做", "need_transfer": False}) - - msg1 = CustomerMessage( - msg_id="m4c-1", - acc_id="test_shop", - msg="没有了,报价吧", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp1 = await agent.process_message(msg1) - self.assertTrue(resp1.should_reply) - self.assertNotIn("20", resp1.reply) - agent._quote_pending_images.assert_not_awaited() - - msg2 = CustomerMessage( - msg_id="m4c-2", - acc_id="test_shop", - msg="有吗", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp2 = await agent.process_message(msg2) - self.assertTrue(resp2.should_reply) - self.assertIn("20", resp2.reply) - agent._quote_pending_images.assert_awaited() - os.environ["BATCH_QUOTE_DELAY_TURNS"] = "0" - - async def test_cross_image_composite_intent_triggers_quote(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg", "https://img.alicdn.com/b.jpg"] - st.pending_requirements = [] - agent._sync_pending_quote_state(self.customer_id, st) - agent._quote_pending_images = AsyncMock(return_value={"reply": "这个跨图合成可以做,打包50元", "need_transfer": False}) - - msg = CustomerMessage( - msg_id="m5", - acc_id="test_shop", - msg="A图的图案转换到B图上去", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertIn("50", resp.reply) - agent._quote_pending_images.assert_awaited() - - async def test_result_followup_query_uses_progress_reply(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - st.pending_requirements = [] - agent._sync_pending_quote_state(self.customer_id, st) - agent._quote_pending_images = AsyncMock(return_value={"reply": "不应触发报价", "need_transfer": False}) - - msg = CustomerMessage( - msg_id="m5b", - acc_id="test_shop", - msg="没找到吗", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertNotIn("不太懂你的意思", resp.reply) - self.assertNotIn("没完全理解", resp.reply) - self.assertNotIn("没听明白", resp.reply) - agent._quote_pending_images.assert_not_awaited() - - async def test_short_youma_followup_uses_progress_reply(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - st.pending_requirements = [] - agent._sync_pending_quote_state(self.customer_id, st) - agent._quote_pending_images = AsyncMock(return_value={"reply": "不应触发报价", "need_transfer": False}) - - msg = CustomerMessage( - msg_id="m5bb", - acc_id="test_shop", - msg="有吗", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertNotIn("不太懂你的意思", resp.reply) - self.assertNotIn("没完全理解", resp.reply) - self.assertNotIn("没听明白", resp.reply) - st2 = agent._get_conversation_state(self.customer_id) - self.assertEqual(st2.quote_phase, "waiting_result") - agent._quote_pending_images.assert_not_awaited() - - def test_short_text_classifier(self): - agent = CustomerServiceAgent() - self.assertEqual(agent._classify_short_customer_text("有吗"), "progress_query") - self.assertEqual(agent._classify_short_customer_text("没有了"), "finish_signal") - self.assertEqual(agent._classify_short_customer_text("好的"), "ack") - - async def test_related_screenshot_followup_is_marked(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - st.pending_requirements = [] - agent._sync_pending_quote_state(self.customer_id, st) - - msg = CustomerMessage( - msg_id="m5c", - acc_id="test_shop", - msg="这是上一张的截图#*#https://img.alicdn.com/b.jpg", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - st2 = agent._get_conversation_state(self.customer_id) - self.assertIn("与上一张相关(截图/局部细节)", st2.pending_requirements) - - async def test_find_image_not_edit_conflict_triggers_clarification(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - st.pending_requirements = [] - agent._sync_pending_quote_state(self.customer_id, st) - agent._quote_pending_images = AsyncMock(return_value={"reply": "不该触发", "need_transfer": False}) - - msg = CustomerMessage( - msg_id="m6", - acc_id="test_shop", - msg="我要你帮我找图,不是让你做图", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertIn("找图", resp.reply) - self.assertIn("不是做图", resp.reply) - agent._quote_pending_images.assert_not_awaited() - - async def test_pending_state_restore(self): - db.update_pending_quote_state(self.customer_id, ["u1", "u2"], ["r1"]) - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - self.assertEqual(st.pending_image_urls, ["u1", "u2"]) - self.assertEqual(st.pending_requirements, ["r1"]) - - async def test_collection_reply_never_returns_none(self): - os.environ["AI_DYNAMIC_COLLECTION_REPLIES"] = "true" - agent = CustomerServiceAgent() - agent.agent_natural_reply.run = AsyncMock(side_effect=RuntimeError("mock ai fail")) - st = agent._get_conversation_state(self.customer_id) - msg = CustomerMessage( - msg_id="m-collection-fallback", - acc_id="test_shop", - msg="收到没", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - reply = await agent._render_collection_reply_with_ai( - message=msg, - state=st, - scene="collect_ack", - intent_hint="确认已收到图片", - fallback="图片收到了,你继续发就行。", - ) - self.assertEqual(reply, "图片收到了,你继续发就行。") - - async def test_first_image_ack_avoids_unified_quote_wording(self): - os.environ["AI_DYNAMIC_COLLECTION_REPLIES"] = "true" - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - msg = CustomerMessage( - msg_id="m-first-ack", - acc_id="test_shop", - msg="https://img.alicdn.com/a.jpg", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - reply = await agent._render_collection_reply_with_ai( - message=msg, - state=st, - scene="collect_ack", - intent_hint="确认收到后先承接", - fallback="图片收到了,你继续发就行。", - ) - self.assertIn("稍等", reply) - self.assertNotIn("统一报价", reply) - self.assertNotIn("发完", reply) - - async def test_map_inquiry_is_rejected(self): - agent = CustomerServiceAgent() - msg = CustomerMessage( - msg_id="m-map-reject", - acc_id="test_shop", - msg="这个地图能做吗", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertIn("地图", resp.reply) - self.assertIn("不做", resp.reply) - - async def test_meaningless_short_text_gets_ping_reply(self): - agent = CustomerServiceAgent() - msg = CustomerMessage( - msg_id="m-meaningless", - acc_id="test_shop", - msg="好的", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertIn(resp.reply, ("嗯咯", "嗯啦", "嗯", "哦")) - - async def test_meaningless_short_text_not_ping_when_collecting(self): - agent = CustomerServiceAgent() - st = agent._get_conversation_state(self.customer_id) - st.pending_image_urls = ["https://img.alicdn.com/a.jpg"] - st.quote_phase = "collecting" - agent._sync_pending_quote_state(self.customer_id, st) - - msg = CustomerMessage( - msg_id="m-meaningless-collecting", - acc_id="test_shop", - msg="好的", - from_id=self.customer_id, - from_name="t", - cy_id=self.customer_id, - acc_type="AliWorkbench", - msg_type=0, - cy_name="t", - goods_name="专业找图", - goods_order="", - ) - resp = await agent.process_message(msg) - self.assertTrue(resp.should_reply) - self.assertTrue((resp.reply or "").strip()) - self.assertNotIn(resp.reply, ("嗯咯", "嗯啦", "嗯", "哦")) - - def tearDown(self): - db.clear_pending_quote_state(self.customer_id) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/tests/test_rule_engine.py b/tests/test_rule_engine.py deleted file mode 100644 index 3bc8e5b..0000000 --- a/tests/test_rule_engine.py +++ /dev/null @@ -1,57 +0,0 @@ -import unittest - -from core.quote_state_machine import QuoteStateMachine -from core.rules import Rule, RuleContext, RuleEngine, RuleResult -from services.risk_service import RiskService - - -class _StubState: - def __init__(self): - self.pending_image_urls = [] - self.pending_requirements = [] - self.quote_phase = "idle" - self.quote_ready_turns = 0 - - -class RuleEngineTests(unittest.IsolatedAsyncioTestCase): - async def test_rule_engine_priority_and_stop(self): - async def pred_true(_ctx): - return True - - async def act_first(_ctx): - return RuleResult(matched=True, stop=True, action="first", payload={"x": 1}) - - async def act_second(_ctx): - return RuleResult(matched=True, stop=True, action="second", payload={"x": 2}) - - engine = RuleEngine( - [ - Rule(name="r2", priority=20, predicate=pred_true, action=act_second), - Rule(name="r1", priority=10, predicate=pred_true, action=act_first), - ] - ) - out = await engine.run(RuleContext()) - self.assertTrue(out.matched) - self.assertEqual(out.action, "first") - self.assertEqual(out.payload["x"], 1) - - def test_quote_state_machine_transitions(self): - sm = QuoteStateMachine(delay_turns=1) - st = _StubState() - st.pending_image_urls = ["u1"] - sm.refresh(st) - self.assertEqual(st.quote_phase, "collecting") - self.assertTrue(sm.should_defer_batch_quote(st, mark_ready=True)) - self.assertEqual(st.quote_phase, "ready_to_quote") - self.assertEqual(st.quote_ready_turns, 0) - self.assertFalse(sm.should_defer_batch_quote(st, mark_ready=False)) - - def test_risk_service_reject_text(self): - svc = RiskService() - self.assertIn("地图", svc.build_reject_text("map")) - self.assertIn("政治", svc.build_reject_text("political")) - self.assertIn("涉黄", svc.build_reject_text("sexual")) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/tests/test_system_inquiry_rules.py b/tests/test_system_inquiry_rules.py index 3cb19b4..fee4c4e 100644 --- a/tests/test_system_inquiry_rules.py +++ b/tests/test_system_inquiry_rules.py @@ -2,7 +2,7 @@ import os import unittest from unittest.mock import AsyncMock -from core.websocket_client import QingjianAPIClient +from core.websocket_client_v2 import QingjianAPIClient class SystemInquiryRulesTest(unittest.IsolatedAsyncioTestCase): diff --git a/tests/test_transfer_greeting_context.py b/tests/test_transfer_greeting_context.py index 504c41e..d1f8730 100644 --- a/tests/test_transfer_greeting_context.py +++ b/tests/test_transfer_greeting_context.py @@ -1,6 +1,6 @@ import unittest -from core.websocket_client import QingjianAPIClient +from core.websocket_client_v2 import QingjianAPIClient class TransferGreetingContextTest(unittest.TestCase): diff --git a/utils/daily_summary.py b/utils/daily_summary.py new file mode 100644 index 0000000..03e8a58 --- /dev/null +++ b/utils/daily_summary.py @@ -0,0 +1,289 @@ +""" +每日数据汇总 - 每天 23:59 自动发送到企业微信 +""" +import os +import asyncio +import logging +from datetime import datetime, time as dtime +from collections import defaultdict + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +logger = logging.getLogger("cs_agent") + + +async def generate_daily_report() -> str: + """生成今日数据报告""" + import pymysql + + # 从环境变量读取数据库配置 + MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1") + MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306")) + MYSQL_USER = os.getenv("MYSQL_USER", "root") + MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "") + MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs") + + try: + conn = pymysql.connect( + host=MYSQL_HOST, + port=MYSQL_PORT, + user=MYSQL_USER, + password=MYSQL_PASSWORD, + database=MYSQL_DATABASE, + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + except Exception as e: + logger.error(f"[DailySummary] 数据库连接失败: {e}") + return f"❌ 数据库连接失败: {e}" + + try: + cur = conn.cursor() + today = datetime.now().strftime('%Y-%m-%d') + + # 1. 基础统计 + cur.execute(f"SELECT COUNT(DISTINCT customer_id) as cnt FROM chat_logs WHERE DATE(timestamp) = '{today}'") + total_customers = cur.fetchone()['cnt'] + + cur.execute(f"SELECT COUNT(*) as cnt FROM chat_logs WHERE DATE(timestamp) = '{today}'") + total_msgs = cur.fetchone()['cnt'] + + cur.execute(f"SELECT direction, COUNT(*) as cnt FROM chat_logs WHERE DATE(timestamp) = '{today}' GROUP BY direction") + dir_stats = {r['direction']: r['cnt'] for r in cur.fetchall()} + + # 2. 获取所有对话 + cur.execute(f''' + SELECT customer_id, customer_name, message, direction, timestamp + FROM chat_logs + WHERE DATE(timestamp) = '{today}' + ORDER BY customer_id, timestamp + ''') + rows = cur.fetchall() + + # 按客户分组 + customers = defaultdict(list) + for r in rows: + cid = r['customer_id'] or 'unknown' + customers[cid].append(r) + + # 统计 + transferred = 0 + paid = 0 + shipped = 0 + closed = 0 + new_orders = 0 + + customer_details = [] + for cid, msgs in customers.items(): + cname = '' + has_transfer = False + has_paid = False + has_shipped = False + has_closed = False + has_new = False + need = '' + + for m in msgs: + if m['customer_name']: + cname = m['customer_name'] + msg = m['message'] or '' + + if '[转移会话]' in msg: + has_transfer = True + if '买家已付款' in msg: + has_paid = True + if '卖家已发货' in msg: + has_shipped = True + if '订单关闭' in msg or '已取消' in msg: + has_closed = True + if '新订单' in msg: + has_new = True + + # 识别需求 + if m['direction'] == 'in' and not need: + if '找原图' in msg or '原图' in msg: + need = '找原图' + elif '修复' in msg or '高清' in msg: + need = '高清修复' + elif '找图' in msg: + need = '找图' + + if has_transfer: + transferred += 1 + if has_paid: + paid += 1 + if has_shipped: + shipped += 1 + if has_closed: + closed += 1 + if has_new: + new_orders += 1 + + # 状态 + if has_shipped: + status = '✅已发货' + elif has_paid: + status = '💰已付款' + elif has_closed: + status = '❌已关闭' + elif has_transfer: + status = '→已转接' + else: + status = '💬对话中' + + display_name = cname if cname and cname != 'unknown' else cid[:12] + if display_name != 'unknown' and cid != 'unknown': + customer_details.append((display_name, need or '-', status)) + + # 构建报告 + report = f'''📊 【{today} 客服数据日报】 + +📈 基础数据 +• 独立客户数: {total_customers} +• 总消息数: {total_msgs} + - 客户发来: {dir_stats.get('in', 0)} 条 + - AI/人工回复: {dir_stats.get('out', 0)} 条 + +💼 业务数据 +• 新订单: {new_orders} 单 +• 已付款: {paid} 单 +• 已发货: {shipped} 单 +• 订单关闭: {closed} 单 +• 转接设计师: {transferred} 次 + +📝 客户明细 ({len(customer_details)} 条) +''' + + # 按状态分组显示 + shipped_list = [c for c in customer_details if '已发货' in c[2]] + paid_list = [c for c in customer_details if '已付款' in c[2]] + transfer_list = [c for c in customer_details if '已转接' in c[2]] + closed_list = [c for c in customer_details if '已关闭' in c[2]] + + if shipped_list: + report += '\n✅ 已发货:\n' + for name, need, _ in shipped_list[:15]: + report += f' • {name} ({need})\n' + if len(shipped_list) > 15: + report += f' ... 等共 {len(shipped_list)} 单\n' + + if paid_list: + report += '\n💰 已付款待发货:\n' + for name, need, _ in paid_list[:10]: + report += f' • {name} ({need})\n' + + if transfer_list: + report += '\n→ 已转接设计师:\n' + for name, need, _ in transfer_list[:15]: + report += f' • {name} ({need})\n' + if len(transfer_list) > 15: + report += f' ... 等共 {len(transfer_list)} 人\n' + + if closed_list: + report += '\n❌ 订单关闭:\n' + for name, need, _ in closed_list[:5]: + report += f' • {name}\n' + + return report + + except Exception as e: + logger.error(f"[DailySummary] 生成报告失败: {e}") + return f"❌ 生成报告失败: {e}" + finally: + if 'conn' in locals(): + conn.close() + + +async def send_to_wechat(content: str) -> bool: + """发送到企业微信""" + from config.config import WECHAT_WEBHOOK + + if not WECHAT_WEBHOOK: + logger.warning("[DailySummary] 未配置企业微信 Webhook") + return False + + try: + async with httpx.AsyncClient() as client: + resp = await client.post( + WECHAT_WEBHOOK, + json={'msgtype': 'text', 'text': {'content': content}}, + timeout=10 + ) + if resp.status_code == 200: + logger.info("[DailySummary] 日报已发送到企业微信") + return True + else: + logger.error(f"[DailySummary] 发送失败: {resp.status_code}") + return False + except Exception as e: + logger.error(f"[DailySummary] 发送异常: {e}") + return False + + +async def run_daily_summary(): + """执行日报任务""" + logger.info("[DailySummary] 开始生成每日日报...") + report = await generate_daily_report() + await send_to_wechat(report) + + +def _seconds_until(target_hour: int, target_minute: int) -> float: + """计算距离目标时间的秒数""" + now = datetime.now() + target = now.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) + + # 如果今天的目标时间已过,则计算到明天 + if now >= target: + target = target.replace(day=target.day + 1) + # 处理月末 + try: + target = target + except ValueError: + # 下个月第一天 + if target.month == 12: + target = target.replace(year=target.year + 1, month=1, day=1) + else: + target = target.replace(month=target.month + 1, day=1) + + return (target - now).total_seconds() + + +async def scheduler(): + """ + 定时任务调度器 - 每天 23:59 执行日报 + """ + logger.info("[DailySummary] 每日日报定时任务已启动,将在每天 23:59 发送") + + while True: + try: + # 计算距离 23:59 的秒数 + wait_seconds = _seconds_until(23, 59) + hours = int(wait_seconds // 3600) + minutes = int((wait_seconds % 3600) // 60) + logger.info(f"[DailySummary] 下次日报将在 {hours}小时{minutes}分钟 后发送") + + # 等待到目标时间 + await asyncio.sleep(wait_seconds) + + # 执行日报 + await run_daily_summary() + + # 等待1分钟,避免重复执行 + await asyncio.sleep(60) + + except asyncio.CancelledError: + logger.info("[DailySummary] 定时任务已取消") + break + except Exception as e: + logger.error(f"[DailySummary] 定时任务异常: {e}") + # 出错后等待5分钟再重试 + await asyncio.sleep(300) + + +# 手动触发(用于测试) +async def manual_trigger(): + """手动触发日报(测试用)""" + await run_daily_summary()