newtw
3
.gitignore
vendored
@@ -18,3 +18,6 @@ venv/
|
|||||||
logs/*.log
|
logs/*.log
|
||||||
config/.runtime_metrics.jsonl
|
config/.runtime_metrics.jsonl
|
||||||
|
|
||||||
|
|
||||||
|
# <20><>ʱ<EFBFBD>浵
|
||||||
|
_archive/
|
||||||
|
|||||||
211
CODE_REVIEW_ISSUES.md
Normal file
@@ -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
|
||||||
23
check_logs.py
Normal file
@@ -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()
|
||||||
@@ -26,7 +26,8 @@ class QianniuAdapter(BaseAdapter):
|
|||||||
with open(config_path, "r", encoding="utf-8") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
cfg = json.load(f)
|
cfg = json.load(f)
|
||||||
return cfg.get(acc_id, self._default_group_id)
|
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
|
return self._default_group_id
|
||||||
|
|
||||||
async def translate_inbound(self, raw: dict) -> Tuple[StandardMessage, str]:
|
async def translate_inbound(self, raw: dict) -> Tuple[StandardMessage, str]:
|
||||||
@@ -81,6 +82,9 @@ class QianniuAdapter(BaseAdapter):
|
|||||||
content = res.reply_content
|
content = res.reply_content
|
||||||
|
|
||||||
try:
|
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)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"[QianniuAdapter] 发送失败: {e}")
|
logger.error(f"[QianniuAdapter] 发送失败: {e}")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async def transfer_to_human_tool(ctx: RunContext[Any], reason: str = Field(descr
|
|||||||
designer_name = await dispatch_service.assign_designer()
|
designer_name = await dispatch_service.assign_designer()
|
||||||
|
|
||||||
if designer_name:
|
if designer_name:
|
||||||
# 2. 有设计师在线:生成标准转接指令
|
# 2. 有设计师在线:生成标准转接指令 (必须包含 [转移会话] 且格式正确)
|
||||||
magic_cmd = f"正在为您转接|[转移会话],{designer_name},无原因"
|
magic_cmd = f"正在为您转接|[转移会话],{designer_name},无原因"
|
||||||
logger.info(f"[Tool] 成功呼叫设计师: {designer_name}")
|
logger.info(f"[Tool] 成功呼叫设计师: {designer_name}")
|
||||||
return magic_cmd
|
return magic_cmd
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
from typing import Optional, List, Any, Dict
|
from typing import Optional, List, Any, Dict
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from core.schema import StandardMessage, StandardResponse
|
from core.schema import StandardMessage, StandardResponse
|
||||||
@@ -12,6 +13,11 @@ from core.repository import repo
|
|||||||
|
|
||||||
logger = logging.getLogger("cs_agent")
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
# 配置常量
|
||||||
|
MSG_DEDUP_CAPACITY = 200 # 消息 ID 去重缓存容量
|
||||||
|
TRANSFER_COOLDOWN_SEC = 60 # 转接冷却时间(秒)
|
||||||
|
DEBOUNCE_SECONDS = 2.0 # 消息防抖延迟(秒)
|
||||||
|
|
||||||
class SystemOrchestrator:
|
class SystemOrchestrator:
|
||||||
"""
|
"""
|
||||||
全系统总编排:具备转接冷却、防抖合并、多消息去重、以及精准日志。
|
全系统总编排:具备转接冷却、防抖合并、多消息去重、以及精准日志。
|
||||||
@@ -22,19 +28,27 @@ class SystemOrchestrator:
|
|||||||
self.brain = CustomerServiceBrain()
|
self.brain = CustomerServiceBrain()
|
||||||
|
|
||||||
# 1. 消息 ID 去重
|
# 1. 消息 ID 去重
|
||||||
self._processed_msg_ids = deque(maxlen=200)
|
self._processed_msg_ids = deque(maxlen=MSG_DEDUP_CAPACITY)
|
||||||
|
|
||||||
# 2. 转接冷却存储 (customer_id -> last_transfer_time)
|
# 2. 转接冷却存储 (customer_id -> last_transfer_time)
|
||||||
self._last_transfer_time: Dict[str, float] = {}
|
self._last_transfer_time: Dict[str, float] = {}
|
||||||
|
|
||||||
# 3. 防抖配置
|
# 3. 防抖配置
|
||||||
self._debounce_seconds = 5.0
|
self._debounce_seconds = DEBOUNCE_SECONDS
|
||||||
self._debounce_tasks: Dict[str, asyncio.Task] = {}
|
self._debounce_tasks: Dict[str, asyncio.Task] = {}
|
||||||
self._pending_messages: Dict[str, List[StandardMessage]] = {}
|
self._pending_messages: Dict[str, List[StandardMessage]] = {}
|
||||||
self._user_locks: Dict[str, asyncio.Lock] = {}
|
self._user_locks: Dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
bus.subscribe("MESSAGE_OUTBOUND", self.handle_outbound_event)
|
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:
|
def _get_user_lock(self, user_id: str) -> asyncio.Lock:
|
||||||
if user_id not in self._user_locks:
|
if user_id not in self._user_locks:
|
||||||
self._user_locks[user_id] = asyncio.Lock()
|
self._user_locks[user_id] = asyncio.Lock()
|
||||||
@@ -47,18 +61,34 @@ class SystemOrchestrator:
|
|||||||
|
|
||||||
std_msg, direction = await self.qianniu_adapter.translate_inbound(raw_data)
|
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 not std_msg.content.strip() and not std_msg.image_urls: return
|
||||||
|
|
||||||
# 如果是商家人工回复,静默入库
|
# 如果是商家人工回复,静默入库
|
||||||
if direction == "out":
|
if direction == "out":
|
||||||
await repo.save_chat(platform, std_msg.user_id, std_msg.content, "out", acc_id=std_msg.acc_id)
|
await repo.save_chat(platform, user_id, std_msg.content, "out", acc_id=std_msg.acc_id)
|
||||||
return
|
|
||||||
|
|
||||||
# 订单消息处理:静默记录
|
|
||||||
if "[系统订单信息]" in std_msg.content:
|
|
||||||
await self._handle_order_packet(platform, std_msg)
|
|
||||||
await repo.save_chat(platform, std_msg.user_id, std_msg.content, "in", acc_id=std_msg.acc_id)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# ID 去重
|
# ID 去重
|
||||||
@@ -66,13 +96,12 @@ class SystemOrchestrator:
|
|||||||
if std_msg.msg_id in self._processed_msg_ids: return
|
if std_msg.msg_id in self._processed_msg_ids: return
|
||||||
self._processed_msg_ids.append(std_msg.msg_id)
|
self._processed_msg_ids.append(std_msg.msg_id)
|
||||||
|
|
||||||
# 进入防抖
|
# 进入防抖(使用 session_key 隔离不同店铺)
|
||||||
user_id = std_msg.user_id
|
if session_key in self._debounce_tasks: self._debounce_tasks[session_key].cancel()
|
||||||
if user_id in self._debounce_tasks: self._debounce_tasks[user_id].cancel()
|
if session_key not in self._pending_messages: self._pending_messages[session_key] = []
|
||||||
if user_id not in self._pending_messages: self._pending_messages[user_id] = []
|
self._pending_messages[session_key].append(std_msg)
|
||||||
self._pending_messages[user_id].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:
|
except Exception as e:
|
||||||
logger.error(f"[Orchestrator] 处理失败: {e}")
|
logger.error(f"[Orchestrator] 处理失败: {e}")
|
||||||
@@ -81,15 +110,74 @@ class SystemOrchestrator:
|
|||||||
try:
|
try:
|
||||||
price_match = re.search(r"订单金额:金额:\s*([\d\.]+)元", msg.content)
|
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 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")
|
# 判定成交结果(扩大范围:已付款 或 已发货 都视为成功,用于后期 AI 话术微调)
|
||||||
elif any(k in msg.content for k in ["退款", "已关闭", "已取消"]): await repo.update_task_outcome(platform, msg.user_id, "refunded")
|
if any(k in msg.content for k in ["买家已付款", "卖家已发货"]):
|
||||||
except Exception: pass
|
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:
|
try:
|
||||||
await asyncio.sleep(self._debounce_seconds)
|
await asyncio.sleep(self._debounce_seconds)
|
||||||
async with self._get_user_lock(user_id):
|
async with self._get_user_lock(session_key):
|
||||||
messages = self._pending_messages.pop(user_id, [])
|
messages = self._pending_messages.pop(session_key, [])
|
||||||
if not messages: return
|
if not messages: return
|
||||||
|
|
||||||
# A. 合并与元数据修复
|
# A. 合并与元数据修复
|
||||||
@@ -108,6 +196,7 @@ class SystemOrchestrator:
|
|||||||
msg_id=merged_msg_id,
|
msg_id=merged_msg_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
content=combined_content,
|
content=combined_content,
|
||||||
|
msg_type=messages[-1].msg_type,
|
||||||
image_urls=all_image_urls,
|
image_urls=all_image_urls,
|
||||||
acc_id=acc_id,
|
acc_id=acc_id,
|
||||||
acc_type=acc_type
|
acc_type=acc_type
|
||||||
@@ -116,17 +205,22 @@ class SystemOrchestrator:
|
|||||||
# B. 持久化
|
# B. 持久化
|
||||||
db_content = combined_content
|
db_content = combined_content
|
||||||
if all_image_urls: db_content = f"【系统:已收到{len(all_image_urls)}张图】\n{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秒内发过转接,告诉大脑“已处于转接中”
|
# C. 冷却检查:如果转接冷却期内发过转接,告诉大脑"已处于转接中"
|
||||||
is_in_cooldown = (time.time() - self._last_transfer_time.get(user_id, 0)) < 60
|
is_in_cooldown = (time.time() - self._last_transfer_time.get(session_key, 0)) < TRANSFER_COOLDOWN_SEC
|
||||||
|
|
||||||
# D. 思考
|
# 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 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}"
|
final_msg.content = f"【系统:当前已向设计师发出转接请求,请勿再次调用转接工具】\n{final_msg.content}"
|
||||||
|
|
||||||
std_res = await self.brain.think_and_reply(final_msg, history=history)
|
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)
|
await repo.save_chat(platform, user_id, std_res.reply_content, "out", acc_id=acc_id)
|
||||||
|
|
||||||
if "[转移会话]" in std_res.reply_content:
|
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 asyncio.CancelledError: pass
|
||||||
except Exception as e: logger.exception(f"[Orchestrator] 处理失败: {e}")
|
except Exception as e: logger.exception(f"[Orchestrator] 处理失败: {e}")
|
||||||
|
|||||||
@@ -11,6 +11,25 @@ logger = logging.getLogger("cs_agent")
|
|||||||
|
|
||||||
from core.skill_manager import skill_manager
|
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:
|
class CustomerServiceBrain:
|
||||||
"""
|
"""
|
||||||
重构后的单一 Agent 大脑:
|
重构后的单一 Agent 大脑:
|
||||||
@@ -27,27 +46,38 @@ class CustomerServiceBrain:
|
|||||||
provider=OpenAIProvider(api_key=self.api_key, base_url=self.base_url)
|
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 ---
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话高端、专业。\n\n"
|
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话自然、专业。\n\n"
|
||||||
|
|
||||||
"【统一称呼规范】\n"
|
"【统一称呼规范】\n"
|
||||||
"1. 严禁使用'师傅'、'客服'、'专员'等词汇!\n"
|
"1. 严禁使用'师傅'、'客服'、'专员'等词汇!必须统一称为【设计师】。\n"
|
||||||
"2. 必须统一称呼为【设计师】。比如:'找设计师看下'、'设计师马上来'、'等设计师核价'。\n\n"
|
"2. 未转接前,用第一人称(我/我这边)。例如:'我叫设计师看下'。\n\n"
|
||||||
|
|
||||||
"【核心逻辑】\n"
|
"【核心逻辑】\n"
|
||||||
"1. 业务:只聊高清修复和找原图。引导发图 -> 问需求 -> 找设计师。\n"
|
"1. 业务:只聊高清修复和找原图。核心链路:引导发图 -> 问需求 -> 找设计师。\n"
|
||||||
"2. 下线安抚:如果工具返回 'ERROR_NO_DESIGNER_ONLINE',说明设计师们【下班/下线】了。回:'亲亲,设计师现在下班啦,需求我先记下,明天第一时间回您哈!'。\n"
|
"2. **主动引导(关键)**:如果客户【没发图】就问能不能做、问收费,你必须回:'亲亲先发图我看下哈'。\n"
|
||||||
"3. 正在转接中:如果看到系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'。\n"
|
"3. **非业务问题**:如果客户问招聘、合作、闲聊等与做图无关的话题,礼貌拒绝:'亲亲咱这边只做图哦,暂不招人哈'。\n"
|
||||||
"4. 没转接时:引导发图 -> 问需求 -> 调工具转人工。\n\n"
|
"4. **客户说没有参考图**:如果客户明确说'没有图'、'找不到'、'想让你们帮找',直接转人工:'好的,我这就叫设计师帮您找哈'。\n"
|
||||||
"5. 语气:淘宝亲切风,多用'亲亲'、'铁子'。每句回复【严禁超过15字】!\n\n"
|
"5. **客户问尺寸/能否打印/退款**:这类问题需要设计师判断,直接转人工:'这个设计师帮您看下哈'。\n"
|
||||||
|
"6. 转接时机:收到图片并明确需求后,立即调用转人工工具,并告知:'收到,正在呼叫设计师核价,稍等哈'。\n"
|
||||||
"【必杀令】\n"
|
"7. **下线安抚(重要)**:只有当【本次】工具返回 'ERROR_NO_DESIGNER_ONLINE' 时才能说下班。不能根据历史对话或自己猜测说下班!\n"
|
||||||
"1. 每句回复严禁超过15个字!\n"
|
"8. 正在转接中:如果系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'。\n"
|
||||||
|
"9. **每次转接必须调用工具**:不要根据之前的结果猜测,每次需要转接都必须重新调用工具检查设计师是否在线。\n\n"
|
||||||
|
|
||||||
|
"【必杀令 - 严格遵守】\n"
|
||||||
|
"1. 每句回复严禁超过15个字!语气淘宝亲切风,多用'哈'、'呢'。\n"
|
||||||
"2. 严禁报价,严禁复读图片已收到的情况。\n"
|
"2. 严禁报价,严禁复读图片已收到的情况。\n"
|
||||||
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n\n"
|
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n"
|
||||||
|
"4. **严禁**说'在呢铁子'!只能说'在呢'或'在呢亲'。\n"
|
||||||
|
"5. **严禁**重复发送相同内容!如果刚说过的话,换一种说法。\n"
|
||||||
|
"6. **严禁**输出任何代码、标记、括号等乱码!只输出自然语言。\n"
|
||||||
|
"7. **严禁**自己臆造'下班'!只有工具返回ERROR才能说下班。\n\n"
|
||||||
|
|
||||||
f"业务参考:\n{all_skills}"
|
f"业务参考:\n{all_skills}"
|
||||||
)
|
)
|
||||||
@@ -57,26 +87,70 @@ class CustomerServiceBrain:
|
|||||||
|
|
||||||
async def think_and_reply(self, msg: StandardMessage, history: List[dict] = []) -> StandardResponse:
|
async def think_and_reply(self, msg: StandardMessage, history: List[dict] = []) -> StandardResponse:
|
||||||
try:
|
try:
|
||||||
# 构造增强上下文(强灌输)
|
# 构造增强上下文
|
||||||
user_content = msg.content
|
user_content = msg.content
|
||||||
if msg.image_urls:
|
if msg.image_urls:
|
||||||
user_content = f"【系统通知:收到客户 {len(msg.image_urls)} 张图】\n{user_content}"
|
user_content = f"【系统通知:收到客户 {len(msg.image_urls)} 张图】\n{user_content}"
|
||||||
|
|
||||||
recent_context = ""
|
recent_context = ""
|
||||||
if history:
|
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"
|
recent_context = "【近期对话回顾】\n" + "\n".join(lines) + "\n----------------\n"
|
||||||
|
|
||||||
full_input = f"{recent_context}现在的对话:{user_content}"
|
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)
|
result = await self.agent.run(full_input, message_history=history)
|
||||||
|
|
||||||
if hasattr(result, 'data') and isinstance(result.data, str):
|
# --- 终极修复:强制截获工具返回的转接指令 ---
|
||||||
reply_text = result.data
|
reply_text = ""
|
||||||
elif hasattr(result, 'output') and isinstance(result.output, str):
|
# pydantic-ai 1.x 使用 result.output(旧版 0.x 使用 result.data)
|
||||||
reply_text = result.output
|
raw_output = getattr(result, 'output', None) or getattr(result, 'data', None)
|
||||||
else:
|
if isinstance(raw_output, str):
|
||||||
reply_text = str(result.data) if hasattr(result, 'data') else "在呢铁子。"
|
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'<think[^>]*>.*', '', reply_text, flags=re.DOTALL) # 清理 <think_xxx>内部思考泄漏
|
||||||
|
reply_text = re.sub(r'</?think[^>]*>', '', reply_text) # 清理 think 标签
|
||||||
|
reply_text = re.sub(r'```[^`]*```', '', reply_text) # 清理代码块
|
||||||
|
reply_text = re.sub(r'\{["\'][^}]+\}', '', reply_text) # 清理 JSON
|
||||||
|
reply_text = 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
|
need_transfer = "[转移会话]" in reply_text
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
return await asyncio.to_thread(
|
||||||
log_message,
|
log_message,
|
||||||
customer_id=user_id,
|
customer_id=user_id,
|
||||||
message=content,
|
message=content,
|
||||||
direction=direction,
|
direction=direction,
|
||||||
platform=platform,
|
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 = []
|
history = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
role = "user" if r["direction"] == "in" else "assistant"
|
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
|
return history
|
||||||
|
|
||||||
# --- 客户相关 (异步化) ---
|
# --- 客户相关 (异步化) ---
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class StandardMessage(BaseModel):
|
|||||||
user_id: str # 发送者唯一ID
|
user_id: str # 发送者唯一ID
|
||||||
user_name: str = "" # 发送者昵称
|
user_name: str = "" # 发送者昵称
|
||||||
content: str # 消息文本内容
|
content: str # 消息文本内容
|
||||||
|
msg_type: int = 0 # 消息类型:0 文本, 1 图片, 2 语音等
|
||||||
image_urls: List[str] = [] # 提取出来的图片链接
|
image_urls: List[str] = [] # 提取出来的图片链接
|
||||||
acc_id: str = "" # 商家/店铺账号ID
|
acc_id: str = "" # 商家/店铺账号ID
|
||||||
acc_type: str = "" # 平台类型标识
|
acc_type: str = "" # 平台类型标识
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ class SkillManager:
|
|||||||
parts.append(f"### 技能:{name}\n{content}")
|
parts.append(f"### 技能:{name}\n{content}")
|
||||||
return "\n\n".join(parts)
|
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()
|
skill_manager = SkillManager()
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
from datetime import datetime
|
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
|
from db.task_db.task_model import get_task_manager, TaskStatus, TaskPriority
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 配置常量
|
||||||
|
TIMEOUT_CHECK_INTERVAL_SEC = 300 # 超时检查间隔(5分钟)
|
||||||
|
ERROR_RETRY_DELAY_SEC = 60 # 错误后重试延迟(1分钟)
|
||||||
|
QUEUE_POLL_INTERVAL_SEC = 1 # 队列轮询间隔(秒)
|
||||||
|
|
||||||
class TaskScheduler:
|
class TaskScheduler:
|
||||||
"""任务调度器"""
|
"""任务调度器"""
|
||||||
|
|
||||||
@@ -54,14 +59,14 @@ class TaskScheduler:
|
|||||||
# 通知天网任务超时
|
# 通知天网任务超时
|
||||||
await self._notify_tianwang(task['task_id'], 'timeout')
|
await self._notify_tianwang(task['task_id'], 'timeout')
|
||||||
|
|
||||||
# 每 5 分钟检查一次
|
# 每隔固定时间检查一次
|
||||||
await asyncio.sleep(300)
|
await asyncio.sleep(TIMEOUT_CHECK_INTERVAL_SEC)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"超时检查失败:{e}")
|
logger.error(f"超时检查失败:{e}")
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(ERROR_RETRY_DELAY_SEC)
|
||||||
|
|
||||||
async def _process_task_queue(self):
|
async def _process_task_queue(self):
|
||||||
"""处理任务队列"""
|
"""处理任务队列"""
|
||||||
@@ -69,8 +74,8 @@ class TaskScheduler:
|
|||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
# 这里实际应该从队列获取任务
|
# 这里实际应该从队列获取任务
|
||||||
# 简化处理:每秒检查一次待触发任务
|
# 简化处理:定期检查待触发任务
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(QUEUE_POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"任务队列处理失败:{e}")
|
logger.error(f"任务队列处理失败:{e}")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class QingjianAPIClient:
|
|||||||
重构后的轻简API客户端 (协议全复刻版)
|
重构后的轻简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
|
from config.config import QINGJIAN_WS_URI
|
||||||
self.uri = uri or QINGJIAN_WS_URI
|
self.uri = uri or QINGJIAN_WS_URI
|
||||||
self.websocket = None
|
self.websocket = None
|
||||||
@@ -23,6 +23,12 @@ class QingjianAPIClient:
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.enable_agent = enable_agent
|
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)
|
self.orchestrator = init_orchestrator(ws_client=self)
|
||||||
logger.info("[WebSocket] 新架构 Orchestrator 已就绪。")
|
logger.info("[WebSocket] 新架构 Orchestrator 已就绪。")
|
||||||
@@ -36,13 +42,35 @@ class QingjianAPIClient:
|
|||||||
async def receive_messages(self):
|
async def receive_messages(self):
|
||||||
await receive_messages_flow(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):
|
async def handle_message(self, message):
|
||||||
"""收到消息处理"""
|
"""收到消息处理"""
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
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)
|
await self.orchestrator.on_raw_message_received(platform="qianniu", raw_data=data)
|
||||||
except Exception as e:
|
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):
|
async def send(self, customer_id: str, acc_id: str, acc_type: str, content: str, msg_type: int = 0):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ def init_db():
|
|||||||
conn.execute("CREATE INDEX idx_ts ON chat_logs(timestamp)")
|
conn.execute("CREATE INDEX idx_ts ON chat_logs(timestamp)")
|
||||||
if "idx_acc" not in exists:
|
if "idx_acc" not in exists:
|
||||||
conn.execute("CREATE INDEX idx_acc ON chat_logs(acc_id)")
|
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:
|
else:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS chat_logs (
|
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 ''")
|
conn.execute("ALTER TABLE chat_logs ADD COLUMN acc_id TEXT DEFAULT ''")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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.execute("CREATE INDEX IF NOT EXISTS idx_acc ON chat_logs(acc_id)")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -150,15 +159,16 @@ def log_message(
|
|||||||
acc_id: str = "", # 店铺账号ID
|
acc_id: str = "", # 店铺账号ID
|
||||||
platform: str = "",
|
platform: str = "",
|
||||||
msg_type: int = 0,
|
msg_type: int = 0,
|
||||||
|
image_urls: str = "", # 图片URL列表,用\n分隔
|
||||||
):
|
):
|
||||||
"""记录一条聊天消息"""
|
"""记录一条聊天消息"""
|
||||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
with _get_conn() as conn:
|
with _get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
_sql("INSERT INTO chat_logs "
|
_sql("INSERT INTO chat_logs "
|
||||||
"(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp) "
|
"(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp, image_urls) "
|
||||||
"VALUES (?,?,?,?,?,?,?,?)"),
|
"VALUES (?,?,?,?,?,?,?,?,?)"),
|
||||||
(customer_id, customer_name, acc_id, platform, direction, message, msg_type, ts),
|
(customer_id, customer_name, acc_id, platform, direction, message, msg_type, ts, image_urls),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -198,10 +208,10 @@ def get_customers(limit: int = 100) -> List[Dict]:
|
|||||||
return [dict(r) for r in rows]
|
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:
|
with _get_conn() as conn:
|
||||||
# 核心修复:先取最新的 limit 条,再按时间正序排列
|
|
||||||
rows = conn.execute(_sql("""
|
rows = conn.execute(_sql("""
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT id, direction, message, msg_type, timestamp, acc_id
|
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]:
|
def get_recent_conversation(customer_id: str, acc_id: str = "", limit: int = 10) -> List[Dict]:
|
||||||
"""返回某客户近期对话(同店铺),用于企微推送保持连贯"""
|
"""返回某客户近期对话,忽略 acc_id 过滤"""
|
||||||
with _get_conn() as conn:
|
with _get_conn() as conn:
|
||||||
if acc_id:
|
rows = conn.execute(_sql("""
|
||||||
rows = conn.execute(_sql("""
|
SELECT id, direction, message, timestamp, acc_id
|
||||||
SELECT id, direction, message, timestamp, acc_id
|
FROM chat_logs
|
||||||
FROM chat_logs
|
WHERE customer_id = ?
|
||||||
WHERE customer_id = ? AND acc_id = ?
|
ORDER BY id DESC
|
||||||
ORDER BY id DESC
|
LIMIT ?
|
||||||
LIMIT ?
|
"""), (customer_id, limit)).fetchall()
|
||||||
"""), (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()
|
|
||||||
out = [dict(r) for r in reversed(rows)]
|
out = [dict(r) for r in reversed(rows)]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ class CustomerProfile:
|
|||||||
last_gemini_prompt: str = "" # 最近一次图片的 Gemini 处理提示词
|
last_gemini_prompt: str = "" # 最近一次图片的 Gemini 处理提示词
|
||||||
last_aspect_ratio: str = "1:1" # 最近一次图片的建议输出比例
|
last_aspect_ratio: str = "1:1" # 最近一次图片的建议输出比例
|
||||||
last_perspective: str = "no" # 最近一次图片的透视状态
|
last_perspective: str = "no" # 最近一次图片的透视状态
|
||||||
|
last_image_analysis: str = "" # 最近一次图片分析结果(JSON字符串,用于数据标定)
|
||||||
|
image_analysis_history: List[str] = None # 图片分析历史记录(JSON列表,用于数据标定)
|
||||||
pending_quote_images: List[str] = None # 待统一报价图片队列(持久化)
|
pending_quote_images: List[str] = None # 待统一报价图片队列(持久化)
|
||||||
pending_quote_requirements: List[str] = None # 待统一报价需求队列(持久化)
|
pending_quote_requirements: List[str] = None # 待统一报价需求队列(持久化)
|
||||||
|
|
||||||
@@ -165,6 +167,8 @@ class CustomerProfile:
|
|||||||
self.pending_quote_images = []
|
self.pending_quote_images = []
|
||||||
if self.pending_quote_requirements is None:
|
if self.pending_quote_requirements is None:
|
||||||
self.pending_quote_requirements = []
|
self.pending_quote_requirements = []
|
||||||
|
if self.image_analysis_history is None:
|
||||||
|
self.image_analysis_history = []
|
||||||
|
|
||||||
|
|
||||||
class CustomerDatabase:
|
class CustomerDatabase:
|
||||||
|
|||||||
@@ -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_status ON image_tasks(status)')
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cust_plat ON image_tasks(customer_id, platform)')
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -88,6 +95,27 @@ class ImageTaskManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update task status: {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):
|
def update_outcome(self, customer_id: str, platform: str, outcome: str):
|
||||||
"""记录任务的最终结局(用于训练样本分类)"""
|
"""记录任务的最终结局(用于训练样本分类)"""
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
|
|||||||
@@ -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 图像生成提示词专家。
|
|
||||||
请仔细分析这张图片,输出以下字段,每行一个,不要多余内容:
|
|
||||||
|
|
||||||
敏感内容: <yes|no>
|
|
||||||
平整度: <flat|mild|rough>
|
|
||||||
含文字: <yes|no>
|
|
||||||
含人脸: <yes|no>
|
|
||||||
阴影: <yes|no>
|
|
||||||
复杂度: <simple|normal|complex|hard>
|
|
||||||
原因: <15字以内,说明复杂度判断依据>
|
|
||||||
主体: <图片核心内容,如:印花图案/logo/人物/产品/老照片/风景/文字/其他>
|
|
||||||
类型: <处理类型,如:印花提取/高清修复/去背景/老照片修复/logo提取/人像修复/其他>
|
|
||||||
质量: <原图质量,如:清晰/轻微模糊/严重模糊/低分辨率/截图/扫描件>
|
|
||||||
可做: <yes|partial|no>
|
|
||||||
风险: <none|low|high>
|
|
||||||
透视: <no|mild|strong>
|
|
||||||
比例: <从以下选一个最合适的: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()
|
|
||||||
@@ -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, ""
|
|
||||||
@@ -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()
|
|
||||||
@@ -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>
|
|
||||||
结论: <pass|fail>
|
|
||||||
问题: <简述主要问题,不超过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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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))
|
|
||||||
@@ -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")
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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 ""
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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**、<font color="...">、> 引用、``` 代码块、- 列表
|
|
||||||
不支持:<details>、<summary>、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"- <font color=\"info\">{label}</font> "
|
|
||||||
f"接待 **{s['unique_customers']}** 人 · "
|
|
||||||
f"消息 {s['total_msgs']} 条(收{s['recv']}/发{s['sent']})"
|
|
||||||
f" {first}~{last}"
|
|
||||||
)
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"<font color=\"comment\">发送时间:{datetime.now().strftime('%H:%M:%S')}</font>")
|
|
||||||
|
|
||||||
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(不支持 <details>,用标准语法)──
|
|
||||||
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)
|
|
||||||
@@ -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]
|
|
||||||
@@ -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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""Self-evolution MVP utilities for the customer service agent."""
|
|
||||||
|
|
||||||
@@ -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],
|
|
||||||
}
|
|
||||||
@@ -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 注册
|
|
||||||
@@ -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 个+信号) | 直接拒绝 |
|
|
||||||
| 敏感内容 | 直接拒绝 |
|
|
||||||
|
|
||||||
@@ -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/` 回归,再扩大流量。
|
|
||||||
@@ -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. **坚持原则**:客户嫌贵也要说明原因
|
|
||||||
|
|
||||||
@@ -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. 发送图片测试
|
|
||||||
# 观察日志中的上传结果和话术
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
|
|
||||||
@@ -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(
|
|
||||||
"您好!收到您的邮件。<br><br>"
|
|
||||||
"请将您需要处理的图片作为<b>附件</b>发送过来,我们会尽快为您报价。<br><br>"
|
|
||||||
"支持格式: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},建议报价 <b>{price} 元</b>"
|
|
||||||
+ (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"<br><br>📦 您共发来 <b>{n} 张</b>图片,支持打包优惠,欢迎咨询。"
|
|
||||||
elif n >= 3:
|
|
||||||
tip = f"<br><br>📦 您共发来 <b>{n} 张</b>图片,3张以上可享9折优惠。"
|
|
||||||
else:
|
|
||||||
tip = ""
|
|
||||||
|
|
||||||
quote_html = "<br>".join(quotes)
|
|
||||||
body = self._html(
|
|
||||||
f"您好!感谢您发来图片,已为您完成分析:<br><br>"
|
|
||||||
f"{quote_html}{tip}<br><br>"
|
|
||||||
f"如需处理,请直接在淘宝店铺下单,付款后我们会尽快为您完成制作并发回。<br>"
|
|
||||||
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"""
|
|
||||||
<html><body style="font-family:Arial,sans-serif;font-size:14px;color:#333">
|
|
||||||
{content}
|
|
||||||
<br><br>
|
|
||||||
<hr style="border:none;border-top:1px solid #eee">
|
|
||||||
<p style="color:#999;font-size:12px">修图客服 · 自动回复</p>
|
|
||||||
</body></html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# ========== 全局实例(从 .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")),
|
|
||||||
)
|
|
||||||
@@ -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'<image{idx}>')
|
|
||||||
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"""
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<h2>您好 {customer_name},您的修图作品已完成!</h2>
|
|
||||||
<p>感谢您选择我们的服务。以下是您处理后的图片:</p>
|
|
||||||
<p><b>处理内容:</b> {image_description}</p>
|
|
||||||
<br>
|
|
||||||
<p>如有任何问题,请随时联系我们。</p>
|
|
||||||
<br>
|
|
||||||
<p>祝您生活愉快!</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.send(to_email, subject, body, result_images)
|
|
||||||
|
|
||||||
|
|
||||||
# 全局实例
|
|
||||||
email_sender = EmailSender()
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 855 KiB |
|
Before Width: | Height: | Size: 810 KiB |
|
Before Width: | Height: | Size: 883 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -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)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .engine import Rule, RuleContext, RuleEngine, RuleResult
|
|
||||||
|
|
||||||
__all__ = ["Rule", "RuleContext", "RuleEngine", "RuleResult"]
|
|
||||||
@@ -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")
|
|
||||||
@@ -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])
|
|
||||||
@@ -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"""
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>聊天记录</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
||||||
background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
|
|
||||||
|
|
||||||
/* ── 顶栏 ── */
|
|
||||||
.topbar {
|
|
||||||
background: #16213e; border-bottom: 1px solid #0f3460;
|
|
||||||
padding: 12px 20px; display: flex; align-items: center; gap: 16px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.topbar h1 { font-size: 16px; color: #4cc9f0; font-weight: 600; letter-spacing: 1px; }
|
|
||||||
.search-box {
|
|
||||||
flex: 1; max-width: 320px;
|
|
||||||
background: #0f3460; border: 1px solid #1a5276;
|
|
||||||
border-radius: 20px; padding: 6px 14px;
|
|
||||||
color: #e0e0e0; font-size: 13px; outline: none;
|
|
||||||
}
|
|
||||||
.search-box::placeholder { color: #6b7a99; }
|
|
||||||
.live-badge {
|
|
||||||
margin-left: auto; font-size: 11px; background: #0d7377;
|
|
||||||
color: #14ffec; padding: 3px 10px; border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 主体 ── */
|
|
||||||
.main { display: flex; flex: 1; overflow: hidden; }
|
|
||||||
|
|
||||||
/* ── 左侧客户列表 ── */
|
|
||||||
.sidebar {
|
|
||||||
width: 280px; background: #16213e;
|
|
||||||
border-right: 1px solid #0f3460;
|
|
||||||
display: flex; flex-direction: column; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 10px 14px; font-size: 12px; color: #6b7a99;
|
|
||||||
border-bottom: 1px solid #0f3460; flex-shrink: 0;
|
|
||||||
display: flex; justify-content: space-between;
|
|
||||||
}
|
|
||||||
.customer-list { overflow-y: auto; flex: 1; }
|
|
||||||
.customer-item {
|
|
||||||
padding: 12px 14px; cursor: pointer; border-bottom: 1px solid #0f3460;
|
|
||||||
transition: background .15s; position: relative;
|
|
||||||
}
|
|
||||||
.customer-item:hover { background: #1e3a5f; }
|
|
||||||
.customer-item.active { background: #0f3460; border-left: 3px solid #4cc9f0; }
|
|
||||||
.customer-item .name { font-size: 13px; font-weight: 500; color: #cce; }
|
|
||||||
.customer-item .cid { font-size: 11px; color: #6b7a99; margin-top: 2px; }
|
|
||||||
.customer-item .meta { font-size: 11px; color: #8899aa; margin-top: 4px;
|
|
||||||
display: flex; justify-content: space-between; }
|
|
||||||
.badge-plat {
|
|
||||||
font-size: 10px; padding: 1px 6px; border-radius: 8px;
|
|
||||||
background: #1a3a5c; color: #4cc9f0;
|
|
||||||
}
|
|
||||||
.badge-plat.ali { background: #3d1a00; color: #ff9f43; }
|
|
||||||
.badge-plat.email { background: #0a2e1a; color: #55efc4; }
|
|
||||||
|
|
||||||
/* ── 右侧对话区 ── */
|
|
||||||
.chat-panel {
|
|
||||||
flex: 1; display: flex; flex-direction: column; overflow: hidden;
|
|
||||||
}
|
|
||||||
.chat-header {
|
|
||||||
padding: 12px 20px; background: #16213e;
|
|
||||||
border-bottom: 1px solid #0f3460; flex-shrink: 0;
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
}
|
|
||||||
.chat-header .cname { font-size: 15px; font-weight: 600; color: #e0e0e0; }
|
|
||||||
.chat-header .cid { font-size: 12px; color: #6b7a99; }
|
|
||||||
.chat-header .stats { margin-left: auto; font-size: 12px; color: #6b7a99; }
|
|
||||||
|
|
||||||
.chat-messages {
|
|
||||||
flex: 1; overflow-y: auto; padding: 20px;
|
|
||||||
display: flex; flex-direction: column; gap: 12px;
|
|
||||||
}
|
|
||||||
.day-divider {
|
|
||||||
text-align: center; font-size: 11px; color: #6b7a99;
|
|
||||||
position: relative; margin: 8px 0;
|
|
||||||
}
|
|
||||||
.day-divider::before, .day-divider::after {
|
|
||||||
content: ""; position: absolute; top: 50%;
|
|
||||||
width: 38%; height: 1px; background: #0f3460;
|
|
||||||
}
|
|
||||||
.day-divider::before { left: 0; }
|
|
||||||
.day-divider::after { right: 0; }
|
|
||||||
|
|
||||||
/* 消息气泡 */
|
|
||||||
.msg-row { display: flex; align-items: flex-end; gap: 8px; max-width: 72%; }
|
|
||||||
.msg-row.in { align-self: flex-start; }
|
|
||||||
.msg-row.out { align-self: flex-end; flex-direction: row-reverse; }
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 34px; height: 34px; border-radius: 50%;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 13px; font-weight: 600; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.avatar.buyer { background: #2d4a7a; color: #90caf9; }
|
|
||||||
.avatar.seller { background: #1a6644; color: #a8e6cf; }
|
|
||||||
|
|
||||||
.bubble-wrap { display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.msg-row.out .bubble-wrap { align-items: flex-end; }
|
|
||||||
|
|
||||||
.bubble {
|
|
||||||
padding: 9px 13px; border-radius: 16px;
|
|
||||||
font-size: 13px; line-height: 1.55; word-break: break-word;
|
|
||||||
max-width: 480px;
|
|
||||||
}
|
|
||||||
.bubble.in { background: #1e3a5f; color: #dce8f8; border-bottom-left-radius: 4px; }
|
|
||||||
.bubble.out { background: #1a6644; color: #d4f5e7; border-bottom-right-radius: 4px; }
|
|
||||||
.bubble img { max-width: 200px; border-radius: 8px; display: block; margin-top: 4px; }
|
|
||||||
|
|
||||||
.msg-time { font-size: 10px; color: #6b7a99; padding: 0 4px; }
|
|
||||||
|
|
||||||
/* 空状态 */
|
|
||||||
.empty-state {
|
|
||||||
flex: 1; display: flex; flex-direction: column;
|
|
||||||
align-items: center; justify-content: center; color: #6b7a99; gap: 10px;
|
|
||||||
}
|
|
||||||
.empty-state .icon { font-size: 48px; opacity: .3; }
|
|
||||||
.empty-state p { font-size: 14px; }
|
|
||||||
|
|
||||||
/* 搜索结果覆盖层 */
|
|
||||||
#search-overlay {
|
|
||||||
display: none; position: absolute; top: 52px; left: 0; right: 0; bottom: 0;
|
|
||||||
background: #1a1a2e; z-index: 10; overflow-y: auto; padding: 16px 20px;
|
|
||||||
}
|
|
||||||
.search-hit {
|
|
||||||
padding: 10px 14px; margin-bottom: 8px;
|
|
||||||
background: #16213e; border-radius: 10px; cursor: pointer;
|
|
||||||
border-left: 3px solid #4cc9f0;
|
|
||||||
}
|
|
||||||
.search-hit:hover { background: #1e3a5f; }
|
|
||||||
.search-hit .hit-cid { font-size: 11px; color: #4cc9f0; }
|
|
||||||
.search-hit .hit-msg { font-size: 13px; color: #e0e0e0; margin-top: 4px; }
|
|
||||||
.search-hit .hit-time { font-size: 11px; color: #6b7a99; margin-top: 3px; }
|
|
||||||
mark { background: transparent; color: #f9ca24; font-weight: 600; }
|
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 4px; }
|
|
||||||
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 2px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="topbar">
|
|
||||||
<h1>💬 聊天记录</h1>
|
|
||||||
<input id="searchInput" class="search-box" placeholder="搜索消息内容..." autocomplete="off">
|
|
||||||
<span class="live-badge" id="liveBadge">● 实时</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main" style="position:relative;">
|
|
||||||
<!-- 搜索覆盖层 -->
|
|
||||||
<div id="search-overlay"></div>
|
|
||||||
|
|
||||||
<!-- 左侧客户列表 -->
|
|
||||||
<div class="sidebar">
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<span id="customerCount">客户</span>
|
|
||||||
<span id="lastRefresh"></span>
|
|
||||||
</div>
|
|
||||||
<div class="customer-list" id="customerList"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧对话 -->
|
|
||||||
<div class="chat-panel" id="chatPanel">
|
|
||||||
<div class="empty-state" id="emptyState">
|
|
||||||
<div class="icon">💬</div>
|
|
||||||
<p>选择一位客户查看对话记录</p>
|
|
||||||
</div>
|
|
||||||
<div id="chatHeader" class="chat-header" style="display:none;">
|
|
||||||
<div>
|
|
||||||
<div class="cname" id="headerName"></div>
|
|
||||||
<div class="cid" id="headerId"></div>
|
|
||||||
</div>
|
|
||||||
<div class="stats" id="headerStats"></div>
|
|
||||||
</div>
|
|
||||||
<div class="chat-messages" id="chatMessages" style="display:none;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let currentCid = null;
|
|
||||||
let autoRefresh = null;
|
|
||||||
let allCustomers = [];
|
|
||||||
|
|
||||||
// ── 时间格式化 ──
|
|
||||||
function fmtTime(ts) {
|
|
||||||
if (!ts) return '';
|
|
||||||
const today = new Date().toISOString().slice(0,10);
|
|
||||||
return ts.startsWith(today) ? ts.slice(11,16) : ts.slice(5,16);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 平台徽章 ──
|
|
||||||
function platBadge(p) {
|
|
||||||
const map = {
|
|
||||||
AliWorkbench: ['ali','淘宝'],
|
|
||||||
taobao: ['ali','淘宝'],
|
|
||||||
pinduoduo: ['','拼多多'],
|
|
||||||
jd: ['','京东'],
|
|
||||||
email: ['email','邮件'],
|
|
||||||
};
|
|
||||||
const [cls, label] = map[p] || ['', p || ''];
|
|
||||||
return label ? `<span class="badge-plat ${cls}">${label}</span>` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 加载客户列表 ──
|
|
||||||
async function loadCustomers() {
|
|
||||||
const r = await fetch('/api/customers');
|
|
||||||
allCustomers = await r.json();
|
|
||||||
renderCustomers(allCustomers);
|
|
||||||
document.getElementById('customerCount').textContent = `客户 ${allCustomers.length} 人`;
|
|
||||||
document.getElementById('lastRefresh').textContent = new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCustomers(list) {
|
|
||||||
const el = document.getElementById('customerList');
|
|
||||||
el.innerHTML = list.map(c => {
|
|
||||||
const active = c.customer_id === currentCid ? 'active' : '';
|
|
||||||
const name = c.customer_name || c.customer_id.slice(-8);
|
|
||||||
return `<div class="customer-item ${active}" onclick="openChat('${c.customer_id}','${(c.customer_name||'').replace(/'/g,"\\'")}','${c.platform||''}',${c.total_msgs},${c.recv},${c.sent})">
|
|
||||||
<div class="name">${name} ${platBadge(c.platform)}</div>
|
|
||||||
<div class="cid">${c.customer_id}</div>
|
|
||||||
<div class="meta"><span>${c.total_msgs} 条消息</span><span>${fmtTime(c.last_time)}</span></div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 打开对话 ──
|
|
||||||
async function openChat(cid, name, platform, total, recv, sent) {
|
|
||||||
currentCid = cid;
|
|
||||||
renderCustomers(allCustomers);
|
|
||||||
|
|
||||||
document.getElementById('emptyState').style.display = 'none';
|
|
||||||
document.getElementById('chatHeader').style.display = 'flex';
|
|
||||||
document.getElementById('chatMessages').style.display = 'flex';
|
|
||||||
document.getElementById('headerName').textContent = name || cid;
|
|
||||||
document.getElementById('headerId').textContent = cid;
|
|
||||||
document.getElementById('headerStats').textContent = `共 ${total} 条 收 ${recv} 发 ${sent}`;
|
|
||||||
|
|
||||||
await loadConversation(cid);
|
|
||||||
if (autoRefresh) clearInterval(autoRefresh);
|
|
||||||
autoRefresh = setInterval(() => loadConversation(cid), 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 加载对话 ──
|
|
||||||
async function loadConversation(cid) {
|
|
||||||
const r = await fetch(`/api/conversation/${encodeURIComponent(cid)}`);
|
|
||||||
const msgs = await r.json();
|
|
||||||
renderMessages(msgs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMessages(msgs) {
|
|
||||||
const el = document.getElementById('chatMessages');
|
|
||||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
|
|
||||||
|
|
||||||
let lastDate = '';
|
|
||||||
const html = msgs.map(m => {
|
|
||||||
const date = (m.timestamp || '').slice(0,10);
|
|
||||||
let divider = '';
|
|
||||||
if (date && date !== lastDate) { divider = `<div class="day-divider">${date}</div>`; lastDate = date; }
|
|
||||||
|
|
||||||
const dir = m.direction;
|
|
||||||
const avatarChar = dir === 'in' ? '买' : '客';
|
|
||||||
const avatarCls = dir === 'in' ? 'buyer' : 'seller';
|
|
||||||
const content = renderMsgContent(m.message, m.msg_type);
|
|
||||||
|
|
||||||
return `${divider}
|
|
||||||
<div class="msg-row ${dir}">
|
|
||||||
<div class="avatar ${avatarCls}">${avatarChar}</div>
|
|
||||||
<div class="bubble-wrap">
|
|
||||||
<div class="bubble ${dir}">${content}</div>
|
|
||||||
<div class="msg-time">${fmtTime(m.timestamp)}</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
el.innerHTML = html;
|
|
||||||
if (atBottom) el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMsgContent(msg, msgType) {
|
|
||||||
if (!msg) return '';
|
|
||||||
const urlRegGlobal = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/gi;
|
|
||||||
const urlRegSingle = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/i;
|
|
||||||
const parts = msg.split('#*#').map(s => s.trim()).filter(Boolean);
|
|
||||||
if (parts.length > 1) {
|
|
||||||
const segs = parts.map(p => {
|
|
||||||
const m = p.match(urlRegSingle);
|
|
||||||
if (m) {
|
|
||||||
const url = m[0];
|
|
||||||
return `<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`;
|
|
||||||
}
|
|
||||||
const esc = p.replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
return esc;
|
|
||||||
});
|
|
||||||
return segs.join('<br>');
|
|
||||||
}
|
|
||||||
const escaped = msg.replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
return escaped.replace(urlRegGlobal, (url) =>
|
|
||||||
`<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 搜索 ──
|
|
||||||
let searchTimer = null;
|
|
||||||
document.getElementById('searchInput').addEventListener('input', function() {
|
|
||||||
clearTimeout(searchTimer);
|
|
||||||
const kw = this.value.trim();
|
|
||||||
if (!kw) { closeSearch(); return; }
|
|
||||||
searchTimer = setTimeout(() => doSearch(kw), 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function doSearch(kw) {
|
|
||||||
const r = await fetch(`/api/search?q=${encodeURIComponent(kw)}`);
|
|
||||||
const results = await r.json();
|
|
||||||
const overlay = document.getElementById('search-overlay');
|
|
||||||
overlay.style.display = 'block';
|
|
||||||
|
|
||||||
if (!results.length) {
|
|
||||||
overlay.innerHTML = `<p style="color:#6b7a99;text-align:center;margin-top:60px;">未找到匹配消息</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hi = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
const re = new RegExp(hi, 'gi');
|
|
||||||
overlay.innerHTML = results.map(r => {
|
|
||||||
const dir = r.direction === 'in' ? '买家' : '客服';
|
|
||||||
const msg = r.message.replace(/</g,'<').replace(re, m => `<mark>${m}</mark>`);
|
|
||||||
return `<div class="search-hit" onclick="closeSearch(); openChat('${r.customer_id}','','','','','')">
|
|
||||||
<div class="hit-cid">${r.customer_id} ${r.customer_name||''} · ${dir}</div>
|
|
||||||
<div class="hit-msg">${msg}</div>
|
|
||||||
<div class="hit-time">${(r.timestamp||'').slice(0,16)}</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSearch() {
|
|
||||||
document.getElementById('search-overlay').style.display = 'none';
|
|
||||||
document.getElementById('searchInput').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 初始化 ──
|
|
||||||
loadCustomers();
|
|
||||||
setInterval(loadCustomers, 10000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
PRICING_HTML = r"""
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>AI 报价测试</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
||||||
background: #1a1a2e; color: #e0e0e0; padding: 20px; }
|
|
||||||
.card { background:#16213e; border:1px solid #0f3460; border-radius:12px; padding:16px; max-width:880px; margin:0 auto; }
|
|
||||||
.title { font-size:16px; color:#4cc9f0; margin-bottom:12px; }
|
|
||||||
.row { display:flex; gap:12px; margin-bottom:10px; }
|
|
||||||
.row .col { flex:1; }
|
|
||||||
.input { width:100%; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:10px 12px; color:#e0e0e0; font-size:13px; outline:none; }
|
|
||||||
.input::placeholder { color:#6b7a99; }
|
|
||||||
.btn { background:#0d7377; color:#14ffec; border:none; border-radius:10px; padding:10px 16px; cursor:pointer; font-size:13px; }
|
|
||||||
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
|
||||||
.result { margin-top:14px; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:12px; font-size:13px; white-space:pre-wrap; }
|
|
||||||
.tip { font-size:12px; color:#6b7a99; margin-top:6px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="title">🧪 AI 报价测试</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<input id="cid" class="input" placeholder="客户ID,如 tb7518056865:小林">
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<input id="acc" class="input" placeholder="店铺ID(可留空)">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<textarea id="msg" class="input" rows="4" placeholder="输入消息文本或图片URL(多张用 #*# 分隔)。示例:这两张有原图吗#*#https://...jpg#*#https://...png"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<button class="btn" id="runBtn" onclick="runPricing()">测试报价</button>
|
|
||||||
</div>
|
|
||||||
<div id="result" class="result" style="display:none;"></div>
|
|
||||||
<div class="tip">提示:含图片URL时,Agent会自动调用图片分析并结合复杂度、尺寸、人脸与风险给出建议价;文本砍价低于最近图片底线会被礼貌拒绝。</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
async function runPricing() {
|
|
||||||
const cid = document.getElementById('cid').value.trim();
|
|
||||||
const acc = document.getElementById('acc').value.trim();
|
|
||||||
const msg = document.getElementById('msg').value.trim();
|
|
||||||
const btn = document.getElementById('runBtn');
|
|
||||||
const res = document.getElementById('result');
|
|
||||||
if (!cid || !msg) { alert('请填写客户ID与消息'); return; }
|
|
||||||
btn.disabled = true; res.style.display = 'none'; res.textContent = '';
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/pricing/run', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({ from_id: cid, acc_id: acc, msg })
|
|
||||||
});
|
|
||||||
const data = await r.json();
|
|
||||||
res.style.display = 'block';
|
|
||||||
res.textContent = data.error ? ('错误:'+data.error) : (
|
|
||||||
`回复:${data.reply}\n\n【调试】目标Agent:${data.agent}\n最低价:${data.floor}\n应答:${data.should_reply?'是':'否'}`
|
|
||||||
);
|
|
||||||
} catch(e) {
|
|
||||||
res.style.display = 'block';
|
|
||||||
res.textContent = '请求失败:'+e;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@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/<customer_id>")
|
|
||||||
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)})
|
|
||||||
@@ -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())
|
|
||||||
@@ -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 # 查看当前数据")
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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),
|
|
||||||
)
|
|
||||||
@@ -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))
|
|
||||||
@@ -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": "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -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),
|
|
||||||
)
|
|
||||||
@@ -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已停止")
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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)}
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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))
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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})")
|
|
||||||
@@ -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)
|
|
||||||