Compare commits
42 Commits
006b035de4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ab173d2d0f | |||
| 311124bc9b | |||
| d191ad8eac | |||
| 87f9e8724d | |||
| 1b136d17ad | |||
| 5b36693c2e | |||
| 5a38fa9e6c | |||
| 71d3f713c9 | |||
| 823f5eac76 | |||
| f3e8ea16c6 | |||
| 3d1d955256 | |||
| 8a67c25887 | |||
| ebca1eaff6 | |||
| 2c003e9a7d | |||
| 3f45a4badd | |||
| c399b8cfc1 | |||
| a082364e34 | |||
| 7aa2dff569 | |||
| 64571f4544 | |||
| e0c9f46162 | |||
| ba5644371f | |||
| 5fcce98583 | |||
| a2119f3b6d | |||
| d3b55798e5 | |||
| 23c2f37a67 | |||
| bcd162ef22 | |||
| 2ab27eb914 | |||
| 82284ce3fb | |||
| 3a78eb304a | |||
| 39de916b89 | |||
| fddd879ba0 | |||
| 2e3409d8c5 | |||
| 5a5bde1ba5 | |||
| 613d375845 | |||
| 54231cbd5c | |||
| 3c52061861 | |||
| 07053ce1ad | |||
| 8460d00379 | |||
| 3020ae4691 | |||
| f06bfb1fa0 | |||
| afb2b78c15 | |||
| 4ba636e98c |
@@ -1,211 +0,0 @@
|
||||
# 代码质量评估报告 & 修复清单
|
||||
|
||||
> 生成时间: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
|
||||
189
README.md
189
README.md
@@ -1,46 +1,73 @@
|
||||
# AI 客服系统 - 天网协作版
|
||||
# AI 客服系统
|
||||
|
||||
**版本**: v1.0 | **服务器**: 1.12.50.92
|
||||
基于 PydanticAI 的智能客服,对接千牛 WebSocket,自动接待、收图、转接设计师。
|
||||
|
||||
---
|
||||
|
||||
## 功能概览
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
客户消息 (千牛)
|
||||
↓
|
||||
WebSocket Client → QianniuAdapter (协议转换)
|
||||
↓
|
||||
Orchestrator (防抖/去重/冷却/路由)
|
||||
↓
|
||||
CustomerServiceBrain (PydanticAI Agent)
|
||||
├── lookup_chat_history_tool → 查询历史记录
|
||||
├── transfer_to_human_tool → 转接设计师
|
||||
└── lookup_customer_orders_tool → 订单查询
|
||||
↓
|
||||
QianniuAdapter → WebSocket → 回复客户
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 天网协作 | 接收天网任务,支持指定客户回复触发 |
|
||||
| 三种工作流 | 找图 / 处理图片 / 转人工派单 |
|
||||
| 图片任务数据库 | 任务持久化,支持后续增加需求 |
|
||||
| 图绘派单系统 | 自动派单给在线设计师 |
|
||||
| 文字检测加价 | 自动识别文字数量并加价 |
|
||||
| 风险评估 | 自动识别敏感内容,拒绝不良订单 |
|
||||
| 作图失败转人工 | 失败自动转接人工客服 |
|
||||
| 智能接待 | 自动引导发图、问需求、转接设计师 |
|
||||
| 历史记忆 | AI 可调用工具查询完整聊天历史,避免重复提问 |
|
||||
| 自动转接 | 收到图片+需求后自动派单给在线设计师 |
|
||||
| 待转接池 | 设计师不在线时先入队,上线后自动补转接 |
|
||||
| 转接冷却 | 转接后 120 秒内不再调用 AI,直接安抚 |
|
||||
| 情绪识别 | 客户愤怒/投诉时自动转人工 |
|
||||
| 消息防抖 | 合并短时间内的多条消息,避免重复回复 |
|
||||
| 订单静默 | 订单通知/SKU 信息自动入库,不触发 AI |
|
||||
| 图片兜底 | 识别 `msg_type=1` 图片包;即使无文本也不会被当心跳丢弃 |
|
||||
| 出站防泄露 | 拦截 `<think>`、工具原文、历史摘要、订单摘要等内部内容 |
|
||||
| 时段感知 | 根据时间区分"没上班"/"下班了"/"暂时不在" |
|
||||
| 图片分析 | 后台调用 Gemini 分析图片复杂度 |
|
||||
| 日报统计 | 每日自动生成客服数据报告 |
|
||||
| 多进程 | 支持多 Worker 并行处理 |
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd /root/ai_customer_service/ai_cs
|
||||
pip3 install -r requirements.txt
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 天网协作版(仅 HTTP API)
|
||||
python3 run.py --api-only
|
||||
# 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填入 API Key、数据库等配置
|
||||
|
||||
# 完整版(HTTP API + WebSocket + AI Agent)
|
||||
python3 run.py --tianwang
|
||||
# 启动(WebSocket 客服模式)
|
||||
python run.py
|
||||
|
||||
# AI 客服(仅 WebSocket,默认)
|
||||
python3 run.py
|
||||
# 完整版(HTTP API + WebSocket)
|
||||
python run.py --tianwang
|
||||
|
||||
# 多进程模式
|
||||
python run.py --multi -w 4
|
||||
|
||||
# 仅 HTTP API
|
||||
python run.py --api-only
|
||||
```
|
||||
|
||||
### 后台运行
|
||||
|
||||
```bash
|
||||
nohup python3 run.py --api-only > /tmp/tianwang.log 2>&1 &
|
||||
```
|
||||
|
||||
### 验证
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:6060/api/health
|
||||
@@ -48,44 +75,90 @@ curl http://localhost:6060/api/health
|
||||
|
||||
---
|
||||
|
||||
## API 地址
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| AI 客服 API | `http://127.0.0.1:6060` |
|
||||
| 派单系统 | `http://1.12.50.92:8005` |
|
||||
| 图绘平台 | `http://1.12.50.92:8002` |
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| **项目功能汇总.md** | 全部功能详细说明(工作流、报价、风险、派单、数据库等) |
|
||||
| **部署文档.md** | 部署、API 接口、天网集成、多进程、故障排查 |
|
||||
| **features/self_evolution_mvp.md** | 自我进化 MVP(采样、评测、建议、灰度门禁) |
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── api/ # HTTP API 服务器
|
||||
├── core/ # 核心逻辑(Agent、工作流、WebSocket)
|
||||
├── run.py # 统一入口
|
||||
├── api/
|
||||
│ └── http_server.py # HTTP API 服务
|
||||
├── core/
|
||||
│ ├── orchestrator.py # 总编排(防抖/去重/冷却/路由)
|
||||
│ ├── pydantic_ai_agent_v2.py # AI 大脑(PydanticAI Agent)
|
||||
│ ├── agent_tools.py # AI 工具(转接/查历史/查订单)
|
||||
│ ├── schema.py # 数据模型(StandardMessage/Response)
|
||||
│ ├── repository.py # 异步数据仓库
|
||||
│ ├── skill_manager.py # 技能加载器
|
||||
│ ├── engine.py # 业务逻辑兜底
|
||||
│ ├── adapters/
|
||||
│ │ └── qianniu_adapter.py # 千牛协议适配
|
||||
│ ├── events/
|
||||
│ │ └── event_bus.py # 异步事件总线
|
||||
│ └── websocket_*.py # WebSocket 连接/发送/日志
|
||||
├── db/
|
||||
│ ├── chat_log_db.py # 聊天记录(SQLite/MySQL)
|
||||
│ ├── customer_db.py # 客户档案
|
||||
│ ├── image_tasks_db.py # 图片任务
|
||||
│ ├── pending_transfer_db.py # 待转接队列(本地 SQLite)
|
||||
│ └── task_db/ # 任务模型
|
||||
├── services/
|
||||
│ ├── dispatch_service.py # 设计师派单
|
||||
│ ├── service_gemini.py # Gemini 图片分析
|
||||
│ ├── service_image_analyzer.py # 图片复杂度分析
|
||||
│ └── ... # 其他服务
|
||||
├── skills/ # AI 技能定义(SKILL.md)
|
||||
│ ├── customer-service/ # 客服核心技能
|
||||
│ ├── owner-style/ # 店主风格
|
||||
│ ├── pre-sales-skill/ # 售前
|
||||
│ ├── after-sales-skill/ # 售后
|
||||
│ ├── pricing-skill/ # 报价(排除出 prompt)
|
||||
│ ├── risk-skill/ # 风控
|
||||
│ └── style-skill/ # 语气风格
|
||||
├── config/ # 配置文件
|
||||
├── db/ # 数据库模块
|
||||
├── image/ # 图片处理模块
|
||||
├── services/ # 外部服务集成
|
||||
├── utils/ # 工具模块
|
||||
├── skills/ # Agent 技能定义
|
||||
└── run.py # 统一入口(--api-only / --tianwang / 默认 WebSocket)
|
||||
├── utils/ # 工具(日报/健康检查/API计费等)
|
||||
└── scripts/ # 运维脚本
|
||||
```
|
||||
|
||||
## 自我进化 MVP
|
||||
---
|
||||
|
||||
```bash
|
||||
python scripts/evolution_cycle.py --hours 24 --publish
|
||||
```
|
||||
## 环境变量
|
||||
|
||||
默认从线上 MySQL 读取对话数据(可用 `--source` 切换)。
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `OPENAI_API_KEY` | 火山引擎 Ark API Key |
|
||||
| `OPENAI_BASE_URL` | API 地址 |
|
||||
| `OPENAI_MODEL` | 对话模型 |
|
||||
| `DB_TYPE` | 数据库类型(`sqlite` / `mysql`) |
|
||||
| `MYSQL_HOST/PORT/USER/PASSWORD/DATABASE` | MySQL 连接信息 |
|
||||
| `MYSQL_POOL_SIZE` | MySQL 连接池大小,默认 `10` |
|
||||
| `MYSQL_POOL_WAIT_TIMEOUT` | 连接池等待超时(秒),默认 `10` |
|
||||
| `WECHAT_WEBHOOK` | 企业微信通知 Webhook |
|
||||
| `MESSAGE_DEBOUNCE_SECONDS` | 消息防抖时间(秒) |
|
||||
| `DISPATCH_BASE_URL` | 派单服务地址 |
|
||||
|
||||
完整配置见 `.env.example`。
|
||||
|
||||
---
|
||||
|
||||
## 消息处理流程
|
||||
|
||||
1. **WebSocket 接收** → 千牛原始消息
|
||||
2. **适配器转换** → `StandardMessage`(统一格式,识别 `msg_type`、递归提取图片 URL)
|
||||
3. **图片兜底** → 纯图片包即使无文本/无直出 URL,也会标记为“已收到图片消息”
|
||||
4. **Orchestrator 过滤** → 订单/SKU 静默入库、心跳过滤、商家回复入库
|
||||
5. **防抖合并** → 2 秒窗口内多条消息合并为一条
|
||||
6. **冷却检查** → 转接后 120 秒内直接安抚,不调 AI
|
||||
7. **AI 思考** → PydanticAI Agent 调用工具、生成回复
|
||||
8. **转接截获** → 工具返回转接指令时直接发送,不经 AI 二次加工
|
||||
9. **待转接入池** → 设计师不在线时记录原因,进入待转接队列,稍后自动补转
|
||||
10. **出站安全过滤** → 过滤 `<think>`、历史摘要、订单摘要、工具原文、时间戳转述历史
|
||||
11. **发送回复** → 通过 WebSocket 回复客户,同时入库
|
||||
|
||||
---
|
||||
|
||||
## 运行注意
|
||||
|
||||
- `db/pending_transfer.db` 是待转接池的本地 SQLite 数据文件,用于暂存“设计师不在线”的转接请求。
|
||||
- `db/chat_log_db/chats.db` 是 SQLite 模式下的本地聊天库;当 `DB_TYPE=mysql` 时,线上主库仍然是 MySQL。
|
||||
- 上面两个 `.db` 文件都属于运行时数据,不建议提交到 Git。
|
||||
- 当前待转接池是单机本地队列;如果未来部署成多台应用机,需要改成共享存储,否则无法跨机器补转接。
|
||||
- 出站消息在 Brain、Orchestrator、QianniuAdapter 三层都会做防泄露清洗;如果线上仍出现历史摘要外发,优先确认服务是否已经重启到最新代码。
|
||||
|
||||
81
UPDATE_LOG_2026-03-09_to_2026-03-12.md
Normal file
81
UPDATE_LOG_2026-03-09_to_2026-03-12.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# 最近更新记录
|
||||
|
||||
统计范围:2026-03-09 到 2026-03-12
|
||||
来源:`git log` 实际提交记录
|
||||
说明:以下为 `tw` 仓库这几天已经提交的代码更新,不依赖 MySQL 数据判断。
|
||||
|
||||
## 2026-03-09
|
||||
|
||||
### 图像下载与 Gemini 处理链路
|
||||
|
||||
- `bcd162ef` `fix: harden alicdn image downloads`
|
||||
- 加强阿里 CDN 图片下载稳定性,减少下载失败。
|
||||
- `23c2f37a` `fix: use resolved download path for gemini input`
|
||||
- Gemini 使用实际解析后的下载路径,避免路径不一致。
|
||||
- `d3b55798` `fix: normalize image formats before gemini`
|
||||
- 在送给 Gemini 前统一图片格式,减少格式兼容问题。
|
||||
- `5fcce985` `fix: normalize animated images before gemini`
|
||||
- 动图类素材先规范化,再进入 Gemini 处理。
|
||||
|
||||
### 图绘标题与通知
|
||||
|
||||
- `a2119f3b` `fix: harden outbound leak guard and title naming`
|
||||
- 强化外发泄露拦截,并优化标题命名。
|
||||
- `ba564437` `feat: include processed image url in wecom notice`
|
||||
- 企微通知里补充处理后图片地址。
|
||||
- `e0c9f461` `feat: derive tuhui title from image analysis`
|
||||
- 根据图片分析结果自动生成图绘标题。
|
||||
|
||||
## 2026-03-10
|
||||
|
||||
### 图绘新域名切换
|
||||
|
||||
- `64571f45` `chore: switch tuhui defaults to new domain`
|
||||
- 默认图绘域名切到 `https://aidg168.uk`。
|
||||
- `7aa2dff5` `fix: normalize tuhui asset urls`
|
||||
- 图绘返回的素材地址统一改写为新域名。
|
||||
|
||||
### 标题与设计师信息清理
|
||||
|
||||
- `a082364e` `fix: simplify auto process titles and notices`
|
||||
- 精简自动处理标题和通知文案。
|
||||
- `c399b8cf` `fix: anonymize tuhui designer and clean titles`
|
||||
- 图绘设计师名称匿名化,并继续清理标题。
|
||||
- `3f45a4ba` `fix: randomize tuhui designer alias`
|
||||
- 图绘设计师改为随机匿名别名,不再暴露真实店铺设计师名。
|
||||
- `2c003e9a` `fix: clean generated tuhui titles`
|
||||
- 清理 URL 式、机器式、脏标题,生成更正常的标题。
|
||||
|
||||
## 2026-03-11
|
||||
|
||||
### AI 历史记录与回复安全
|
||||
|
||||
- `ebca1eaf` `fix: block leaked history summaries in replies`
|
||||
- 修复历史摘要、详细记录被误发给客户的问题。
|
||||
|
||||
### 首轮对话与延迟消息承接
|
||||
|
||||
- `8a67c258` `feat: improve first-turn and delayed-image replies`
|
||||
- 优化首轮回复和“客户说已经发过图”的承接方式。
|
||||
- `3d1d9552` `fix: send immediate greeting for each inbound message`
|
||||
- 临时改成每条入站消息先回 `在的 亲`。
|
||||
- `f3e8ea16` `fix: only greet on first message in a session`
|
||||
- 修正上一版过度触发的问题,改为同一轮会话只在第一句先回一次 `在的 亲`。
|
||||
|
||||
## 2026-03-12
|
||||
|
||||
### 成交信号与转接
|
||||
|
||||
- `823f5eac` `fix: transfer when customer asks for payment link`
|
||||
- 客户明确说“发付款链接 / 支付链接 / 拍单链接 / 下单链接”时,作为强成交信号优先触发转接。
|
||||
|
||||
### 脏图片链接过滤
|
||||
|
||||
- `71d3f713` `fix: ignore malformed image urls from card payloads`
|
||||
- 过滤卡片消息、退款消息、JSON 残片里的伪图片链接,避免误送 `ImageAnalyzer` 导致 `400 Bad Request`。
|
||||
|
||||
## 备注
|
||||
|
||||
- 当前文档只记录“已经提交进仓库”的更新。
|
||||
- 未提交的本地修改不在本记录中。
|
||||
- 如果后面还要继续追加,可以直接在这个文件后面按日期补充。
|
||||
@@ -1,23 +0,0 @@
|
||||
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()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,12 +2,34 @@ import re
|
||||
import logging
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
from typing import List, Tuple, Any
|
||||
from core.adapters.base import BaseAdapter
|
||||
from core.schema import StandardMessage, StandardResponse
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
_OUTBOUND_BLOCK_MARKERS = (
|
||||
"【历史记录摘要】",
|
||||
"【详细记录】",
|
||||
"【订单摘要】",
|
||||
"【订单详情】",
|
||||
"<think",
|
||||
"think_never_used",
|
||||
'[{"name":',
|
||||
)
|
||||
|
||||
_HISTORY_LEAK_PATTERNS = [
|
||||
r'\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*(客户|客服)[::]',
|
||||
r'\[\d{2}:\d{2}:\d{2}\]\s*(客户|客服|我)[::]',
|
||||
r'(根据|查看|查询|翻看)(历史|聊天|对话)(记录|内容)',
|
||||
r'历史(记录|对话|消息)(显示|表明|中)',
|
||||
r'之前的(聊天|对话|记录)(中|里|显示)',
|
||||
r'共\d+条(历史|对话)?消息',
|
||||
r'订单号[::]\s*\d{10,}',
|
||||
r'(状态|金额|数量)[::].*(状态|金额|数量)[::]',
|
||||
]
|
||||
|
||||
class QianniuAdapter(BaseAdapter):
|
||||
"""
|
||||
千牛适配器:支持识别消息来源(客户 vs 商家人工)。
|
||||
@@ -30,6 +52,22 @@ class QianniuAdapter(BaseAdapter):
|
||||
logger.warning(f"[QianniuAdapter] 读取转接分组配置失败: {e}")
|
||||
return self._default_group_id
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_outbound_text(content: str) -> str:
|
||||
if not content:
|
||||
return ""
|
||||
cleaned = str(content).strip()
|
||||
if "[转移会话]" in cleaned:
|
||||
return cleaned
|
||||
if any(marker in cleaned for marker in _OUTBOUND_BLOCK_MARKERS):
|
||||
logger.warning("[QianniuAdapter] 拦截到内部内容外发,替换为安全兜底回复")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
for pattern in _HISTORY_LEAK_PATTERNS:
|
||||
if re.search(pattern, cleaned):
|
||||
logger.warning(f"[QianniuAdapter] 检测到历史记录泄露模式: {pattern[:30]}...")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
return cleaned
|
||||
|
||||
async def translate_inbound(self, raw: dict) -> Tuple[StandardMessage, str]:
|
||||
"""
|
||||
返回: (标准消息, 消息方向)
|
||||
@@ -40,6 +78,10 @@ class QianniuAdapter(BaseAdapter):
|
||||
acc_id = str(raw.get("acc_id") or raw.get("shop_id") or "")
|
||||
from_id = str(raw.get("from_id") or raw.get("cy_id") or "")
|
||||
msg_text = str(raw.get("msg") or raw.get("content") or "")
|
||||
raw_msg_type = self._safe_int(raw.get("msg_type"), 0)
|
||||
image_urls = self._extract_inbound_image_urls(raw, msg_text)
|
||||
if raw_msg_type == 1 and not msg_text.strip():
|
||||
msg_text = "【系统:已收到图片消息】"
|
||||
|
||||
# 判断方向:如果 from_id 包含了店铺名或 acc_id,通常说明是商家自己在说话
|
||||
# 或者逆向接口通常有一个特定的标识,这里我们做一个通用的逻辑判断
|
||||
@@ -58,7 +100,8 @@ class QianniuAdapter(BaseAdapter):
|
||||
user_id=user_id,
|
||||
user_name=str(raw.get("from_name", "")),
|
||||
content=msg_text,
|
||||
image_urls=self._extract_urls(msg_text),
|
||||
msg_type=raw_msg_type,
|
||||
image_urls=image_urls,
|
||||
acc_id=acc_id,
|
||||
acc_type=str(raw.get("acc_type") or "AliWorkbench"),
|
||||
raw_data=raw
|
||||
@@ -81,6 +124,9 @@ class QianniuAdapter(BaseAdapter):
|
||||
else:
|
||||
content = res.reply_content
|
||||
|
||||
if res.msg_type == 0:
|
||||
content = self._sanitize_outbound_text(content)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"[REPLY->CUSTOMER] user={user_id} acc={acc_id} type={res.msg_type}\n{content}"
|
||||
@@ -90,7 +136,73 @@ class QianniuAdapter(BaseAdapter):
|
||||
logger.error(f"[QianniuAdapter] 发送失败: {e}")
|
||||
|
||||
def _extract_urls(self, text: str) -> List[str]:
|
||||
if not text: return []
|
||||
if not text:
|
||||
return []
|
||||
image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
|
||||
candidates = re.findall(r'https?://[^\s#]+', text)
|
||||
return [u for u in candidates if any(ext in u.lower() for ext in image_exts)]
|
||||
candidates = re.findall(r'https?://[^\s#,"\'}\]]+', text)
|
||||
urls: List[str] = []
|
||||
seen = set()
|
||||
|
||||
for candidate in candidates:
|
||||
url = str(candidate or "").strip().rstrip('\'".,;:!?)')
|
||||
lower = url.lower()
|
||||
if not any(ext in lower for ext in image_exts):
|
||||
continue
|
||||
# 过滤被卡片/JSON 串污染的伪图片链接
|
||||
if any(marker in lower for marker in ("%22title%22", "%22topic%22", '"title":', '"topic":', "%7d")):
|
||||
continue
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
urls.append(url)
|
||||
|
||||
return urls
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _extract_inbound_image_urls(self, raw: dict, msg_text: str) -> List[str]:
|
||||
urls = []
|
||||
seen = set()
|
||||
|
||||
def add_url(url: str):
|
||||
if not url:
|
||||
return
|
||||
s = str(url).strip()
|
||||
if not s or s in seen:
|
||||
return
|
||||
if self._extract_urls(s):
|
||||
seen.add(s)
|
||||
urls.append(s)
|
||||
|
||||
for url in self._extract_urls(msg_text):
|
||||
add_url(url)
|
||||
|
||||
for url in self._find_image_urls_in_obj(raw):
|
||||
add_url(url)
|
||||
|
||||
return urls
|
||||
|
||||
def _find_image_urls_in_obj(self, obj: Any) -> List[str]:
|
||||
found: List[str] = []
|
||||
|
||||
def walk(val: Any):
|
||||
if val is None:
|
||||
return
|
||||
if isinstance(val, str):
|
||||
found.extend(self._extract_urls(val))
|
||||
return
|
||||
if isinstance(val, dict):
|
||||
for item in val.values():
|
||||
walk(item)
|
||||
return
|
||||
if isinstance(val, (list, tuple, set)):
|
||||
for item in val:
|
||||
walk(item)
|
||||
|
||||
walk(obj)
|
||||
return found
|
||||
|
||||
@@ -1,13 +1,59 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_ai import RunContext
|
||||
from core.schema import StandardResponse
|
||||
from services.dispatch_service import dispatch_service
|
||||
from db.chat_log_db import get_conversation, get_customer_orders
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
_TRANSFER_COMMAND_RE = re.compile(r"^\s*正在为您转接\|\[转移会话\],[^,\r\n]+,[^\r\n]*\s*$")
|
||||
_HISTORY_NOISE_PREFIXES = (
|
||||
"[系统订单信息]",
|
||||
"[进店卡片]",
|
||||
"【系统:已收到",
|
||||
"金额:",
|
||||
"定制:",
|
||||
)
|
||||
|
||||
|
||||
def _is_plain_transfer_command(text: str) -> bool:
|
||||
return bool(_TRANSFER_COMMAND_RE.fullmatch(str(text or "").strip()))
|
||||
|
||||
|
||||
def _normalize_history_message(message: str, role: str) -> str:
|
||||
text = str(message or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if _is_plain_transfer_command(text):
|
||||
return "已转接设计师"
|
||||
if role == "客服" and "[转移会话]" in text:
|
||||
return "已尝试转接设计师"
|
||||
return text
|
||||
|
||||
|
||||
def _extract_need_snippet(message: str) -> str:
|
||||
text = str(message or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if any(text.startswith(prefix) for prefix in _HISTORY_NOISE_PREFIXES):
|
||||
return ""
|
||||
if "http://" in text or "https://" in text:
|
||||
return ""
|
||||
return text[:60]
|
||||
|
||||
|
||||
class TransferSuccessException(Exception):
|
||||
"""转接成功后抛出此异常,用于提前终止 AI 处理流程"""
|
||||
def __init__(self, transfer_cmd: str):
|
||||
self.transfer_cmd = transfer_cmd
|
||||
super().__init__(transfer_cmd)
|
||||
|
||||
|
||||
async def transfer_to_human_tool(ctx: RunContext[Any], reason: str = Field(description="转人工的原因")) -> str:
|
||||
"""
|
||||
【核心工具】执行转人工逻辑。
|
||||
@@ -15,25 +61,129 @@ async def transfer_to_human_tool(ctx: RunContext[Any], reason: str = Field(descr
|
||||
"""
|
||||
logger.info(f"[Tool] 尝试呼叫设计师接手: {reason}")
|
||||
|
||||
# 1. 尝试派单获取设计师姓名
|
||||
designer_name = await dispatch_service.assign_designer()
|
||||
|
||||
if designer_name:
|
||||
# 2. 有设计师在线:生成标准转接指令 (必须包含 [转移会话] 且格式正确)
|
||||
magic_cmd = f"正在为您转接|[转移会话],{designer_name},无原因"
|
||||
logger.info(f"[Tool] 成功呼叫设计师: {designer_name}")
|
||||
return magic_cmd
|
||||
logger.info(f"[Tool] 成功呼叫设计师: {designer_name},立即触发转接")
|
||||
# 抛出异常以提前终止 AI 后续处理,节省等待时间
|
||||
raise TransferSuccessException(magic_cmd)
|
||||
else:
|
||||
# 3. 设计师下线:返回特定信号
|
||||
logger.warning("[Tool] 派单失败:设计师们已下线或不在位")
|
||||
return "ERROR_NO_DESIGNER_ONLINE"
|
||||
hour = datetime.now().hour
|
||||
logger.warning(f"[Tool] 派单失败:设计师们不在位 (当前{hour}点)")
|
||||
if 0 <= hour < 9:
|
||||
return "ERROR_DESIGNER_NOT_STARTED:现在设计师还没上班,你告诉客户需求记下了,上班后第一时间处理。不要说下班。"
|
||||
elif 22 <= hour or hour < 1:
|
||||
return "ERROR_DESIGNER_OFFLINE:设计师已下班,你告诉客户需求记下了,明天第一时间回复。"
|
||||
else:
|
||||
return "ERROR_DESIGNER_BUSY:设计师暂时不在位,你告诉客户稍等,马上帮忙联系设计师。不要说下班。"
|
||||
|
||||
|
||||
async def lookup_customer_orders_tool(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str = Field(description="客户ID,从当前对话上下文中获取"),
|
||||
) -> str:
|
||||
"""
|
||||
【订单查询工具】查询该客户的订单记录(订单号、状态、金额等)。
|
||||
使用场景:
|
||||
- 客户问"我的订单怎么样了"、"付款了"、"发货了吗"
|
||||
- 客户提到订单号
|
||||
- 需要确认客户是否已付款
|
||||
返回该客户的所有订单及其状态。
|
||||
"""
|
||||
logger.info(f"[Tool] 查询客户订单: customer_id={customer_id}")
|
||||
try:
|
||||
rows = await asyncio.to_thread(get_customer_orders, customer_id, limit=10)
|
||||
if not rows:
|
||||
return f"该客户({customer_id})暂无订单记录。"
|
||||
|
||||
lines = []
|
||||
for r in rows:
|
||||
oid = r.get("order_id", "")
|
||||
status = r.get("order_status", "")
|
||||
amount = r.get("amount", 0)
|
||||
qty = r.get("quantity", 0)
|
||||
title = r.get("product_title", "")
|
||||
note = r.get("buyer_note", "")
|
||||
updated = str(r.get("updated_at", ""))
|
||||
line = f"订单号:{oid} 状态:{status} 金额:{amount}元 数量:{qty} 商品:{title}"
|
||||
if note:
|
||||
line += f" 备注:{note}"
|
||||
line += f" 更新时间:{updated}"
|
||||
lines.append(line)
|
||||
|
||||
has_paid = any("已付款" in r.get("order_status", "") for r in rows)
|
||||
has_shipped = any("已发货" in r.get("order_status", "") for r in rows)
|
||||
summary_parts = [f"共{len(rows)}条订单记录。"]
|
||||
if has_paid:
|
||||
summary_parts.append("客户已付款!")
|
||||
if has_shipped:
|
||||
summary_parts.append("已发货。")
|
||||
|
||||
summary = " ".join(summary_parts)
|
||||
return f"【订单摘要】{summary}\n\n【订单详情】\n" + "\n".join(lines)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Tool] 查询订单失败: {e}")
|
||||
return f"查询订单失败: {e}"
|
||||
|
||||
|
||||
async def lookup_chat_history_tool(
|
||||
ctx: RunContext[Any],
|
||||
customer_id: str = Field(description="客户ID,从当前对话上下文中获取"),
|
||||
num_messages: int = Field(default=30, description="要查询的历史消息条数,默认30条"),
|
||||
) -> str:
|
||||
"""
|
||||
【历史记录查询工具】查询该客户的历史聊天记录。
|
||||
使用场景:
|
||||
- 客户说"之前聊过"、"上次"、"你看聊天记录"、"我发过图了"等暗示有历史对话时
|
||||
- 客户第二次来访、追问进度、催单时
|
||||
- 你不确定客户之前是否发过图或说过需求时
|
||||
必须先调用此工具回顾历史,再回复客户,避免重复要求客户发图。
|
||||
"""
|
||||
logger.info(f"[Tool] 查询历史记录: customer_id={customer_id}, limit={num_messages}")
|
||||
try:
|
||||
rows = await asyncio.to_thread(get_conversation, customer_id, limit=num_messages)
|
||||
if not rows:
|
||||
return f"该客户({customer_id})暂无历史聊天记录。"
|
||||
|
||||
lines = []
|
||||
has_images = False
|
||||
customer_needs = []
|
||||
for r in rows:
|
||||
role = "客户" if r["direction"] == "in" else "客服"
|
||||
ts = str(r.get("timestamp", ""))
|
||||
msg = _normalize_history_message(r.get("message", ""), role)
|
||||
line = f"[{ts}] {role}:{msg}"
|
||||
lines.append(line)
|
||||
if r["direction"] == "in":
|
||||
msg_type = int(r.get("msg_type") or 0)
|
||||
raw_message = str(r.get("message", "") or "")
|
||||
image_urls = str(r.get("image_urls", "") or "").strip()
|
||||
if msg_type == 1 or image_urls or ("已收到" in msg and "图" in msg):
|
||||
has_images = True
|
||||
need_text = _extract_need_snippet(raw_message)
|
||||
if need_text and any(k in need_text for k in ["找原图", "修复", "高清", "去背景", "抠图", "做衣服", "打印", "大图", "素材"]):
|
||||
customer_needs.append(need_text)
|
||||
|
||||
summary_parts = [f"共{len(rows)}条历史消息。"]
|
||||
if has_images:
|
||||
summary_parts.append("⚠️ 客户之前已经发过图片!不要再让客户发图!")
|
||||
if customer_needs:
|
||||
summary_parts.append(f"客户曾表达的需求:{';'.join(customer_needs[:3])}")
|
||||
|
||||
summary = " ".join(summary_parts)
|
||||
history_text = "\n".join(lines[-30:])
|
||||
return f"【历史记录摘要】{summary}\n\n【详细记录】\n{history_text}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Tool] 查询历史记录失败: {e}")
|
||||
return f"查询历史记录失败: {e}"
|
||||
|
||||
async def check_order_status_tool(ctx: RunContext[Any], customer_id: str = Field(description="客户ID")) -> str:
|
||||
"""查询订单状态。"""
|
||||
return "设计师正在后台加急处理中,请稍等哈。"
|
||||
|
||||
def register_agent_tools(agent: Any):
|
||||
"""注册工具"""
|
||||
agent.tool(transfer_to_human_tool)
|
||||
agent.tool(check_order_status_tool)
|
||||
logger.info("[Agent] 工具箱已更新:称呼统一为“设计师”。")
|
||||
agent.tool(lookup_chat_history_tool)
|
||||
agent.tool(lookup_customer_orders_tool)
|
||||
logger.info("[Agent] 工具箱已更新:含转人工、历史记录查询、订单查询。")
|
||||
|
||||
@@ -7,43 +7,18 @@ logger = logging.getLogger("cs_agent")
|
||||
|
||||
class BusinessEngine:
|
||||
"""
|
||||
业务逻辑中枢:
|
||||
1. 接收 StandardMessage。
|
||||
2. 决定由哪个 AI 工具或流程处理。
|
||||
3. 返回 StandardResponse。
|
||||
4. 对外广播异步事件。
|
||||
业务逻辑中枢(备用引擎,主流程由 Orchestrator + Brain 处理)。
|
||||
仅在 Orchestrator 不可用时作为降级方案。
|
||||
"""
|
||||
def __init__(self, agent_instance: Any = None):
|
||||
"""
|
||||
:param agent_instance: 核心 AI Agent 的实例(比如重构后的 CustomerServiceAgent)
|
||||
"""
|
||||
self.agent = agent_instance
|
||||
|
||||
async def handle_message(self, msg: StandardMessage) -> StandardResponse:
|
||||
"""
|
||||
大脑的思考主入口
|
||||
"""
|
||||
logger.info(f"[Engine] 收到来自 {msg.platform} 的消息: {msg.user_id} -> {msg.content[:50]}")
|
||||
content = (msg.content or "")
|
||||
logger.info(f"[Engine] 收到来自 {msg.platform} 的消息: {msg.user_id} -> {content[:50]}")
|
||||
|
||||
# TODO: 这里将接入重构后的 Single Agent + Tool Calling
|
||||
# 目前模拟一个简单的规则响应,展示 StandardResponse 的用法
|
||||
|
||||
if "报价" in msg.content or msg.image_urls:
|
||||
return StandardResponse(
|
||||
reply_content="正在为你查看图片,请稍等...",
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
)
|
||||
|
||||
if "转人工" in msg.content:
|
||||
return StandardResponse(
|
||||
reply_content="正在为你转接设计师...",
|
||||
need_transfer=True,
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
)
|
||||
|
||||
# 兜底回复
|
||||
return StandardResponse(
|
||||
reply_content="你好,我是AI助手,有什么可以帮你的?",
|
||||
reply_content="稍等哈,设计师马上来。",
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
)
|
||||
|
||||
|
||||
@@ -29,8 +29,11 @@ class AsyncEventBus:
|
||||
tasks.append(asyncio.create_task(callback(**kwargs)))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info(f"[EventBus] 事件 {event_type} 已成功广播给 {len(tasks)} 个订阅者")
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for i, r in enumerate(results):
|
||||
if isinstance(r, Exception):
|
||||
logger.error(f"[EventBus] 事件 {event_type} 订阅者 {i} 异常: {r}")
|
||||
logger.info(f"[EventBus] 事件 {event_type} 已广播给 {len(tasks)} 个订阅者")
|
||||
|
||||
# 全局单例,所有模块共用这一个广播台
|
||||
bus = AsyncEventBus()
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncio
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any, Dict
|
||||
from collections import deque
|
||||
from core.schema import StandardMessage, StandardResponse
|
||||
@@ -10,13 +11,60 @@ from core.adapters.qianniu_adapter import QianniuAdapter
|
||||
from core.pydantic_ai_agent_v2 import CustomerServiceBrain
|
||||
from core.events.event_bus import bus
|
||||
from core.repository import repo
|
||||
from db.pending_transfer_db import (
|
||||
enqueue_pending_transfer,
|
||||
claim_due_pending_transfers,
|
||||
complete_pending_transfer,
|
||||
retry_pending_transfer,
|
||||
)
|
||||
from services.dispatch_service import dispatch_service
|
||||
from services.service_auto_image_pipeline import auto_image_pipeline_service
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
# 配置常量
|
||||
MSG_DEDUP_CAPACITY = 200 # 消息 ID 去重缓存容量
|
||||
TRANSFER_COOLDOWN_SEC = 60 # 转接冷却时间(秒)
|
||||
TRANSFER_COOLDOWN_SEC = 120 # 转接冷却时间(秒)—— 转接后2分钟内不再调用AI
|
||||
DEBOUNCE_SECONDS = 2.0 # 消息防抖延迟(秒)
|
||||
FIRST_GREETING_IDLE_SEC = 180 # 超过该空闲时间,视为新一轮对话
|
||||
PENDING_TRANSFER_POLL_SECONDS = 30
|
||||
PENDING_TRANSFER_RETRY_SECONDS = 60
|
||||
TRANSFER_RETRY_WINDOW_SEC = 300
|
||||
TRANSFER_RETRY_GAP_SEC = 45
|
||||
|
||||
# 转接后安抚话术池(轮换使用,避免复读)
|
||||
_TRANSFER_CALM_REPLIES = [
|
||||
"我在帮你催了哈,稍等下",
|
||||
"已经转了哈,马上就来",
|
||||
"收到,设计师在赶来了哈",
|
||||
"好的亲,稍等一下哈",
|
||||
"在催了在催了,马上哈",
|
||||
]
|
||||
|
||||
_OUTBOUND_BLOCK_MARKERS = (
|
||||
"【历史记录摘要】",
|
||||
"【详细记录】",
|
||||
"【订单摘要】",
|
||||
"【订单详情】",
|
||||
"<think",
|
||||
"think_never_used",
|
||||
'[{"name":',
|
||||
)
|
||||
|
||||
_TRANSFER_COMMAND_MARKER = "[转移会话]"
|
||||
_TRANSFER_COMMAND_RE = re.compile(r"^\s*正在为您转接\|\[转移会话\],[^,\r\n]+,[^\r\n]*\s*$")
|
||||
|
||||
# 历史记录格式检测模式(AI 转述历史时容易泄露)
|
||||
_HISTORY_LEAK_PATTERNS = [
|
||||
r'\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*(客户|客服)[::]', # [2026-03-07 12:00:00] 客户:
|
||||
r'\[\d{2}:\d{2}:\d{2}\]\s*(客户|客服|我)[::]', # [12:00:00] 客户:
|
||||
r'(根据|查看|查询|翻看)(历史|聊天|对话)(记录|内容)', # 根据历史记录
|
||||
r'历史(记录|对话|消息)(显示|表明|中)', # 历史记录显示
|
||||
r'之前的(聊天|对话|记录)(中|里|显示)', # 之前的聊天中
|
||||
r'共\d+条(历史|对话)?消息', # 共30条历史消息
|
||||
r'订单号[::]\s*\d{10,}', # 订单号:xxxxxxxxxx
|
||||
r'(状态|金额|数量)[::].*(状态|金额|数量)[::]', # 状态:xxx 金额:xxx 连续出现
|
||||
]
|
||||
|
||||
class SystemOrchestrator:
|
||||
"""
|
||||
@@ -30,14 +78,19 @@ class SystemOrchestrator:
|
||||
# 1. 消息 ID 去重
|
||||
self._processed_msg_ids = deque(maxlen=MSG_DEDUP_CAPACITY)
|
||||
|
||||
# 2. 转接冷却存储 (customer_id -> last_transfer_time)
|
||||
# 2. 转接冷却存储 (session_key -> last_transfer_time)
|
||||
self._last_transfer_time: Dict[str, float] = {}
|
||||
self._transfer_calm_idx: Dict[str, int] = {} # 安抚话术轮换索引
|
||||
|
||||
# 3. 防抖配置
|
||||
self._debounce_seconds = DEBOUNCE_SECONDS
|
||||
self._debounce_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._pending_messages: Dict[str, List[StandardMessage]] = {}
|
||||
self._user_locks: Dict[str, asyncio.Lock] = {}
|
||||
self._last_inbound_seen_time: Dict[str, float] = {}
|
||||
self._pending_transfer_task: Optional[asyncio.Task] = None
|
||||
self._last_retry_transfer_time: Dict[str, float] = {}
|
||||
self._auto_pipeline_jobs: Dict[str, float] = {}
|
||||
|
||||
bus.subscribe("MESSAGE_OUTBOUND", self.handle_outbound_event)
|
||||
|
||||
@@ -54,10 +107,258 @@ class SystemOrchestrator:
|
||||
self._user_locks[user_id] = asyncio.Lock()
|
||||
return self._user_locks[user_id]
|
||||
|
||||
def _should_run_pending_transfer_worker(self) -> bool:
|
||||
worker_id = getattr(self.ws_client, "worker_id", -1) if self.ws_client else -1
|
||||
return worker_id in (-1, 0)
|
||||
|
||||
def _ensure_background_tasks(self):
|
||||
if not self._should_run_pending_transfer_worker():
|
||||
return
|
||||
if self._pending_transfer_task is None or self._pending_transfer_task.done():
|
||||
self._pending_transfer_task = asyncio.create_task(self._process_pending_transfers_loop())
|
||||
logger.info("[Orchestrator] 待转接轮询任务已启动")
|
||||
|
||||
@staticmethod
|
||||
def _parse_history_ts(ts: Any) -> Optional[datetime]:
|
||||
text = str(ts or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _find_stalled_transfer(self, history: List[dict]) -> Optional[dict]:
|
||||
if not history:
|
||||
return None
|
||||
|
||||
last_transfer_idx = -1
|
||||
for idx in range(len(history) - 1, -1, -1):
|
||||
item = history[idx]
|
||||
if item.get("role") == "assistant" and _TRANSFER_COMMAND_MARKER in str(item.get("content") or ""):
|
||||
last_transfer_idx = idx
|
||||
break
|
||||
|
||||
if last_transfer_idx < 0:
|
||||
return None
|
||||
|
||||
transfer_item = history[last_transfer_idx]
|
||||
transfer_at = self._parse_history_ts(transfer_item.get("timestamp"))
|
||||
if not transfer_at:
|
||||
return None
|
||||
|
||||
elapsed = time.time() - transfer_at.timestamp()
|
||||
if elapsed < 0 or elapsed > TRANSFER_RETRY_WINDOW_SEC:
|
||||
return None
|
||||
|
||||
after_transfer = history[last_transfer_idx + 1:]
|
||||
if not any(item.get("role") == "user" for item in after_transfer):
|
||||
return None
|
||||
|
||||
for item in after_transfer:
|
||||
if item.get("role") != "assistant":
|
||||
continue
|
||||
content = str(item.get("content") or "")
|
||||
if _TRANSFER_COMMAND_MARKER not in content:
|
||||
return None
|
||||
|
||||
return {
|
||||
"timestamp": transfer_at,
|
||||
"elapsed": elapsed,
|
||||
"content": str(transfer_item.get("content") or ""),
|
||||
}
|
||||
|
||||
async def _retry_stalled_transfer_if_needed(
|
||||
self,
|
||||
session_key: str,
|
||||
user_id: str,
|
||||
platform: str,
|
||||
acc_id: str,
|
||||
acc_type: str,
|
||||
history: List[dict],
|
||||
) -> Optional[StandardResponse]:
|
||||
stalled = self._find_stalled_transfer(history)
|
||||
if not stalled:
|
||||
return None
|
||||
|
||||
last_retry_at = self._last_retry_transfer_time.get(session_key, 0.0)
|
||||
if time.time() - last_retry_at < TRANSFER_RETRY_GAP_SEC:
|
||||
logger.info(
|
||||
f"[Orchestrator] 转接补发冷却中,先不重复补转: user={user_id} acc={acc_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"[Orchestrator] 检测到疑似转接未接上,准备补发转接: "
|
||||
f"user={user_id} acc={acc_id} elapsed={stalled['elapsed']:.0f}s"
|
||||
)
|
||||
designer_name = await dispatch_service.assign_designer(user_id=user_id)
|
||||
if not designer_name:
|
||||
logger.info(f"[Orchestrator] 补发转接失败,当前仍无可用设计师: user={user_id} acc={acc_id}")
|
||||
return None
|
||||
|
||||
self._last_retry_transfer_time[session_key] = time.time()
|
||||
return StandardResponse(
|
||||
reply_content=f"正在为您转接|[转移会话],{designer_name},无原因",
|
||||
need_transfer=True,
|
||||
metadata={
|
||||
"acc_id": acc_id,
|
||||
"acc_type": acc_type,
|
||||
"transfer_prelude": "我再帮您转一下哈",
|
||||
"retry_transfer": True,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_outbound_text(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
cleaned = str(text).strip()
|
||||
if _TRANSFER_COMMAND_RE.fullmatch(cleaned):
|
||||
return cleaned
|
||||
if _TRANSFER_COMMAND_MARKER in cleaned:
|
||||
logger.warning("[Orchestrator] 检测到混入正文的转接指令,替换为安全兜底回复")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
if any(marker in cleaned for marker in _OUTBOUND_BLOCK_MARKERS):
|
||||
logger.warning("[Orchestrator] 拦截到内部内容外发,替换为安全兜底回复")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
# 检查历史记录泄露模式
|
||||
for pattern in _HISTORY_LEAK_PATTERNS:
|
||||
if re.search(pattern, cleaned):
|
||||
logger.warning(f"[Orchestrator] 检测到历史记录泄露模式: {pattern[:30]}...")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_history_content_for_ai(text: str) -> str:
|
||||
cleaned = str(text or "").strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
if _TRANSFER_COMMAND_RE.fullmatch(cleaned):
|
||||
return "系统:之前已转接设计师"
|
||||
if "【历史记录摘要】" in cleaned or "【详细记录】" in cleaned:
|
||||
return "系统:刚刚查过历史记录"
|
||||
if "【订单摘要】" in cleaned or "【订单详情】" in cleaned:
|
||||
return "系统:刚刚查过订单记录"
|
||||
if _TRANSFER_COMMAND_MARKER in cleaned:
|
||||
cleaned = re.sub(
|
||||
r"正在为您转接\|\[转移会话\],[^,\r\n]+,[^\r\n]*",
|
||||
"系统:之前已转接设计师",
|
||||
cleaned,
|
||||
)
|
||||
return cleaned
|
||||
|
||||
def _sanitize_history_for_ai(self, history: List[dict]) -> List[dict]:
|
||||
sanitized = []
|
||||
for item in history or []:
|
||||
normalized = dict(item)
|
||||
normalized["content"] = self._sanitize_history_content_for_ai(item.get("content", ""))
|
||||
sanitized.append(normalized)
|
||||
return sanitized
|
||||
|
||||
@staticmethod
|
||||
def _extract_designer_name(transfer_cmd: str) -> str:
|
||||
text = str(transfer_cmd or "").strip()
|
||||
match = re.search(r"\[转移会话\],([^,]+),", text)
|
||||
return str(match.group(1)).strip() if match else ""
|
||||
|
||||
@staticmethod
|
||||
def _infer_processing_intent(requirement_text: str, history: Optional[List[dict]] = None) -> str:
|
||||
combined_parts = [str(requirement_text or "").lower()]
|
||||
for item in history or []:
|
||||
if item.get("role") == "user":
|
||||
combined_parts.append(str(item.get("content") or "").lower())
|
||||
combined = "\n".join(combined_parts)
|
||||
repair_keywords = ("修复", "高清", "清晰", "放大", "老照片")
|
||||
if any(k in combined for k in repair_keywords):
|
||||
return "repair"
|
||||
return "find_original"
|
||||
|
||||
@staticmethod
|
||||
def _collect_recent_image_urls(history: List[dict], fallback_urls: Optional[List[str]] = None) -> List[str]:
|
||||
urls: List[str] = []
|
||||
seen = set()
|
||||
|
||||
def add_url(url: str):
|
||||
value = str(url or "").strip()
|
||||
if not value or value in seen:
|
||||
return
|
||||
seen.add(value)
|
||||
urls.append(value)
|
||||
|
||||
for url in fallback_urls or []:
|
||||
add_url(url)
|
||||
|
||||
for item in reversed(history or []):
|
||||
if item.get("role") != "user":
|
||||
continue
|
||||
raw_urls = item.get("image_urls") or []
|
||||
if isinstance(raw_urls, str):
|
||||
for part in re.split(r"[\n#]+", raw_urls):
|
||||
add_url(part)
|
||||
elif isinstance(raw_urls, list):
|
||||
for part in raw_urls:
|
||||
add_url(part)
|
||||
content = str(item.get("content") or "")
|
||||
for match in re.findall(r"https?://[^\s#]+", content):
|
||||
add_url(match)
|
||||
if len(urls) >= 5:
|
||||
break
|
||||
return urls
|
||||
|
||||
def _schedule_auto_pipeline(
|
||||
self,
|
||||
*,
|
||||
session_key: str,
|
||||
customer_id: str,
|
||||
acc_id: str,
|
||||
designer_name: str,
|
||||
requirement_text: str,
|
||||
history: List[dict],
|
||||
image_urls: Optional[List[str]] = None,
|
||||
):
|
||||
resolved_urls = self._collect_recent_image_urls(history, image_urls)
|
||||
if not resolved_urls:
|
||||
logger.info(f"[Orchestrator] 自动处理跳过:未找到客户图片 user={customer_id} acc={acc_id}")
|
||||
return
|
||||
|
||||
intent = self._infer_processing_intent(requirement_text, history)
|
||||
signature_src = f"{session_key}|{designer_name}|{intent}|{'|'.join(resolved_urls)}"
|
||||
signature = str(abs(hash(signature_src)))
|
||||
now = time.time()
|
||||
last_run = self._auto_pipeline_jobs.get(signature, 0.0)
|
||||
if now - last_run < 600:
|
||||
logger.info(f"[Orchestrator] 自动处理已在近期触发,跳过重复任务 user={customer_id} acc={acc_id}")
|
||||
return
|
||||
self._auto_pipeline_jobs[signature] = now
|
||||
|
||||
async def _runner():
|
||||
try:
|
||||
result = await auto_image_pipeline_service.process_and_notify(
|
||||
session_key=session_key,
|
||||
customer_id=customer_id,
|
||||
acc_id=acc_id,
|
||||
designer_name=designer_name,
|
||||
requirement_text=requirement_text,
|
||||
image_urls=resolved_urls,
|
||||
intent=intent,
|
||||
)
|
||||
logger.info(
|
||||
f"[Orchestrator] 自动处理完成 user={customer_id} acc={acc_id} "
|
||||
f"ok={result.get('success')} uploaded={len(result.get('uploaded') or [])}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Orchestrator] 自动处理失败 user={customer_id} acc={acc_id}: {e}")
|
||||
|
||||
asyncio.create_task(_runner())
|
||||
|
||||
async def on_raw_message_received(self, platform: str, raw_data: dict):
|
||||
"""链路入口"""
|
||||
try:
|
||||
if platform != "qianniu": return
|
||||
self._ensure_background_tasks()
|
||||
|
||||
std_msg, direction = await self.qianniu_adapter.translate_inbound(raw_data)
|
||||
|
||||
@@ -68,11 +369,23 @@ class SystemOrchestrator:
|
||||
# 店铺隔离:同一客户在不同店铺的对话独立处理
|
||||
session_key = f"{user_id}@{std_msg.acc_id}"
|
||||
|
||||
# 订单消息处理:静默入库并更新状态,但不触发 AI 回复
|
||||
if "[系统订单信息]" in (std_msg.content or ""):
|
||||
# 订单消息 / 纯金额通知 / SKU信息:静默入库,不触发 AI 回复
|
||||
msg_text = std_msg.content or ""
|
||||
is_order = "[系统订单信息]" in msg_text
|
||||
is_price_only = bool(re.match(r'^[\s\n]*金?额?[::]?\s*[\d.]+\s*元', msg_text.strip()))
|
||||
is_sku_only = bool(re.match(r'^[\s\n]*(备注[::]|数量[::]|款式[::]|定制[::])', msg_text.strip()))
|
||||
is_sku_amount = bool(re.match(r'^[\s\n]*金额[::]\s*[\d.]+元\s*●', msg_text.strip()))
|
||||
if is_order or is_price_only or is_sku_only or is_sku_amount:
|
||||
await self._handle_order_packet(platform, std_msg)
|
||||
logger.info(f"[订单消息] user={user_id} acc={std_msg.acc_id} 已入库更新状态")
|
||||
await repo.save_chat(platform, user_id, std_msg.content, "in", acc_id=std_msg.acc_id)
|
||||
await repo.save_chat(
|
||||
platform,
|
||||
user_id,
|
||||
msg_text,
|
||||
"in",
|
||||
acc_id=std_msg.acc_id,
|
||||
msg_type=std_msg.msg_type,
|
||||
)
|
||||
return
|
||||
|
||||
preview = (std_msg.content or "").replace("\n", "\\n")
|
||||
@@ -83,12 +396,20 @@ class SystemOrchestrator:
|
||||
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
|
||||
# 过滤心跳;图片消息即使暂时没拿到 URL,也不能直接丢掉
|
||||
if std_msg.msg_type != 1 and not (std_msg.content or "").strip() and not std_msg.image_urls:
|
||||
return
|
||||
|
||||
# 如果是商家人工回复,静默入库
|
||||
if direction == "out":
|
||||
await repo.save_chat(platform, 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,
|
||||
msg_type=std_msg.msg_type,
|
||||
)
|
||||
return
|
||||
|
||||
# ID 去重
|
||||
@@ -96,6 +417,25 @@ class SystemOrchestrator:
|
||||
if std_msg.msg_id in self._processed_msg_ids: return
|
||||
self._processed_msg_ids.append(std_msg.msg_id)
|
||||
|
||||
# 同一轮对话只在第一句先发固定承接,不经过 AI
|
||||
now_ts = time.time()
|
||||
last_inbound_ts = self._last_inbound_seen_time.get(session_key, 0.0)
|
||||
self._last_inbound_seen_time[session_key] = now_ts
|
||||
if now_ts - last_inbound_ts >= FIRST_GREETING_IDLE_SEC:
|
||||
first_greet = StandardResponse(
|
||||
reply_content="在的 亲",
|
||||
metadata={"acc_id": std_msg.acc_id, "acc_type": std_msg.acc_type},
|
||||
)
|
||||
await self.qianniu_adapter.translate_outbound(first_greet, user_id)
|
||||
await repo.save_chat(
|
||||
platform,
|
||||
user_id,
|
||||
first_greet.reply_content,
|
||||
"out",
|
||||
acc_id=std_msg.acc_id,
|
||||
msg_type=first_greet.msg_type,
|
||||
)
|
||||
|
||||
# 进入防抖(使用 session_key 隔离不同店铺)
|
||||
if session_key in self._debounce_tasks: self._debounce_tasks[session_key].cancel()
|
||||
if session_key not in self._pending_messages: self._pending_messages[session_key] = []
|
||||
@@ -108,17 +448,48 @@ class SystemOrchestrator:
|
||||
|
||||
async def _handle_order_packet(self, platform: str, msg: StandardMessage):
|
||||
try:
|
||||
price_match = re.search(r"订单金额:金额:\s*([\d\.]+)元", msg.content)
|
||||
if price_match: await repo.update_task_price(platform, msg.user_id, float(price_match.group(1)))
|
||||
# 判定成交结果(扩大范围:已付款 或 已发货 都视为成功,用于后期 AI 话术微调)
|
||||
if any(k in msg.content for k in ["买家已付款", "卖家已发货"]):
|
||||
from core.order_helpers import parse_order_info
|
||||
from db.chat_log_db import upsert_order
|
||||
|
||||
content = msg.content or ""
|
||||
info = parse_order_info(content)
|
||||
|
||||
price_match = re.search(r"金额[::]\s*([\d\.]+)\s*元", content)
|
||||
if price_match:
|
||||
await repo.update_task_price(platform, msg.user_id, float(price_match.group(1)))
|
||||
|
||||
if any(k in content for k in ["买家已付款", "卖家已发货"]):
|
||||
await repo.update_task_outcome(platform, msg.user_id, "deal_success")
|
||||
elif any(k in msg.content for k in ["退款", "已关闭", "已取消"]):
|
||||
elif any(k in content for k in ["退款", "已关闭", "已取消"]):
|
||||
await repo.update_task_outcome(platform, msg.user_id, "refunded")
|
||||
|
||||
# 结构化写入 customer_orders 表
|
||||
order_id = info.get("order_id", "")
|
||||
if order_id and msg.user_id and msg.user_id != "unknown":
|
||||
title_match = re.search(r"商品标题[::]\s*([^\s]+(?:\s+[^\s订]+)*)", content)
|
||||
product_title = title_match.group(1).strip() if title_match else ""
|
||||
amount = float(info.get("amount", 0))
|
||||
quantity = int(info.get("quantity", 0))
|
||||
order_status = info.get("order_status", "")
|
||||
buyer_note = info.get("buyer_note", "")
|
||||
|
||||
await asyncio.to_thread(
|
||||
upsert_order,
|
||||
customer_id=msg.user_id,
|
||||
order_id=order_id,
|
||||
order_status=order_status,
|
||||
acc_id=msg.acc_id,
|
||||
product_title=product_title,
|
||||
amount=amount,
|
||||
quantity=quantity,
|
||||
buyer_note=buyer_note,
|
||||
)
|
||||
logger.info(f"[订单入库] user={msg.user_id} order={order_id} status={order_status} amount={amount}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Orchestrator] 订单消息处理异常: {e}")
|
||||
|
||||
async def _analyze_images_background(self, session_key: str, image_urls: List[str]):
|
||||
async def _analyze_images_background(self, session_key: str, image_urls: List[str], requirement_text: str = ""):
|
||||
"""后台静默分析图片,存入用户数据库用于数据标定"""
|
||||
try:
|
||||
from services.service_image_analyzer import image_analyzer_service
|
||||
@@ -127,9 +498,9 @@ class SystemOrchestrator:
|
||||
db = CustomerDatabase()
|
||||
profile = db.get_customer(session_key)
|
||||
|
||||
for url in image_urls:
|
||||
for url in (image_urls or [])[:1]:
|
||||
try:
|
||||
result = await image_analyzer_service.analyze(url)
|
||||
result = await image_analyzer_service.analyze(url, customer_requirement=requirement_text)
|
||||
result_json = json.dumps(result, ensure_ascii=False)
|
||||
|
||||
# 更新最近一次分析
|
||||
@@ -175,13 +546,26 @@ class SystemOrchestrator:
|
||||
|
||||
async def _debounced_process(self, session_key: str, user_id: str, platform: str):
|
||||
try:
|
||||
# 记录开始时间(防抖前)
|
||||
process_start = time.time()
|
||||
|
||||
await asyncio.sleep(self._debounce_seconds)
|
||||
async with self._get_user_lock(session_key):
|
||||
messages = self._pending_messages.pop(session_key, [])
|
||||
if not messages: return
|
||||
|
||||
# A. 合并与元数据修复
|
||||
combined_content = "\n".join([m.content for m in messages if m.content.strip()])
|
||||
debounce_elapsed = time.time() - process_start
|
||||
logger.info(f"[计时] user={user_id} 防抖等待完成: {debounce_elapsed:.1f}s")
|
||||
|
||||
# A. 合并与元数据修复(去重:同一防抖窗口内完全相同的内容只保留一条)
|
||||
seen_contents = set()
|
||||
unique_parts = []
|
||||
for m in messages:
|
||||
c = (m.content or "").strip()
|
||||
if c and c not in seen_contents:
|
||||
seen_contents.add(c)
|
||||
unique_parts.append(c)
|
||||
combined_content = "\n".join(unique_parts)
|
||||
all_image_urls = []
|
||||
acc_id = messages[-1].acc_id
|
||||
acc_type = messages[-1].acc_type
|
||||
@@ -203,45 +587,225 @@ class SystemOrchestrator:
|
||||
)
|
||||
|
||||
# B. 持久化
|
||||
db_start = time.time()
|
||||
db_content = combined_content
|
||||
if all_image_urls: db_content = f"【系统:已收到{len(all_image_urls)}张图】\n{combined_content}"
|
||||
await repo.save_chat(platform, user_id, db_content, "in", acc_id=acc_id, image_urls=all_image_urls)
|
||||
await repo.save_chat(
|
||||
platform,
|
||||
user_id,
|
||||
db_content,
|
||||
"in",
|
||||
acc_id=acc_id,
|
||||
image_urls=all_image_urls,
|
||||
msg_type=final_msg.msg_type,
|
||||
)
|
||||
db_elapsed = time.time() - db_start
|
||||
logger.info(f"[计时] user={user_id} 消息入库: {db_elapsed:.2f}s")
|
||||
|
||||
# B2. 后台图片分析(不阻塞主流程,用于数据标定)
|
||||
if all_image_urls:
|
||||
asyncio.create_task(self._analyze_images_background(session_key, all_image_urls))
|
||||
asyncio.create_task(self._analyze_images_background(session_key, all_image_urls, combined_content))
|
||||
|
||||
# C. 冷却检查:如果转接冷却期内发过转接,告诉大脑"已处于转接中"
|
||||
is_in_cooldown = (time.time() - self._last_transfer_time.get(session_key, 0)) < TRANSFER_COOLDOWN_SEC
|
||||
history_start = time.time()
|
||||
history = await repo.get_chat_history(user_id, limit=12, acc_id=acc_id)
|
||||
history_elapsed = time.time() - history_start
|
||||
logger.info(f"[计时] user={user_id} 查询历史: {history_elapsed:.2f}s (共{len(history)}条)")
|
||||
ai_history = history[:-1] if history and history[-1].get("content") == db_content else history
|
||||
ai_history = self._sanitize_history_for_ai(ai_history)
|
||||
|
||||
# D. 思考
|
||||
history = await repo.get_chat_history(user_id, limit=10, acc_id=acc_id)
|
||||
if history and history[-1]['content'] == db_content: history = history[:-1]
|
||||
# C. 短时间追问且疑似没真正接上人工:优先补发一次转接
|
||||
std_res = await self._retry_stalled_transfer_if_needed(
|
||||
session_key=session_key,
|
||||
user_id=user_id,
|
||||
platform=platform,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
history=history,
|
||||
)
|
||||
|
||||
# 只在“明确又要转接”时注入冷却提示,普通问候/新需求不注入
|
||||
transfer_intent = self._has_transfer_intent(combined_content)
|
||||
if is_in_cooldown and transfer_intent:
|
||||
final_msg.content = f"【系统:当前已向设计师发出转接请求,请勿再次调用转接工具】\n{final_msg.content}"
|
||||
# D. 冷却检查:转接成功后冷却期内,直接回安抚话术,不调AI
|
||||
last_transfer = self._last_transfer_time.get(session_key, 0)
|
||||
cooldown_elapsed = time.time() - last_transfer
|
||||
is_in_cooldown = cooldown_elapsed < TRANSFER_COOLDOWN_SEC
|
||||
|
||||
std_res = await self.brain.think_and_reply(final_msg, history=history)
|
||||
if std_res is None and is_in_cooldown:
|
||||
idx = self._transfer_calm_idx.get(session_key, 0)
|
||||
calm_reply = _TRANSFER_CALM_REPLIES[idx % len(_TRANSFER_CALM_REPLIES)]
|
||||
self._transfer_calm_idx[session_key] = idx + 1
|
||||
logger.info(f"[Orchestrator] 转接冷却中({cooldown_elapsed:.0f}s),直接安抚: {calm_reply}")
|
||||
std_res = StandardResponse(
|
||||
reply_content=calm_reply,
|
||||
metadata={"acc_id": acc_id, "acc_type": acc_type}
|
||||
)
|
||||
|
||||
# E. 发送并记录时间
|
||||
if std_res is None:
|
||||
# E. 正常流程:调用AI思考
|
||||
ai_start = time.time()
|
||||
std_res = await self.brain.think_and_reply(final_msg, history=ai_history)
|
||||
ai_elapsed = time.time() - ai_start
|
||||
total_elapsed = time.time() - process_start
|
||||
logger.info(f"[计时] user={user_id} AI思考: {ai_elapsed:.1f}s | 总耗时: {total_elapsed:.1f}s")
|
||||
|
||||
# F. 发送并记录时间
|
||||
if std_res.should_reply:
|
||||
# 关键修复:补全发送时的元数据,解决日志 customer_id 为空的问题
|
||||
std_res.metadata = {"acc_id": acc_id, "acc_type": acc_type}
|
||||
std_res.reply_content = self._sanitize_outbound_text(std_res.reply_content)
|
||||
meta = dict(std_res.metadata or {})
|
||||
meta.update({"acc_id": acc_id, "acc_type": acc_type})
|
||||
std_res.metadata = meta
|
||||
|
||||
# 转接场景:先发一句安抚话,再发转接指令
|
||||
if "[转移会话]" in std_res.reply_content:
|
||||
designer_name = self._extract_designer_name(std_res.reply_content)
|
||||
transfer_prelude = str(std_res.metadata.get("transfer_prelude") or "").strip()
|
||||
greet = StandardResponse(
|
||||
reply_content=transfer_prelude or "收到,我叫设计师来看下哈",
|
||||
metadata={"acc_id": acc_id, "acc_type": acc_type}
|
||||
)
|
||||
await self.qianniu_adapter.translate_outbound(greet, user_id)
|
||||
await repo.save_chat(
|
||||
platform,
|
||||
user_id,
|
||||
greet.reply_content,
|
||||
"out",
|
||||
acc_id=acc_id,
|
||||
msg_type=greet.msg_type,
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
await self.qianniu_adapter.translate_outbound(std_res, user_id)
|
||||
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,
|
||||
msg_type=std_res.msg_type,
|
||||
)
|
||||
|
||||
if std_res.metadata.get("pending_transfer"):
|
||||
reason = str(std_res.metadata.get("pending_transfer_reason") or "").strip()
|
||||
if reason:
|
||||
pending_id = await asyncio.to_thread(
|
||||
enqueue_pending_transfer,
|
||||
customer_id=user_id,
|
||||
acc_id=acc_id,
|
||||
acc_type=acc_type,
|
||||
platform=platform,
|
||||
reason=reason,
|
||||
)
|
||||
logger.info(
|
||||
f"[Orchestrator] 已加入待转接池: pending_id={pending_id} user={user_id} acc={acc_id}"
|
||||
)
|
||||
|
||||
if "[转移会话]" in std_res.reply_content:
|
||||
self._last_transfer_time[session_key] = time.time()
|
||||
self._schedule_auto_pipeline(
|
||||
session_key=session_key,
|
||||
customer_id=user_id,
|
||||
acc_id=acc_id,
|
||||
designer_name=self._extract_designer_name(std_res.reply_content),
|
||||
requirement_text=combined_content,
|
||||
history=history,
|
||||
image_urls=all_image_urls,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError: pass
|
||||
except Exception as e: logger.exception(f"[Orchestrator] 处理失败: {e}")
|
||||
|
||||
async def handle_outbound_event(self, user_id: str, platform: str, response: StandardResponse):
|
||||
if platform == "qianniu":
|
||||
if response and response.msg_type == 0:
|
||||
response.reply_content = self._sanitize_outbound_text(response.reply_content)
|
||||
await self.qianniu_adapter.translate_outbound(response, user_id)
|
||||
|
||||
async def _process_pending_transfers_loop(self):
|
||||
while True:
|
||||
try:
|
||||
if not self.ws_client or not getattr(self.ws_client, "websocket", None):
|
||||
await asyncio.sleep(PENDING_TRANSFER_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
rows = await asyncio.to_thread(claim_due_pending_transfers, 10)
|
||||
if not rows:
|
||||
await asyncio.sleep(PENDING_TRANSFER_POLL_SECONDS)
|
||||
continue
|
||||
|
||||
for row in rows:
|
||||
row_id = int(row["id"])
|
||||
customer_id = str(row.get("customer_id") or "")
|
||||
acc_id = str(row.get("acc_id") or "")
|
||||
acc_type = str(row.get("acc_type") or "AliWorkbench")
|
||||
reason = str(row.get("reason") or "").strip()
|
||||
|
||||
try:
|
||||
designer_name = await dispatch_service.assign_designer(user_id=customer_id)
|
||||
if not designer_name:
|
||||
await asyncio.to_thread(
|
||||
retry_pending_transfer,
|
||||
row_id,
|
||||
PENDING_TRANSFER_RETRY_SECONDS,
|
||||
"designer_unavailable",
|
||||
)
|
||||
continue
|
||||
|
||||
notify = StandardResponse(
|
||||
reply_content="设计师上线了,我给您转过去哈",
|
||||
metadata={"acc_id": acc_id, "acc_type": acc_type},
|
||||
)
|
||||
transfer = StandardResponse(
|
||||
reply_content=f"正在为您转接|[转移会话],{designer_name},无原因",
|
||||
need_transfer=True,
|
||||
metadata={"acc_id": acc_id, "acc_type": acc_type},
|
||||
)
|
||||
|
||||
await self.qianniu_adapter.translate_outbound(notify, customer_id)
|
||||
await repo.save_chat(
|
||||
"qianniu",
|
||||
customer_id,
|
||||
notify.reply_content,
|
||||
"out",
|
||||
acc_id=acc_id,
|
||||
msg_type=notify.msg_type,
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
await self.qianniu_adapter.translate_outbound(transfer, customer_id)
|
||||
await repo.save_chat(
|
||||
"qianniu",
|
||||
customer_id,
|
||||
transfer.reply_content,
|
||||
"out",
|
||||
acc_id=acc_id,
|
||||
msg_type=transfer.msg_type,
|
||||
)
|
||||
|
||||
self._last_transfer_time[f"{customer_id}@{acc_id}"] = time.time()
|
||||
history = await repo.get_chat_history(customer_id, limit=12, acc_id=acc_id)
|
||||
self._schedule_auto_pipeline(
|
||||
session_key=f"{customer_id}@{acc_id}",
|
||||
customer_id=customer_id,
|
||||
acc_id=acc_id,
|
||||
designer_name=designer_name,
|
||||
requirement_text=reason,
|
||||
history=history,
|
||||
)
|
||||
await asyncio.to_thread(complete_pending_transfer, row_id)
|
||||
logger.info(
|
||||
f"[Orchestrator] 待转接自动完成: pending_id={row_id} user={customer_id} designer={designer_name} reason={reason}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Orchestrator] 待转接处理失败 pending_id={row_id}: {e}")
|
||||
await asyncio.to_thread(
|
||||
retry_pending_transfer,
|
||||
row_id,
|
||||
PENDING_TRANSFER_RETRY_SECONDS,
|
||||
str(e),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"[Orchestrator] 待转接轮询异常: {e}")
|
||||
await asyncio.sleep(PENDING_TRANSFER_RETRY_SECONDS)
|
||||
|
||||
# 全局单例
|
||||
orchestrator: Optional[SystemOrchestrator] = None
|
||||
def init_orchestrator(ws_client):
|
||||
|
||||
@@ -1,17 +1,150 @@
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
from typing import List, Optional, Any, Dict
|
||||
from pydantic_ai import Agent, RunContext
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
from core.schema import StandardMessage, StandardResponse
|
||||
from core.agent_tools import register_agent_tools
|
||||
from core.agent_tools import register_agent_tools, TransferSuccessException
|
||||
from services.service_wecom_bot import wecom_bot_service
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
# 日志详细程度:设置环境变量 AI_LOG_LEVEL=debug 可获得完整日志
|
||||
_LOG_FULL_PROMPT = os.getenv("AI_LOG_LEVEL", "").lower() == "debug"
|
||||
_LOG_CLIP_LIMIT = int(os.getenv("AI_LOG_CLIP", "2000")) # 日志截断长度
|
||||
|
||||
from core.skill_manager import skill_manager
|
||||
|
||||
|
||||
_INTERNAL_TOOL_MARKERS = (
|
||||
"【历史记录摘要】",
|
||||
"【详细记录】",
|
||||
"【订单摘要】",
|
||||
"【订单详情】",
|
||||
)
|
||||
_TRANSFER_COMMAND_RE = re.compile(r"^\s*正在为您转接\|\[转移会话\],[^,\r\n]+,[^\r\n]*\s*$")
|
||||
|
||||
# 历史记录格式检测模式(AI 转述历史时容易泄露)
|
||||
_HISTORY_LEAK_PATTERNS = [
|
||||
r'\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*(客户|客服)[::]', # [2026-03-07 12:00:00] 客户:
|
||||
r'\[\d{2}:\d{2}:\d{2}\]\s*(客户|客服|我)[::]', # [12:00:00] 客户:
|
||||
r'(根据|查看|查询|翻看)(历史|聊天|对话)(记录|内容)', # 根据历史记录
|
||||
r'历史(记录|对话|消息)(显示|表明|中)', # 历史记录显示
|
||||
r'之前的(聊天|对话|记录)(中|里|显示)', # 之前的聊天中
|
||||
r'共\d+条(历史|对话)?消息', # 共30条历史消息
|
||||
r'订单号[::]\s*\d{10,}', # 订单号:xxxxxxxxxx
|
||||
r'(状态|金额|数量)[::].*(状态|金额|数量)[::]', # 状态:xxx 金额:xxx 连续出现
|
||||
]
|
||||
|
||||
_FIND_ORIGINAL_INTENT_KEYWORDS = (
|
||||
"找图",
|
||||
"找原图",
|
||||
"原图",
|
||||
"素材",
|
||||
"大图",
|
||||
"源图",
|
||||
)
|
||||
|
||||
_FIND_ORIGINAL_QUESTION_KEYWORDS = (
|
||||
"有吗",
|
||||
"有没",
|
||||
"有没有",
|
||||
"能找吗",
|
||||
"找得到吗",
|
||||
"能不能找到",
|
||||
"能找到吗",
|
||||
)
|
||||
|
||||
_REPAIR_INTENT_KEYWORDS = (
|
||||
"修复",
|
||||
"高清修复",
|
||||
"高清",
|
||||
"清晰",
|
||||
"清楚",
|
||||
"变清晰",
|
||||
"修清楚",
|
||||
"放大清晰",
|
||||
)
|
||||
|
||||
_IMAGE_ALREADY_SENT_HINT_KEYWORDS = (
|
||||
"上面不是发了吗",
|
||||
"上面不是有吗",
|
||||
"我不是发了吗",
|
||||
"前面不是发了吗",
|
||||
"前面发了",
|
||||
"上面发了",
|
||||
"我发过了",
|
||||
"不是发了吗",
|
||||
"都发了",
|
||||
"你没看到吗",
|
||||
"聊天记录里有",
|
||||
"上面有图",
|
||||
)
|
||||
|
||||
_PAYMENT_LINK_REQUEST_KEYWORDS = (
|
||||
"付款链接",
|
||||
"支付链接",
|
||||
"拍单链接",
|
||||
"下单链接",
|
||||
"付款吧",
|
||||
"发我链接",
|
||||
"发个链接",
|
||||
"发下链接",
|
||||
"发一下链接",
|
||||
"给我链接",
|
||||
)
|
||||
|
||||
_FILE_HANDOFF_TRANSFER_KEYWORDS = (
|
||||
"发送文件了看到了吗",
|
||||
"发文件了看到了吗",
|
||||
"文件发了看到了吗",
|
||||
"文件收到了吗",
|
||||
"文件收到没",
|
||||
"文件看到了吗",
|
||||
"我把文件发过去了",
|
||||
"文件发过去了",
|
||||
"给你发文件了",
|
||||
"源文件发过去了",
|
||||
"文件发你了",
|
||||
)
|
||||
|
||||
_DELIVERY_HANDOFF_HINT_KEYWORDS = (
|
||||
"发给我吧原图",
|
||||
"原图发给我",
|
||||
"把原图发给我",
|
||||
"发我吧原图",
|
||||
"把文件发给我",
|
||||
"把成品发给我",
|
||||
"做好了发给我",
|
||||
"做好了直接发我",
|
||||
"做完了发给我",
|
||||
"发过来吧",
|
||||
)
|
||||
|
||||
_DESIGNER_SCHEDULE_QUESTION_KEYWORDS = (
|
||||
"几点上班",
|
||||
"什么时候上班",
|
||||
"大概什么时候上班",
|
||||
"一般几点上班",
|
||||
"明天几点上班",
|
||||
"设计师几点上班",
|
||||
"设计师什么时候上班",
|
||||
"几点在线",
|
||||
"什么时候在线",
|
||||
"设计师在吗",
|
||||
"上班了没",
|
||||
"设计师上班了吗",
|
||||
)
|
||||
|
||||
DESIGNER_WORK_START_HOUR = 9
|
||||
DESIGNER_WORK_END_HOUR = 12
|
||||
|
||||
|
||||
def _clip(text: str, limit: int = 1200) -> str:
|
||||
if text is None:
|
||||
return ""
|
||||
@@ -21,6 +154,46 @@ def _clip(text: str, limit: int = 1200) -> str:
|
||||
return f"{text[:limit]}...(截断, 共{len(text)}字)"
|
||||
|
||||
|
||||
async def _notify_brain_fallback(
|
||||
msg: StandardMessage,
|
||||
error: Exception,
|
||||
history_messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
history_messages = history_messages or []
|
||||
recent_lines: List[str] = []
|
||||
for item in history_messages[-3:]:
|
||||
role = str(item.get("role") or "").strip() or "unknown"
|
||||
content = _clip(str(item.get("content") or "").replace("\r", " ").replace("\n", " "), 80)
|
||||
if content:
|
||||
recent_lines.append(f"{role}: {content}")
|
||||
|
||||
current_input = _clip(str(msg.content or "").replace("\r", " ").replace("\n", " "), 200)
|
||||
image_count = len(getattr(msg, "image_urls", None) or [])
|
||||
lines = [
|
||||
"【AI兜底告警】",
|
||||
f"店铺:{msg.acc_id or '-'}",
|
||||
f"客户:{msg.user_id or '-'}",
|
||||
f"消息类型:{getattr(msg, 'msg_type', '-')}",
|
||||
f"图片数:{image_count}",
|
||||
f"当前消息:{current_input or '-'}",
|
||||
f"错误:{_clip(str(error), 300)}",
|
||||
]
|
||||
if recent_lines:
|
||||
lines.append("最近上下文:")
|
||||
lines.extend(recent_lines)
|
||||
|
||||
try:
|
||||
ok = await wecom_bot_service.send_text("\n".join(lines))
|
||||
if ok:
|
||||
logger.info(f"[Brain Fallback Alert] 已发送企业微信告警 user={msg.user_id} acc={msg.acc_id}")
|
||||
else:
|
||||
logger.warning(f"[Brain Fallback Alert] 企业微信告警发送失败 user={msg.user_id} acc={msg.acc_id}")
|
||||
except Exception as notify_err:
|
||||
logger.warning(
|
||||
f"[Brain Fallback Alert] 企业微信告警异常 user={msg.user_id} acc={msg.acc_id}: {notify_err}"
|
||||
)
|
||||
|
||||
|
||||
def _fmt_time(ts: Any) -> str:
|
||||
s = str(ts or "").strip()
|
||||
if not s:
|
||||
@@ -30,6 +203,151 @@ def _fmt_time(ts: Any) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def _sanitize_reply_text(reply_text: str) -> str:
|
||||
if not reply_text:
|
||||
return ""
|
||||
|
||||
text = str(reply_text)
|
||||
text = re.sub(r'\[\]<\|[^|]+\|>', '', text)
|
||||
text = re.sub(r'<\|[^|]*\|>', '', text)
|
||||
text = re.sub(r'\[Function[^\]]*\]', '', text)
|
||||
text = re.sub(r'\[/?Tool[^\]]*\]', '', text)
|
||||
text = re.sub(r'</?tool[_\-]?[^>]*>', '', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'<think[_a-zA-Z0-9]*[^>]*>.*?</think[_a-zA-Z0-9]*[^>]*>', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'<think[_a-zA-Z0-9]*[^>]*>.*', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'</?think[_a-zA-Z0-9]*[^>]*>', '', text)
|
||||
text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'AgentRunResult\([^)]*\)', '', text)
|
||||
text = re.sub(r'\[/?[A-Z][a-zA-Z]*(?:Call|End|Start|Result|Return)[^\]]*\]', '', text)
|
||||
text = re.sub(r'^\s*\[\s*\{.*$', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'^\s*[\[{].*"name"\s*:.*$', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'[\[\]]{2,}', '', text)
|
||||
text = text.strip()
|
||||
|
||||
if _TRANSFER_COMMAND_RE.fullmatch(text):
|
||||
return text
|
||||
|
||||
if "[转移会话]" in text:
|
||||
logger.warning("[Brain] 拦截到混入正文的转接指令,降级为安全兜底回复")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
|
||||
# 检查固定标记
|
||||
if any(marker in text for marker in _INTERNAL_TOOL_MARKERS):
|
||||
logger.warning("[Brain] 拦截到工具原文泄露,降级为安全兜底回复")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
|
||||
# 检查历史记录泄露模式(AI 转述历史内容)
|
||||
for pattern in _HISTORY_LEAK_PATTERNS:
|
||||
if re.search(pattern, text):
|
||||
logger.warning(f"[Brain] 检测到历史记录泄露模式: {pattern[:30]}...")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _normalize_text(text: Any) -> str:
|
||||
return str(text or "").strip().lower()
|
||||
|
||||
|
||||
def _infer_image_intent(current_text: str, history: Optional[List[dict]] = None) -> str:
|
||||
text = _normalize_text(current_text)
|
||||
recent_user_text = "\n".join(
|
||||
_normalize_text(h.get("content", ""))
|
||||
for h in (history or [])[-6:]
|
||||
if h.get("role") == "user"
|
||||
)
|
||||
combined = f"{recent_user_text}\n{text}"
|
||||
|
||||
if any(k in combined for k in _REPAIR_INTENT_KEYWORDS):
|
||||
return "repair"
|
||||
|
||||
if any(k in combined for k in _FIND_ORIGINAL_INTENT_KEYWORDS):
|
||||
return "find_original"
|
||||
|
||||
if any(k in text for k in _FIND_ORIGINAL_QUESTION_KEYWORDS):
|
||||
return "find_original"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _history_has_customer_image(history: Optional[List[dict]] = None) -> bool:
|
||||
for item in history or []:
|
||||
if item.get("role") != "user":
|
||||
continue
|
||||
msg_type = int(item.get("msg_type") or 0)
|
||||
image_urls = item.get("image_urls") or []
|
||||
if isinstance(image_urls, str):
|
||||
image_urls = [part for part in image_urls.splitlines() if part.strip()]
|
||||
content = str(item.get("content") or "")
|
||||
if msg_type == 1 or image_urls or ("已收到" in content and "图" in content):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _customer_claims_image_already_sent(current_text: str, history: Optional[List[dict]] = None) -> bool:
|
||||
text = _normalize_text(current_text)
|
||||
if not text or not _history_has_customer_image(history):
|
||||
return False
|
||||
return any(keyword in text for keyword in _IMAGE_ALREADY_SENT_HINT_KEYWORDS)
|
||||
|
||||
|
||||
def _requests_payment_link(current_text: str, history: Optional[List[dict]] = None) -> bool:
|
||||
text = _normalize_text(current_text)
|
||||
if not text:
|
||||
return False
|
||||
if any(keyword in text for keyword in _PAYMENT_LINK_REQUEST_KEYWORDS):
|
||||
return True
|
||||
return ("付款" in text or "支付" in text or "拍单" in text or "下单" in text) and "链接" in text
|
||||
|
||||
|
||||
def _history_has_transfer_or_order(history: Optional[List[dict]] = None) -> bool:
|
||||
for item in history or []:
|
||||
content = str(item.get("content") or "")
|
||||
if not content:
|
||||
continue
|
||||
if "[转移会话]" in content or "设计师上线了" in content:
|
||||
return True
|
||||
if "[系统订单信息]" in content or "订单状态:" in content or "订单号:" in content:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _requests_file_handoff_transfer(current_text: str, history: Optional[List[dict]] = None) -> bool:
|
||||
text = _normalize_text(current_text)
|
||||
if not text:
|
||||
return False
|
||||
if any(keyword in text for keyword in _FILE_HANDOFF_TRANSFER_KEYWORDS):
|
||||
return True
|
||||
if any(keyword in text for keyword in _DELIVERY_HANDOFF_HINT_KEYWORDS):
|
||||
return True
|
||||
|
||||
has_file_signal = "文件" in text or "源文件" in text
|
||||
has_delivery_signal = any(token in text for token in ("发了", "发送了", "发过去", "发你", "给你发"))
|
||||
has_receipt_signal = any(token in text for token in ("看到了吗", "收到了吗", "收到没", "看见了吗", "收到吗"))
|
||||
if has_file_signal and (has_delivery_signal or has_receipt_signal):
|
||||
return True
|
||||
|
||||
if _history_has_transfer_or_order(history):
|
||||
has_asset_signal = any(token in text for token in ("原图", "源文件", "文件", "成品", "图", "图片"))
|
||||
has_send_request = any(
|
||||
token in text
|
||||
for token in ("发给我", "发我", "发过来", "给我吧", "给我发", "直接发", "传给我", "传过来")
|
||||
)
|
||||
if has_asset_signal and has_send_request:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _asks_designer_schedule(current_text: str, history: Optional[List[dict]] = None) -> bool:
|
||||
text = _normalize_text(current_text)
|
||||
if not text:
|
||||
return False
|
||||
if any(keyword in text for keyword in _DESIGNER_SCHEDULE_QUESTION_KEYWORDS):
|
||||
return True
|
||||
return ("上班" in text or "在线" in text) and ("几点" in text or "什么时候" in text or "在吗" in text)
|
||||
|
||||
|
||||
class CustomerServiceBrain:
|
||||
"""
|
||||
重构后的单一 Agent 大脑:
|
||||
@@ -55,29 +373,76 @@ class CustomerServiceBrain:
|
||||
system_prompt = (
|
||||
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话自然、专业。\n\n"
|
||||
|
||||
"【统一称呼规范】\n"
|
||||
"1. 严禁使用'师傅'、'客服'、'专员'等词汇!必须统一称为【设计师】。\n"
|
||||
"2. 未转接前,用第一人称(我/我这边)。例如:'我叫设计师看下'。\n\n"
|
||||
"【统一称呼规范 - 第一人称原则】\n"
|
||||
"1. 你就是店主本人,未转接设计师之前,所有回复必须用第一人称:'我'、'我这边'。\n"
|
||||
"2. 例如:客户问进度 → '我在看哈,稍等';客户催 → '我帮你催下哈'。\n"
|
||||
"3. 只有在需要转接时才提'设计师':'我叫设计师来看下哈'。\n"
|
||||
"4. 严禁使用'师傅'、'客服'、'专员'等词汇。\n\n"
|
||||
|
||||
"【★★★ 历史记录查询 - 最高优先级 ★★★】\n"
|
||||
"你有一个 lookup_chat_history_tool 工具,可以查询客户的完整历史聊天记录。\n"
|
||||
"以下情况你【必须】先调用此工具查历史,再回复:\n"
|
||||
"1. 客户说'之前聊过'、'上次'、'你看聊天记录'、'我发过了'、'前面发了'等\n"
|
||||
"2. 客户追问进度:'做好了吗'、'多久能好'、'怎么样了'\n"
|
||||
"3. 客户表达不满或困惑:'?'、'你瞎么'、'搞笑'、'说过了'\n"
|
||||
"4. 【近期对话回顾】中显示客户之前已发过图或说过需求\n"
|
||||
"查到历史后,根据历史内容回复,绝对不要再重复问客户已经回答过的问题!\n\n"
|
||||
|
||||
"【订单查询工具】\n"
|
||||
"你有一个 lookup_customer_orders_tool 工具,可以查询客户的订单记录。\n"
|
||||
"以下情况你【必须】调用此工具:\n"
|
||||
"1. 客户问'我付款了'、'订单怎么样了'、'发货了吗'\n"
|
||||
"2. 客户提到订单号\n"
|
||||
"3. 你需要确认客户是否已付款再决定如何回复\n"
|
||||
"查到订单后,根据订单状态回复(已付款→'收到,马上安排';已发货→'已经发了哈')。\n\n"
|
||||
|
||||
"【核心逻辑】\n"
|
||||
"1. 业务:只聊高清修复和找原图。核心链路:引导发图 -> 问需求 -> 找设计师。\n"
|
||||
"2. **主动引导(关键)**:如果客户【没发图】就问能不能做、问收费,你必须回:'亲亲先发图我看下哈'。\n"
|
||||
"3. **非业务问题**:如果客户问招聘、合作、闲聊等与做图无关的话题,礼貌拒绝:'亲亲咱这边只做图哦,暂不招人哈'。\n"
|
||||
"4. **客户说没有参考图**:如果客户明确说'没有图'、'找不到'、'想让你们帮找',直接转人工:'好的,我这就叫设计师帮您找哈'。\n"
|
||||
"5. **客户问尺寸/能否打印/退款**:这类问题需要设计师判断,直接转人工:'这个设计师帮您看下哈'。\n"
|
||||
"6. 转接时机:收到图片并明确需求后,立即调用转人工工具,并告知:'收到,正在呼叫设计师核价,稍等哈'。\n"
|
||||
"7. **下线安抚(重要)**:只有当【本次】工具返回 'ERROR_NO_DESIGNER_ONLINE' 时才能说下班。不能根据历史对话或自己猜测说下班!\n"
|
||||
"8. 正在转接中:如果系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'。\n"
|
||||
"9. **每次转接必须调用工具**:不要根据之前的结果猜测,每次需要转接都必须重新调用工具检查设计师是否在线。\n\n"
|
||||
"2. **主动引导**:只有当客户【从未发过图】且没有历史图片记录时,才引导发图。\n"
|
||||
"2.1 **消息延迟安抚**:如果客户说'上面不是发了吗'、'我发过了'、'你没看到吗',说明他在提醒你图早就发过了。\n"
|
||||
" 这时先道歉,类似'不好意思哈,刚刚消息慢了点',再承接后续;严禁让客户重发图片。\n"
|
||||
"3. **非业务问题**:如果客户问招聘、合作、闲聊等与做图无关的话题,礼貌拒绝。\n"
|
||||
"4. **客户说没有参考图**:直接转人工:'好的,我这就叫设计师帮您找哈'。\n"
|
||||
"5. **客户问尺寸/能否打印/退款**:直接转人工:'这个设计师帮您看下哈'。\n"
|
||||
"6. **付款链接特判**:客户明确说'发付款链接'、'支付链接'、'拍单链接'、'下单链接'时,视为强成交信号,必须立即调用转人工工具;严禁只回复'直接下单'。\n"
|
||||
"7. **设计师上班时间特判**:客户问'几点上班'、'什么时候上班'、'设计师在吗'、'什么时候在线'时,默认是在问设计师。不要按闲聊处理,也不要回复'我只处理业务'。\n"
|
||||
" 设计师固定是早上9点上班,12点下班。应结合当前时间自然回答,不要机械复读。\n"
|
||||
"8. **转接时机(严格两步)**:除付款链接特判、文件交接特判外,必须同时满足【有图】+【客户明确或可直接判断的需求】才能转接。\n"
|
||||
" 客户只发了图但没说需求 → 先问'亲亲这张是找原图还是修复哈?'\n"
|
||||
" 客户说了'有吗'、'能找吗'、'找图'、'找原图'、'有大图吗' → 直接按【找原图】意图处理,不要重复追问。\n"
|
||||
" 客户说了'修复'、'高清'、'清晰点'、'放大清晰' → 直接按【高清修复】意图处理,不要重复追问。\n"
|
||||
" 客户说'文件发过去了'、'发送文件了看到了吗'、'源文件发你了'、'发给我吧原图'、'做好了直接发我'这类交付话术 → 视为设计师成品/文件交接场景,必须立即调用转人工工具,不要再问客户发图或问需求。\n"
|
||||
"9. **下线安抚**:只有工具返回ERROR时才能提设计师不在。根据错误码区分:\n"
|
||||
" - ERROR_DESIGNER_NOT_STARTED → 说'还没上班,记下了上班马上处理'(严禁说下班)\n"
|
||||
" - ERROR_DESIGNER_OFFLINE → 说'下班了,需求记下明天回'\n"
|
||||
" - ERROR_DESIGNER_BUSY → 说'稍等,我帮你联系下'(严禁说下班)\n"
|
||||
"10. 正在转接中:如果系统提示已在转接,回:'已经在帮你催了哈,稍等下!'。\n"
|
||||
"11. **每次转接必须调用工具**:不要猜测,每次都重新调用。\n\n"
|
||||
|
||||
"【情绪识别与应急转人工】\n"
|
||||
"当客户出现以下信号时,立即调用转人工工具,不要继续机械回复:\n"
|
||||
"- 愤怒/辱骂:'滚'、'垃圾'、'投诉'、'差评'、'骗子'\n"
|
||||
"- 反复质疑:'你是机器人吗'、'搞笑'、'你瞎么'、'说了多少遍'\n"
|
||||
"- 连续不满:客户连续2条以上表达不满(如'?'、'...'、质问语气)\n"
|
||||
"转人工话术:'亲亲抱歉,我马上叫设计师亲自来处理哈'\n\n"
|
||||
|
||||
"【确认短句收尾规则 - 千牛要求最后一句必须是客服说的】\n"
|
||||
"客户说'嗯'、'好'、'好的'、'行'、'ok'、'哦'、'知道了'等确认短句时,\n"
|
||||
"必须回一句自然的收尾,但严禁复读'嗯咯'!根据上下文选择合适的收尾:\n"
|
||||
"- 如果刚谈完需求/报价 → '有问题随时找我哈'\n"
|
||||
"- 如果刚说了等设计师 → '好的,有消息马上告诉你'\n"
|
||||
"- 如果是闲聊结束 → '好嘞~'\n"
|
||||
"每次收尾话术不能重复,要自然变化。\n\n"
|
||||
|
||||
"【必杀令 - 严格遵守】\n"
|
||||
"1. 每句回复严禁超过15个字!语气淘宝亲切风,多用'哈'、'呢'。\n"
|
||||
"2. 严禁报价,严禁复读图片已收到的情况。\n"
|
||||
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n"
|
||||
"4. **严禁**说'在呢铁子'!只能说'在呢'或'在呢亲'。\n"
|
||||
"5. **严禁**重复发送相同内容!如果刚说过的话,换一种说法。\n"
|
||||
"5. **严禁**连续两次回复相同或相似内容!回顾你最近说过的话,换一种说法。\n"
|
||||
"6. **严禁**输出任何代码、标记、括号等乱码!只输出自然语言。\n"
|
||||
"7. **严禁**自己臆造'下班'!只有工具返回ERROR才能说下班。\n\n"
|
||||
"7. **严禁**自己臆造'下班'!只有工具返回ERROR才能说下班。\n"
|
||||
"8. **严禁**在客户已发过图的情况下还说'先发图来看看'!先查历史确认。\n\n"
|
||||
|
||||
f"业务参考:\n{all_skills}"
|
||||
)
|
||||
@@ -85,63 +450,177 @@ class CustomerServiceBrain:
|
||||
self.agent = Agent(model=model, system_prompt=system_prompt)
|
||||
register_agent_tools(self.agent)
|
||||
|
||||
async def think_and_reply(self, msg: StandardMessage, history: List[dict] = []) -> StandardResponse:
|
||||
async def think_and_reply(self, msg: StandardMessage, history: Optional[List[dict]] = None) -> StandardResponse:
|
||||
if history is None:
|
||||
history = []
|
||||
try:
|
||||
# 构造增强上下文
|
||||
user_content = msg.content
|
||||
if msg.image_urls:
|
||||
user_content = f"【系统通知:收到客户 {len(msg.image_urls)} 张图】\n{user_content}"
|
||||
user_content = msg.content or ""
|
||||
|
||||
if _requests_payment_link(user_content, history):
|
||||
user_content = (
|
||||
"【系统通知:客户正在明确索要付款/支付链接,这是强成交信号。"
|
||||
"不要只回复'直接下单'或'平台拍单',必须立即调用转人工工具转接设计师跟进付款。】\n"
|
||||
f"{user_content}"
|
||||
)
|
||||
elif _requests_file_handoff_transfer(user_content, history):
|
||||
logger.info(f"[Brain] 已识别为文件交接转接意图: user={msg.user_id}")
|
||||
user_content = (
|
||||
"【系统通知:客户现在是在说文件/原图/成品的交接,或者让你把做好的内容直接发过去。"
|
||||
"这通常代表设计师已经做完,客户现在是在催交付或确认文件收发。"
|
||||
"不要让客户重发图,不要继续问需求,必须立即调用转人工工具转接设计师跟进交付。】\n"
|
||||
f"{user_content}"
|
||||
)
|
||||
elif _asks_designer_schedule(user_content, history):
|
||||
now_dt = datetime.now()
|
||||
user_content = (
|
||||
"【系统通知:客户现在是在问设计师几点上班、什么时候在线、有没有在。"
|
||||
"这是有效业务上下文,不要按闲聊或无关业务拒绝。"
|
||||
f"设计师固定工作时间是每天{DESIGNER_WORK_START_HOUR}点上班,{DESIGNER_WORK_END_HOUR}点下班。"
|
||||
f"当前时间是{now_dt.strftime('%Y-%m-%d %H:%M:%S')}。"
|
||||
"请结合当前时间自然回答:"
|
||||
f"如果现在还没到{DESIGNER_WORK_START_HOUR}点,就表达还没上班,上班后马上处理;"
|
||||
f"如果现在已经在{DESIGNER_WORK_START_HOUR}点到{DESIGNER_WORK_END_HOUR}点之间,就表达设计师已经在了或陆续在处理;"
|
||||
f"如果现在已经过了{DESIGNER_WORK_END_HOUR}点,就表达已经下班,明天{DESIGNER_WORK_START_HOUR}点后处理。"
|
||||
"不要机械照抄这段说明,要自然一点。】\n"
|
||||
f"{user_content}"
|
||||
)
|
||||
|
||||
# 客户已发图:告知 AI 图已收到,引导问需求,但不要直接转接
|
||||
has_image_message = bool(msg.image_urls) or msg.msg_type == 1
|
||||
if not has_image_message and _customer_claims_image_already_sent(user_content, history):
|
||||
inferred_intent = _infer_image_intent(user_content, history)
|
||||
if inferred_intent == "find_original":
|
||||
next_step = "客户当前更像是在问找原图,别再问他有没有发图。"
|
||||
elif inferred_intent == "repair":
|
||||
next_step = "客户当前更像是在问高清修复,别再问他有没有发图。"
|
||||
else:
|
||||
next_step = "如果客户需求还不明确,只问这是找原图还是修复,不要让客户重发。"
|
||||
user_content = (
|
||||
"【系统通知:客户是在提醒你他上面已经发过图片了,可能刚刚网络或消息同步有点慢。"
|
||||
"回复时先简短道歉,表示现在已经看到图了,再继续正常承接。"
|
||||
f"{next_step}】\n{user_content}"
|
||||
)
|
||||
if has_image_message:
|
||||
image_count = max(len(msg.image_urls), 1)
|
||||
if user_content.startswith("【系统:已收到图片消息"):
|
||||
user_content = ""
|
||||
inferred_intent = _infer_image_intent(user_content, history)
|
||||
if inferred_intent == "find_original":
|
||||
logger.info(f"[Brain] 已根据客户表述推断为找原图意图: user={msg.user_id}")
|
||||
user_content = (
|
||||
f"【系统通知:客户已发送 {image_count} 张图片,图已收到不要再让客户发图。"
|
||||
f"系统判断客户当前意图是【找原图】;像'有吗'、'能找吗'、'找图'都算找原图意图。"
|
||||
f"不要再追问'找原图还是高清修复',直接按找原图流程继续;如果信息足够就直接转接。】\n{user_content}"
|
||||
)
|
||||
elif inferred_intent == "repair":
|
||||
logger.info(f"[Brain] 已根据客户表述推断为高清修复意图: user={msg.user_id}")
|
||||
user_content = (
|
||||
f"【系统通知:客户已发送 {image_count} 张图片,图已收到不要再让客户发图。"
|
||||
f"系统判断客户当前意图是【高清修复】;像'修复'、'高清'、'清晰点'都算修复意图。"
|
||||
f"不要再追问'找原图还是高清修复',直接按高清修复流程继续;如果信息足够就直接转接。】\n{user_content}"
|
||||
)
|
||||
else:
|
||||
user_content = (
|
||||
f"【系统通知:客户已发送 {image_count} 张图片,图已收到不要再让客户发图。"
|
||||
f"你现在必须先问客户:这张是找原图还是高清修复?有什么具体要求?"
|
||||
f"等客户明确回答后才能转接,严禁跳过问需求直接转接!】\n{user_content}"
|
||||
)
|
||||
|
||||
recent_context = ""
|
||||
if history:
|
||||
lines = [
|
||||
f"[{_fmt_time(h.get('timestamp'))}] {('客户' if h['role']=='user' else '我')}:{h['content']}"
|
||||
for h in history[-6:]
|
||||
]
|
||||
lines = []
|
||||
for h in history[-6:]:
|
||||
role = "客户" if h.get("role") == "user" else "我"
|
||||
content = h.get("content", "")
|
||||
lines.append(f"[{_fmt_time(h.get('timestamp'))}] {role}:{content}")
|
||||
recent_context = "【近期对话回顾】\n" + "\n".join(lines) + "\n----------------\n"
|
||||
|
||||
full_input = f"{recent_context}现在的对话:{user_content}"
|
||||
logger.info(
|
||||
f"[PROMPT->AI] user={msg.user_id} acc={msg.acc_id} images={len(msg.image_urls)}\n"
|
||||
f"{_clip(full_input)}"
|
||||
full_input = f"【当前客户ID:{msg.user_id}】\n{recent_context}现在的对话:{user_content}"
|
||||
start_time = time.time()
|
||||
|
||||
# ===== 详细日志:发给 AI 的提示词 =====
|
||||
logger.info(f"[AI提示词] user={msg.user_id} acc={msg.acc_id} images={len(msg.image_urls)}\n{full_input}")
|
||||
if history:
|
||||
history_preview = "\n".join([f" {h.get('role','?')}: {str(h.get('content',''))[:50]}" for h in history[-4:]])
|
||||
logger.info(f"[AI历史上下文] 共{len(history)}条:\n{history_preview}")
|
||||
|
||||
# 尝试运行 AI,捕获转接成功异常以提前终止
|
||||
try:
|
||||
result = await self.agent.run(full_input, message_history=history)
|
||||
except TransferSuccessException as e:
|
||||
# 转接工具成功后立即返回,无需等待 AI 继续生成
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"[Brain] 转接成功(提前终止,耗时{elapsed:.1f}s): {e.transfer_cmd[:60]}")
|
||||
return StandardResponse(
|
||||
reply_content=e.transfer_cmd,
|
||||
need_transfer=True,
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
)
|
||||
|
||||
result = await self.agent.run(full_input, message_history=history)
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(f"[Brain] AI处理完成,总耗时{elapsed:.1f}s")
|
||||
|
||||
# --- 终极修复:强制截获工具返回的转接指令 ---
|
||||
# ===== 详细日志:AI 的思考过程和工具调用 =====
|
||||
pending_transfer_reason = ""
|
||||
pending_transfer_error = ""
|
||||
try:
|
||||
all_msgs = result.all_messages()
|
||||
for idx, m in enumerate(all_msgs):
|
||||
msg_kind = getattr(m, 'kind', type(m).__name__)
|
||||
if hasattr(m, 'parts'):
|
||||
for part in m.parts:
|
||||
part_kind = getattr(part, 'part_kind', '')
|
||||
if part_kind == 'tool-call':
|
||||
tool_name = getattr(part, 'tool_name', '?')
|
||||
tool_args = getattr(part, 'args', {})
|
||||
logger.info(f"[AI思考] 步骤{idx+1} 调用工具: {tool_name}({tool_args})")
|
||||
if tool_name == "transfer_to_human_tool":
|
||||
if isinstance(tool_args, str):
|
||||
try:
|
||||
tool_args = json.loads(tool_args)
|
||||
except Exception:
|
||||
tool_args = {"reason": tool_args}
|
||||
if isinstance(tool_args, dict):
|
||||
pending_transfer_reason = str(tool_args.get("reason") or "").strip()
|
||||
elif part_kind == 'tool-return':
|
||||
content = str(getattr(part, 'content', ''))[:200]
|
||||
logger.info(f"[AI思考] 步骤{idx+1} 工具返回: {content}")
|
||||
full_content = str(getattr(part, 'content', ''))
|
||||
if full_content.startswith("ERROR_DESIGNER_"):
|
||||
pending_transfer_error = full_content
|
||||
elif part_kind == 'text':
|
||||
content = str(getattr(part, 'content', ''))[:150]
|
||||
if content.strip():
|
||||
logger.info(f"[AI思考] 步骤{idx+1} 文本输出: {content}")
|
||||
except Exception as log_err:
|
||||
logger.debug(f"[AI思考日志] 解析失败: {log_err}")
|
||||
|
||||
# --- 转接指令:直接从工具返回截获,不经过 AI 二次加工 ---
|
||||
transfer_cmd = ""
|
||||
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:
|
||||
transfer_cmd = content
|
||||
|
||||
if transfer_cmd:
|
||||
logger.info(f"[Brain] 工具返回转接指令,直接发送(跳过AI加工): {transfer_cmd[:60]}")
|
||||
return StandardResponse(
|
||||
reply_content=transfer_cmd,
|
||||
need_transfer=True,
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
)
|
||||
|
||||
# --- 非转接场景:取 AI 的正常回复 ---
|
||||
reply_text = ""
|
||||
# pydantic-ai 1.x 使用 result.output(旧版 0.x 使用 result.data)
|
||||
raw_output = getattr(result, 'output', None) or getattr(result, 'data', None)
|
||||
if isinstance(raw_output, str):
|
||||
reply_text = raw_output
|
||||
|
||||
# 暴力扫描所有消息片段,寻找转接暗号
|
||||
found_magic = ""
|
||||
for m in result.all_messages():
|
||||
if hasattr(m, 'parts'):
|
||||
for part in m.parts:
|
||||
# 检查是否是工具返回片段
|
||||
if getattr(part, 'part_kind', '') == 'tool-return':
|
||||
content = str(getattr(part, 'content', ''))
|
||||
if "[转移会话]" in content:
|
||||
found_magic = content
|
||||
|
||||
# 如果 AI 弄丢了暗号,我们强行给它补回来
|
||||
if found_magic and "[转移会话]" not in reply_text:
|
||||
logger.info(f"[Brain] 检测到 AI 弄丢了转接暗号,正在强制恢复: {found_magic[:30]}...")
|
||||
reply_text = found_magic
|
||||
# ----------------------------------------
|
||||
|
||||
# 清理可能的乱码/代码标记
|
||||
import re
|
||||
reply_text = re.sub(r'\[\]<\|[^|]+\|>', '', reply_text) # 清理 []<|xxx|>
|
||||
reply_text = re.sub(r'<\|[^|]+\|>', '', reply_text) # 清理 <|xxx|>
|
||||
reply_text = re.sub(r'\[Function[^\]]*\]', '', reply_text) # 清理 [FunctionXxx]
|
||||
reply_text = re.sub(r'<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()
|
||||
# 清理模型泄露的内部标记/工具原文
|
||||
reply_text = _sanitize_reply_text(reply_text)
|
||||
|
||||
# 过滤"在呢铁子"
|
||||
if "在呢铁子" in reply_text:
|
||||
@@ -157,9 +636,16 @@ class CustomerServiceBrain:
|
||||
return StandardResponse(
|
||||
reply_content=reply_text,
|
||||
need_transfer=need_transfer,
|
||||
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||
metadata={
|
||||
"acc_id": msg.acc_id,
|
||||
"acc_type": msg.acc_type,
|
||||
"pending_transfer": bool(pending_transfer_error and pending_transfer_reason),
|
||||
"pending_transfer_reason": pending_transfer_reason,
|
||||
"pending_transfer_error": pending_transfer_error,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Brain Error]: {e}")
|
||||
return StandardResponse(reply_content="好哒,设计师正在看图,稍等回你。", metadata={"acc_id": msg.acc_id})
|
||||
await _notify_brain_fallback(msg, e, history)
|
||||
return StandardResponse(reply_content="好哒,我在看图,稍等回你哈。", metadata={"acc_id": msg.acc_id})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime
|
||||
from db.customer_db import db as customer_db
|
||||
@@ -8,6 +9,47 @@ from db.chat_log_db import log_message, get_conversation
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
_OUTBOUND_BLOCK_MARKERS = (
|
||||
"【历史记录摘要】",
|
||||
"【详细记录】",
|
||||
"【订单摘要】",
|
||||
"【订单详情】",
|
||||
"<think",
|
||||
"think_never_used",
|
||||
'[{"name":',
|
||||
)
|
||||
_TRANSFER_COMMAND_RE = re.compile(r"^\s*正在为您转接\|\[转移会话\],[^,\r\n]+,[^\r\n]*\s*$")
|
||||
|
||||
_HISTORY_LEAK_PATTERNS = [
|
||||
r'\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*(客户|客服)[::]',
|
||||
r'\[\d{2}:\d{2}:\d{2}\]\s*(客户|客服|我)[::]',
|
||||
r'(根据|查看|查询|翻看)(历史|聊天|对话)(记录|内容)',
|
||||
r'历史(记录|对话|消息)(显示|表明|中)',
|
||||
r'之前的(聊天|对话|记录)(中|里|显示)',
|
||||
r'共\d+条(历史|对话)?消息',
|
||||
r'订单号[::]\s*\d{10,}',
|
||||
r'(状态|金额|数量)[::].*(状态|金额|数量)[::]',
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_outbound_archive_text(content: str) -> str:
|
||||
if not content:
|
||||
return ""
|
||||
cleaned = str(content).strip()
|
||||
if _TRANSFER_COMMAND_RE.fullmatch(cleaned):
|
||||
return cleaned
|
||||
if "[转移会话]" in cleaned:
|
||||
logger.warning("[Repository] 检测到混入正文的转接指令,拦截出站入库")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
if any(marker in cleaned for marker in _OUTBOUND_BLOCK_MARKERS):
|
||||
logger.warning("[Repository] 拦截到内部内容写入外发记录,替换为安全兜底回复")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
for pattern in _HISTORY_LEAK_PATTERNS:
|
||||
if re.search(pattern, cleaned):
|
||||
logger.warning(f"[Repository] 检测到历史记录泄露模式,拦截出站入库: {pattern[:30]}...")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
return cleaned
|
||||
|
||||
class DataRepository:
|
||||
"""
|
||||
异步数据仓库:使用 asyncio.to_thread 屏蔽底层同步 IO 阻塞。
|
||||
@@ -18,8 +60,19 @@ class DataRepository:
|
||||
|
||||
# --- 聊天记录 (异步化) ---
|
||||
|
||||
async def save_chat(self, platform: str, user_id: str, content: str, direction: str, acc_id: str = "", image_urls: list = None):
|
||||
async def save_chat(
|
||||
self,
|
||||
platform: str,
|
||||
user_id: str,
|
||||
content: str,
|
||||
direction: str,
|
||||
acc_id: str = "",
|
||||
image_urls: list = None,
|
||||
msg_type: int = 0,
|
||||
):
|
||||
"""异步持久化存储聊天记录"""
|
||||
if direction == "out" and int(msg_type or 0) == 0:
|
||||
content = _sanitize_outbound_archive_text(content)
|
||||
# 将图片URL列表转为\n分隔的字符串
|
||||
urls_str = "\n".join(image_urls) if image_urls else ""
|
||||
return await asyncio.to_thread(
|
||||
@@ -29,6 +82,7 @@ class DataRepository:
|
||||
direction=direction,
|
||||
platform=platform,
|
||||
acc_id=acc_id,
|
||||
msg_type=msg_type,
|
||||
image_urls=urls_str
|
||||
)
|
||||
|
||||
@@ -42,6 +96,8 @@ class DataRepository:
|
||||
{
|
||||
"role": role,
|
||||
"content": r["message"],
|
||||
"msg_type": r.get("msg_type", 0),
|
||||
"image_urls": r.get("image_urls", ""),
|
||||
"timestamp": r.get("timestamp", ""),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ class StandardMessage(BaseModel):
|
||||
user_name: str = "" # 发送者昵称
|
||||
content: str # 消息文本内容
|
||||
msg_type: int = 0 # 消息类型:0 文本, 1 图片, 2 语音等
|
||||
image_urls: List[str] = [] # 提取出来的图片链接
|
||||
image_urls: List[str] = Field(default_factory=list) # 提取出来的图片链接
|
||||
acc_id: str = "" # 商家/店铺账号ID
|
||||
acc_type: str = "" # 平台类型标识
|
||||
timestamp: datetime = Field(default_factory=datetime.now)
|
||||
@@ -27,4 +27,4 @@ class StandardResponse(BaseModel):
|
||||
should_reply: bool = True # 是否需要发送
|
||||
need_transfer: bool = False # 是否触发转人工
|
||||
transfer_group: str = "" # 转人工的分组ID
|
||||
metadata: dict = {} # 额外元数据(如埋点、调试信息)
|
||||
metadata: dict = Field(default_factory=dict) # 额外元数据(如埋点、调试信息)
|
||||
|
||||
@@ -14,7 +14,8 @@ class SkillManager:
|
||||
3. 支持热加载(无需重启即可更新 AI 知识)。
|
||||
"""
|
||||
def __init__(self, skills_dir: str = "skills"):
|
||||
self.skills_dir = Path(skills_dir)
|
||||
given = Path(skills_dir)
|
||||
self.skills_dir = given if given.is_absolute() else Path(__file__).resolve().parent.parent / skills_dir
|
||||
self._skill_cache: Dict[str, str] = {}
|
||||
self.reload_skills()
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -51,8 +52,6 @@ class QingjianAPIClient:
|
||||
if not customer_id:
|
||||
return self.worker_id == 0
|
||||
|
||||
import hashlib
|
||||
# 使用稳定的哈希算法分配客户
|
||||
hash_val = int(hashlib.md5(str(customer_id).encode("utf-8")).hexdigest(), 16)
|
||||
return (hash_val % self.worker_count) == self.worker_id
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _AnsiColorFormatter(logging.Formatter):
|
||||
@@ -56,20 +58,67 @@ class _AnsiColorFormatter(logging.Formatter):
|
||||
return f"{color}{msg}{self.RESET}"
|
||||
|
||||
|
||||
_APP_VERSION = None
|
||||
_LOG_RECORD_FACTORY_INSTALLED = False
|
||||
|
||||
|
||||
def get_app_log_version() -> str:
|
||||
global _APP_VERSION
|
||||
if _APP_VERSION:
|
||||
return _APP_VERSION
|
||||
|
||||
env_version = str(os.getenv("APP_VERSION", "")).strip()
|
||||
if env_version:
|
||||
_APP_VERSION = env_version
|
||||
return _APP_VERSION
|
||||
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
git_version = subprocess.check_output(
|
||||
["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip()
|
||||
except Exception:
|
||||
git_version = ""
|
||||
|
||||
_APP_VERSION = git_version or "dev"
|
||||
os.environ.setdefault("APP_VERSION", _APP_VERSION)
|
||||
return _APP_VERSION
|
||||
|
||||
|
||||
def install_log_record_factory():
|
||||
global _LOG_RECORD_FACTORY_INSTALLED
|
||||
if _LOG_RECORD_FACTORY_INSTALLED:
|
||||
return
|
||||
|
||||
version = get_app_log_version()
|
||||
old_factory = logging.getLogRecordFactory()
|
||||
|
||||
def record_factory(*args, **kwargs):
|
||||
record = old_factory(*args, **kwargs)
|
||||
record.app_version = getattr(record, "app_version", version)
|
||||
return record
|
||||
|
||||
logging.setLogRecordFactory(record_factory)
|
||||
_LOG_RECORD_FACTORY_INSTALLED = True
|
||||
|
||||
|
||||
def setup_logger():
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from config.config import LOG_DIR, LOG_MAX_BYTES, LOG_BACKUP_COUNT
|
||||
|
||||
install_log_record_factory()
|
||||
logger = logging.getLogger("cs_agent")
|
||||
if getattr(logger, "_cs_logger_configured", False):
|
||||
return logger
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
|
||||
fmt = logging.Formatter("[v%(app_version)s][%(asctime)s] %(message)s", datefmt="%H:%M:%S")
|
||||
use_color = (os.getenv("LOG_COLOR", "1").lower() in ("1", "true", "yes")) and not bool(os.getenv("NO_COLOR"))
|
||||
|
||||
ch = logging.StreamHandler()
|
||||
ch.setFormatter(_AnsiColorFormatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S", use_color=use_color))
|
||||
ch.setFormatter(_AnsiColorFormatter("[v%(app_version)s][%(asctime)s] %(message)s", datefmt="%H:%M:%S", use_color=use_color))
|
||||
logger.addHandler(ch)
|
||||
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
@@ -1,13 +1,54 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import websockets
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
_OUTBOUND_BLOCK_MARKERS = (
|
||||
"【历史记录摘要】",
|
||||
"【详细记录】",
|
||||
"【订单摘要】",
|
||||
"【订单详情】",
|
||||
"<think",
|
||||
"think_never_used",
|
||||
'[{"name":',
|
||||
)
|
||||
|
||||
_HISTORY_LEAK_PATTERNS = [
|
||||
r'\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*(客户|客服)[::]',
|
||||
r'\[\d{2}:\d{2}:\d{2}\]\s*(客户|客服|我)[::]',
|
||||
r'(根据|查看|查询|翻看)(历史|聊天|对话)(记录|内容)',
|
||||
r'历史(记录|对话|消息)(显示|表明|中)',
|
||||
r'之前的(聊天|对话|记录)(中|里|显示)',
|
||||
r'共\d+条(历史|对话)?消息',
|
||||
r'订单号[::]\s*\d{10,}',
|
||||
r'(状态|金额|数量)[::].*(状态|金额|数量)[::]',
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_outbound_text(content: str) -> str:
|
||||
if not content:
|
||||
return ""
|
||||
cleaned = str(content).strip()
|
||||
if "[转移会话]" in cleaned:
|
||||
return cleaned
|
||||
if any(marker in cleaned for marker in _OUTBOUND_BLOCK_MARKERS):
|
||||
logger.warning("[WebSocketSend] 拦截到内部内容外发,替换为安全兜底回复")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
for pattern in _HISTORY_LEAK_PATTERNS:
|
||||
if re.search(pattern, cleaned):
|
||||
logger.warning(f"[WebSocketSend] 检测到历史记录泄露模式: {pattern[:30]}...")
|
||||
return "我在帮你看记录,稍等哈"
|
||||
return cleaned
|
||||
|
||||
|
||||
async def send_text_flow(client, cy_id, acc_type, content):
|
||||
"""主动发送文本消息。"""
|
||||
message = {
|
||||
"msg_id": "",
|
||||
"acc_id": "",
|
||||
"msg": content,
|
||||
"msg": _sanitize_outbound_text(content),
|
||||
"from_id": client.reply_id,
|
||||
"from_name": client.reply_id,
|
||||
"cy_id": cy_id,
|
||||
@@ -38,34 +79,38 @@ async def send_message_flow(client, message):
|
||||
"""发送消息到服务器。"""
|
||||
if client.websocket and client.websocket.state == websockets.protocol.State.OPEN:
|
||||
try:
|
||||
msg_json = json.dumps(message, ensure_ascii=False)
|
||||
payload = dict(message) if isinstance(message, dict) else {}
|
||||
if int(payload.get("msg_type", 0) or 0) == 0:
|
||||
payload["msg"] = _sanitize_outbound_text(payload.get("msg", ""))
|
||||
msg_json = json.dumps(payload, ensure_ascii=False)
|
||||
await client.websocket.send(msg_json)
|
||||
pretty = json.dumps(message, ensure_ascii=False, indent=2)
|
||||
pretty = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
client.logger.info(f"[{client.get_time()}] 发送成功:\n{pretty}")
|
||||
data = message.get("data", {}) if isinstance(message, dict) else {}
|
||||
client._activity_log(
|
||||
"send_message_success",
|
||||
trace_id=message.get("_trace_id", "") if isinstance(message, dict) else "",
|
||||
acc_id=data.get("acc_id", ""),
|
||||
customer_id=data.get("cy_id", ""),
|
||||
msg_type=data.get("msg_type", 0),
|
||||
msg=data.get("msg", ""),
|
||||
trace_id=payload.get("_trace_id", ""),
|
||||
acc_id=payload.get("acc_id", ""),
|
||||
customer_id=payload.get("cy_id") or payload.get("from_id", ""),
|
||||
msg_type=payload.get("msg_type", 0),
|
||||
msg=payload.get("msg", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
client.logger.info(f"[{client.get_time()}] 发送失败: {e}")
|
||||
payload = message if isinstance(message, dict) else {}
|
||||
client._activity_log(
|
||||
"send_message_error",
|
||||
trace_id=message.get("_trace_id", ""),
|
||||
acc_id=message.get("acc_id", ""),
|
||||
customer_id=message.get("from_id", ""),
|
||||
trace_id=payload.get("_trace_id", ""),
|
||||
acc_id=payload.get("acc_id", ""),
|
||||
customer_id=payload.get("cy_id") or payload.get("from_id", ""),
|
||||
error=str(e),
|
||||
)
|
||||
else:
|
||||
client.logger.info(f"[{client.get_time()}] 错误: 连接未打开")
|
||||
payload = message if isinstance(message, dict) else {}
|
||||
client._activity_log(
|
||||
"send_message_skipped",
|
||||
trace_id=message.get("_trace_id", ""),
|
||||
trace_id=payload.get("_trace_id", ""),
|
||||
reason="socket_not_open",
|
||||
acc_id=message.get("acc_id", ""),
|
||||
customer_id=message.get("from_id", ""),
|
||||
acc_id=payload.get("acc_id", ""),
|
||||
customer_id=payload.get("cy_id") or payload.get("from_id", ""),
|
||||
)
|
||||
|
||||
19
core/提示词.MD
19
core/提示词.MD
@@ -1,19 +0,0 @@
|
||||
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话高端、专业。\n\n"
|
||||
|
||||
"【统一称呼规范】\n"
|
||||
"1. 严禁使用'师傅'、'客服'、'专员'等词汇!\n"
|
||||
"2. 必须统一称呼为【设计师】。比如:'找设计师看下'、'设计师马上来'、'等设计师核价'。\n\n"
|
||||
|
||||
"【核心逻辑】\n"
|
||||
"1. 业务:只聊高清修复和找原图。引导发图 -> 问需求 -> 找设计师。\n"
|
||||
"2. 下线安抚:如果工具返回 'ERROR_NO_DESIGNER_ONLINE',说明设计师们【下班/下线】了。回:'亲亲,设计师现在下班啦,需求我先记下,明天第一时间回您哈!'。\n"
|
||||
"3. 正在转接中:如果看到系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'。\n\n"
|
||||
"4. 没转接时:引导发图 -> 问需求 -> 调工具转人工。\n\n
|
||||
"5. 语气:淘宝亲切风,多用'亲亲'、'铁子'。每句回复【严禁超过15字】!\n\n"
|
||||
|
||||
"【必杀令】\n"
|
||||
"1. 每句回复严禁超过15个字!\n"
|
||||
"2. 严禁报价,严禁复读图片已收到的情况。\n"
|
||||
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n\n"
|
||||
|
||||
f"业务参考:\n{all_skills}"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
聊天记录数据库(SQLite)
|
||||
聊天记录数据库(SQLite / MySQL)
|
||||
每条消息独立存储,按客户ID分开,支持查询和展示。
|
||||
支持 MySQL 连接池以提高性能。
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
import threading
|
||||
from queue import Queue, Empty
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
_DB_PATH = os.path.join(os.path.dirname(__file__), "chat_log_db", "chats.db")
|
||||
@@ -16,6 +19,93 @@ _MYSQL_USER = os.getenv("MYSQL_USER", "root")
|
||||
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
|
||||
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
|
||||
|
||||
# ========== MySQL 连接池 ==========
|
||||
_POOL_SIZE = int(os.getenv("MYSQL_POOL_SIZE", "10"))
|
||||
_POOL_WAIT_TIMEOUT = float(os.getenv("MYSQL_POOL_WAIT_TIMEOUT", "10"))
|
||||
_mysql_pool: Optional[Queue] = None
|
||||
_pool_lock = threading.Lock()
|
||||
_mysql_conn_count = 0
|
||||
|
||||
|
||||
def _create_mysql_conn():
|
||||
"""创建单个 MySQL 连接"""
|
||||
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,
|
||||
connect_timeout=10,
|
||||
read_timeout=30,
|
||||
write_timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _init_mysql_pool():
|
||||
"""初始化 MySQL 连接池(懒创建,不在启动时预建满)"""
|
||||
global _mysql_pool
|
||||
with _pool_lock:
|
||||
if _mysql_pool is None:
|
||||
_mysql_pool = Queue(maxsize=_POOL_SIZE)
|
||||
|
||||
|
||||
def _discard_conn(conn):
|
||||
"""丢弃失效连接并维护计数"""
|
||||
global _mysql_conn_count
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
with _pool_lock:
|
||||
if _mysql_conn_count > 0:
|
||||
_mysql_conn_count -= 1
|
||||
|
||||
|
||||
def _get_pooled_conn(timeout: float = 5.0):
|
||||
"""从连接池获取连接,达到上限后阻塞等待,不再额外扩容。"""
|
||||
global _mysql_pool, _mysql_conn_count
|
||||
if _mysql_pool is None:
|
||||
_init_mysql_pool()
|
||||
|
||||
with _pool_lock:
|
||||
if _mysql_conn_count < _POOL_SIZE:
|
||||
conn = _create_mysql_conn()
|
||||
_mysql_conn_count += 1
|
||||
return conn
|
||||
|
||||
try:
|
||||
conn = _mysql_pool.get(timeout=timeout)
|
||||
try:
|
||||
conn.ping(reconnect=True)
|
||||
except Exception:
|
||||
_discard_conn(conn)
|
||||
with _pool_lock:
|
||||
if _mysql_conn_count < _POOL_SIZE:
|
||||
conn = _create_mysql_conn()
|
||||
_mysql_conn_count += 1
|
||||
return conn
|
||||
conn = _mysql_pool.get(timeout=timeout)
|
||||
conn.ping(reconnect=True)
|
||||
return conn
|
||||
except Empty:
|
||||
raise TimeoutError(f"MySQL连接池已耗尽(pool_size={_POOL_SIZE}, wait_timeout={timeout}s)")
|
||||
|
||||
|
||||
def _return_conn(conn):
|
||||
"""归还连接到池,失效连接直接丢弃。"""
|
||||
global _mysql_pool
|
||||
if _mysql_pool is None:
|
||||
return
|
||||
try:
|
||||
conn.ping(reconnect=False)
|
||||
_mysql_pool.put_nowait(conn)
|
||||
except Exception:
|
||||
_discard_conn(conn)
|
||||
|
||||
|
||||
class _CompatResult:
|
||||
def __init__(self, rows=None, rowcount: int = 0, lastrowid: int = 0):
|
||||
@@ -31,10 +121,11 @@ class _CompatResult:
|
||||
|
||||
|
||||
class _PyMySQLCompatConn:
|
||||
"""让 pymysql 连接兼容 sqlite 的 conn.execute 用法。"""
|
||||
"""让 pymysql 连接兼容 sqlite 的 conn.execute 用法,支持连接池。"""
|
||||
|
||||
def __init__(self, conn):
|
||||
def __init__(self, conn, use_pool: bool = True):
|
||||
self._conn = conn
|
||||
self._use_pool = use_pool
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
@@ -45,6 +136,10 @@ class _PyMySQLCompatConn:
|
||||
self._conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
# 归还连接到池而不是关闭
|
||||
if self._use_pool:
|
||||
_return_conn(self._conn)
|
||||
else:
|
||||
self._conn.close()
|
||||
|
||||
def execute(self, query: str, args=None):
|
||||
@@ -59,6 +154,9 @@ class _PyMySQLCompatConn:
|
||||
self._conn.commit()
|
||||
|
||||
def close(self):
|
||||
if self._use_pool:
|
||||
_return_conn(self._conn)
|
||||
else:
|
||||
self._conn.close()
|
||||
|
||||
def _is_mysql() -> bool:
|
||||
@@ -68,20 +166,22 @@ def _sql(query: str) -> str:
|
||||
return query.replace("?", "%s") if _is_mysql() else query
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
def _get_conn(max_retries: int = 3, retry_delay: float = 0.5) -> sqlite3.Connection:
|
||||
"""获取数据库连接,MySQL 使用连接池"""
|
||||
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)
|
||||
import time
|
||||
last_error = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
conn = _get_pooled_conn(timeout=_POOL_WAIT_TIMEOUT)
|
||||
return _PyMySQLCompatConn(conn, use_pool=True)
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(retry_delay * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
raise last_error
|
||||
os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -143,14 +243,87 @@ def init_db():
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_acc ON chat_logs(acc_id)")
|
||||
|
||||
# ---- customer_orders 表 ----
|
||||
if _is_mysql():
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS customer_orders (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
customer_id VARCHAR(128) NOT NULL,
|
||||
acc_id VARCHAR(128) DEFAULT '',
|
||||
order_id VARCHAR(64) NOT NULL,
|
||||
order_status VARCHAR(64) DEFAULT '',
|
||||
product_title VARCHAR(512) DEFAULT '',
|
||||
amount DECIMAL(10,2) DEFAULT 0,
|
||||
quantity INTEGER DEFAULT 0,
|
||||
buyer_note TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
idx_rows2 = conn.execute("SHOW INDEX FROM customer_orders").fetchall()
|
||||
exists2 = {str(r.get("Key_name", "")) for r in idx_rows2}
|
||||
if "idx_co_customer" not in exists2:
|
||||
conn.execute("CREATE INDEX idx_co_customer ON customer_orders(customer_id)")
|
||||
if "idx_co_order" not in exists2:
|
||||
conn.execute("CREATE UNIQUE INDEX idx_co_order ON customer_orders(order_id, order_status)")
|
||||
else:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS customer_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id TEXT NOT NULL,
|
||||
acc_id TEXT DEFAULT '',
|
||||
order_id TEXT NOT NULL,
|
||||
order_status TEXT DEFAULT '',
|
||||
product_title TEXT DEFAULT '',
|
||||
amount REAL DEFAULT 0,
|
||||
quantity INTEGER DEFAULT 0,
|
||||
buyer_note TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_co_customer ON customer_orders(customer_id)")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_co_order ON customer_orders(order_id, order_status)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
init_db()
|
||||
|
||||
|
||||
# ========== 重试装饰器 ==========
|
||||
|
||||
def _retry_db_operation(func):
|
||||
"""数据库操作重试装饰器,处理连接丢失等临时错误"""
|
||||
import functools
|
||||
import time
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
max_retries = 3
|
||||
last_error = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
err_str = str(e).lower()
|
||||
# 判断是否为可重试的连接错误
|
||||
is_conn_error = any(k in err_str for k in [
|
||||
"lost connection", "gone away", "connection reset",
|
||||
"can't connect", "connection refused", "2013", "2006"
|
||||
])
|
||||
if is_conn_error and attempt < max_retries - 1:
|
||||
last_error = e
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
raise last_error
|
||||
return wrapper
|
||||
|
||||
|
||||
# ========== 写入 ==========
|
||||
|
||||
@_retry_db_operation
|
||||
def log_message(
|
||||
customer_id: str,
|
||||
message: str,
|
||||
@@ -208,13 +381,14 @@ def get_customers(limit: int = 100) -> List[Dict]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@_retry_db_operation
|
||||
def get_conversation(customer_id: str, limit: int = 200, acc_id: str = "") -> List[Dict]:
|
||||
"""返回某客户的最近对话记录(按时间升序)"""
|
||||
# 忽略 acc_id 过滤,实现全店铺记忆
|
||||
with _get_conn() as conn:
|
||||
rows = conn.execute(_sql("""
|
||||
SELECT * FROM (
|
||||
SELECT id, direction, message, msg_type, timestamp, acc_id
|
||||
SELECT id, direction, message, msg_type, timestamp, acc_id, image_urls
|
||||
FROM chat_logs
|
||||
WHERE customer_id = ?
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
@@ -351,3 +525,108 @@ def get_latest_messages(limit: int = 20) -> List[Dict]:
|
||||
ORDER BY id DESC LIMIT ?
|
||||
"""), (limit,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_waiting_customer_pool(window_minutes: int = 30) -> Dict:
|
||||
"""统计最近窗口内、最后一条消息仍来自客户的待接待客户池。"""
|
||||
cutoff = (datetime.now() - timedelta(minutes=max(window_minutes, 1))).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with _get_conn() as conn:
|
||||
rows = conn.execute(_sql("""
|
||||
SELECT id, customer_id, acc_id, direction, timestamp
|
||||
FROM chat_logs
|
||||
WHERE timestamp >= ?
|
||||
AND customer_id <> ''
|
||||
AND customer_id <> 'unknown'
|
||||
AND acc_id <> ''
|
||||
ORDER BY id DESC
|
||||
"""), (cutoff,)).fetchall()
|
||||
|
||||
latest_by_session = {}
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
key = (str(item.get("customer_id") or ""), str(item.get("acc_id") or ""))
|
||||
if key not in latest_by_session:
|
||||
latest_by_session[key] = item
|
||||
|
||||
per_shop: Dict[str, int] = {}
|
||||
waiting_sessions = 0
|
||||
for item in latest_by_session.values():
|
||||
if str(item.get("direction") or "") != "in":
|
||||
continue
|
||||
acc_id = str(item.get("acc_id") or "")
|
||||
if not acc_id:
|
||||
continue
|
||||
per_shop[acc_id] = per_shop.get(acc_id, 0) + 1
|
||||
waiting_sessions += 1
|
||||
|
||||
shops = [
|
||||
{"acc_id": acc_id, "waiting_customers": count}
|
||||
for acc_id, count in sorted(per_shop.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
]
|
||||
return {
|
||||
"total_waiting_customers": waiting_sessions,
|
||||
"shops": shops,
|
||||
"window_minutes": window_minutes,
|
||||
}
|
||||
|
||||
|
||||
# ========== 订单相关 ==========
|
||||
|
||||
@_retry_db_operation
|
||||
def upsert_order(
|
||||
customer_id: str,
|
||||
order_id: str,
|
||||
order_status: str = "",
|
||||
acc_id: str = "",
|
||||
product_title: str = "",
|
||||
amount: float = 0.0,
|
||||
quantity: int = 0,
|
||||
buyer_note: str = "",
|
||||
):
|
||||
"""写入或更新一条订单记录(按 order_id + order_status 去重)"""
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with _get_conn() as conn:
|
||||
if _is_mysql():
|
||||
conn.execute(
|
||||
"INSERT INTO customer_orders "
|
||||
"(customer_id, acc_id, order_id, order_status, product_title, amount, quantity, buyer_note, created_at, updated_at) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
|
||||
"ON DUPLICATE KEY UPDATE customer_id=VALUES(customer_id), acc_id=VALUES(acc_id), "
|
||||
"product_title=VALUES(product_title), amount=VALUES(amount), quantity=VALUES(quantity), "
|
||||
"buyer_note=VALUES(buyer_note), updated_at=VALUES(updated_at)",
|
||||
(customer_id, acc_id, order_id, order_status, product_title, amount, quantity, buyer_note, ts, ts),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
_sql("INSERT OR REPLACE INTO customer_orders "
|
||||
"(customer_id, acc_id, order_id, order_status, product_title, amount, quantity, buyer_note, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?)"),
|
||||
(customer_id, acc_id, order_id, order_status, product_title, amount, quantity, buyer_note, ts, ts),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@_retry_db_operation
|
||||
def get_customer_orders(customer_id: str, limit: int = 10) -> List[Dict]:
|
||||
"""查询某客户的订单记录(按时间倒序)"""
|
||||
with _get_conn() as conn:
|
||||
rows = conn.execute(_sql("""
|
||||
SELECT order_id, order_status, product_title, amount, quantity, buyer_note, created_at, updated_at
|
||||
FROM customer_orders
|
||||
WHERE customer_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
"""), (customer_id, limit)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_order_by_id(order_id: str) -> List[Dict]:
|
||||
"""按订单号查询所有状态变更记录"""
|
||||
with _get_conn() as conn:
|
||||
rows = conn.execute(_sql("""
|
||||
SELECT customer_id, order_id, order_status, product_title, amount, quantity, buyer_note, created_at, updated_at
|
||||
FROM customer_orders
|
||||
WHERE order_id = ?
|
||||
ORDER BY updated_at ASC
|
||||
"""), (order_id,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
Binary file not shown.
@@ -13,6 +13,9 @@ _MYSQL_USER = os.getenv("MYSQL_USER", "root")
|
||||
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
|
||||
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
|
||||
|
||||
# 复用 chat_log_db 的连接池
|
||||
from db.chat_log_db import _get_pooled_conn, _return_conn
|
||||
|
||||
|
||||
def _is_mysql() -> bool:
|
||||
return _DB_TYPE in ("mysql", "mariadb")
|
||||
@@ -171,6 +174,23 @@ class CustomerProfile:
|
||||
self.image_analysis_history = []
|
||||
|
||||
|
||||
class _PooledMySQLConn:
|
||||
"""包装 pymysql 连接,支持连接池归还"""
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
def __enter__(self):
|
||||
return self._conn
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if exc_type:
|
||||
try:
|
||||
self._conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
_return_conn(self._conn)
|
||||
|
||||
|
||||
class CustomerDatabase:
|
||||
"""客户数据库"""
|
||||
|
||||
@@ -180,17 +200,8 @@ class CustomerDatabase:
|
||||
self._ensure_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,
|
||||
)
|
||||
"""从连接池获取 MySQL 连接"""
|
||||
return _PooledMySQLConn(_get_pooled_conn(timeout=5.0))
|
||||
|
||||
def _ensure_db(self):
|
||||
if _is_mysql():
|
||||
@@ -284,9 +295,14 @@ class CustomerDatabase:
|
||||
data.pop('customer_id', None)
|
||||
return CustomerProfile(customer_id=customer_id, **data)
|
||||
|
||||
def save_customer(self, profile: CustomerProfile):
|
||||
def save_customer(self, profile: CustomerProfile, max_retries: int = 3):
|
||||
"""保存客户画像(带重试机制)"""
|
||||
import time
|
||||
profile.last_update = datetime.now().isoformat()
|
||||
if _is_mysql():
|
||||
last_error = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
with self._get_mysql_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -302,6 +318,18 @@ class CustomerDatabase:
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
err_str = str(e).lower()
|
||||
is_conn_error = any(k in err_str for k in [
|
||||
"lost connection", "gone away", "connection reset",
|
||||
"can't connect", "connection refused", "2013", "2006"
|
||||
])
|
||||
if is_conn_error and attempt < max_retries - 1:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
raise last_error
|
||||
customers = self._load_customers()
|
||||
customers[profile.customer_id] = asdict(profile)
|
||||
self._save_customers(customers)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
174
db/pending_transfer_db.py
Normal file
174
db/pending_transfer_db.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
待转接队列(本地 SQLite)
|
||||
用于在设计师不在线时暂存转接请求,待设计师上线后自动转接。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict
|
||||
|
||||
_DB_PATH = os.path.join(os.path.dirname(__file__), "pending_transfer.db")
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pending_transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id TEXT NOT NULL,
|
||||
acc_id TEXT DEFAULT '',
|
||||
acc_type TEXT DEFAULT '',
|
||||
platform TEXT DEFAULT 'qianniu',
|
||||
reason TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_retry_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
completed_at TEXT DEFAULT '',
|
||||
last_error TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pending_status_retry ON pending_transfers(status, next_retry_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pending_customer_acc ON pending_transfers(customer_id, acc_id)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
init_db()
|
||||
|
||||
|
||||
def enqueue_pending_transfer(
|
||||
customer_id: str,
|
||||
acc_id: str,
|
||||
acc_type: str,
|
||||
platform: str,
|
||||
reason: str,
|
||||
) -> int:
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with _get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM pending_transfers
|
||||
WHERE customer_id = ? AND acc_id = ? AND status IN ('pending', 'processing')
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(customer_id, acc_id),
|
||||
).fetchone()
|
||||
|
||||
if row:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE pending_transfers
|
||||
SET acc_type = ?, platform = ?, reason = ?, status = 'pending',
|
||||
next_retry_at = ?, updated_at = ?, last_error = ''
|
||||
WHERE id = ?
|
||||
""",
|
||||
(acc_type, platform, reason, now, now, row["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return int(row["id"])
|
||||
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO pending_transfers
|
||||
(customer_id, acc_id, acc_type, platform, reason, status, retry_count, next_retry_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)
|
||||
""",
|
||||
(customer_id, acc_id, acc_type, platform, reason, now, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def claim_due_pending_transfers(limit: int = 10) -> List[Dict]:
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with _get_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM pending_transfers
|
||||
WHERE status = 'pending' AND next_retry_at <= ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(now, limit),
|
||||
).fetchall()
|
||||
|
||||
ids = [int(r["id"]) for r in rows]
|
||||
if ids:
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
conn.execute(
|
||||
f"""
|
||||
UPDATE pending_transfers
|
||||
SET status = 'processing', updated_at = ?
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
(now, *ids),
|
||||
)
|
||||
conn.commit()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def complete_pending_transfer(row_id: int):
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE pending_transfers
|
||||
SET status = 'completed', completed_at = ?, updated_at = ?, last_error = ''
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, now, row_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def retry_pending_transfer(row_id: int, delay_seconds: int = 60, error: str = ""):
|
||||
now = datetime.now()
|
||||
next_retry = now + timedelta(seconds=max(delay_seconds, 5))
|
||||
now_s = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
next_s = next_retry.strftime("%Y-%m-%d %H:%M:%S")
|
||||
with _get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE pending_transfers
|
||||
SET status = 'pending',
|
||||
retry_count = retry_count + 1,
|
||||
next_retry_at = ?,
|
||||
updated_at = ?,
|
||||
last_error = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(next_s, now_s, error[:500], row_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def count_open_pending_transfers() -> int:
|
||||
with _get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM pending_transfers
|
||||
WHERE status IN ('pending', 'processing')
|
||||
"""
|
||||
).fetchone()
|
||||
return int(row["cnt"] or 0) if row else 0
|
||||
@@ -10,6 +10,7 @@ from typing import Optional, Dict, List
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
import os
|
||||
from db.chat_log_db import _get_pooled_conn, _return_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
|
||||
@@ -45,6 +46,19 @@ class TaskPriority(Enum):
|
||||
HIGH = "high"
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class _PooledMySQLConn:
|
||||
"""包装 pymysql 连接,close 时归还到共享连接池。"""
|
||||
|
||||
def __init__(self, conn):
|
||||
self._conn = conn
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._conn, name)
|
||||
|
||||
def close(self):
|
||||
_return_conn(self._conn)
|
||||
|
||||
class TaskManager:
|
||||
"""任务管理器 - SQLite 存储"""
|
||||
|
||||
@@ -139,17 +153,7 @@ class TaskManager:
|
||||
def _get_conn(self):
|
||||
"""获取数据库连接"""
|
||||
if _is_mysql():
|
||||
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,
|
||||
)
|
||||
return _PooledMySQLConn(_get_pooled_conn())
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
Binary file not shown.
@@ -1,223 +0,0 @@
|
||||
[15:17:16] Agent 正在处理消息...
|
||||
[15:17:21] Agent 回复: 发图来我看看
|
||||
[15:17:56] Agent 正在处理消息...
|
||||
[15:19:02] Agent 回复: 30,拍下我安排
|
||||
[15:19:02] Agent 正在处理消息...
|
||||
[15:19:06] Agent 回复: 可以,30,拍下我安排
|
||||
[15:21:19] Agent 正在处理消息...
|
||||
[15:21:57] Agent 正在处理消息...
|
||||
[15:22:26] Agent 回复: 无需额外回复,流程已完成。
|
||||
[15:22:26] Agent 正在处理消息...
|
||||
[15:22:36] Agent 回复: 40元,拍下我安排
|
||||
[15:28:22] Agent 正在处理消息...
|
||||
[15:28:32] Agent 回复: 发图来我看看
|
||||
[15:34:13] Agent 正在处理消息...
|
||||
[15:34:16] Agent 回复: 发图来我看看
|
||||
[15:36:29] Agent 正在处理消息...
|
||||
[15:36:32] Agent 回复: 在呢
|
||||
[15:36:49] Agent 正在处理消息...
|
||||
[15:36:58] Agent 回复: 30,拍下发你
|
||||
[15:37:15] Agent 正在处理消息...
|
||||
[15:37:21] Agent 回复: 拍下发你,不好退
|
||||
[15:38:22] Agent 正在处理消息...
|
||||
[15:38:24] Agent 回复: 30,拍下发你
|
||||
[15:39:14] Agent 正在处理消息...
|
||||
[15:39:16] Agent 回复: 拍下发你,不好退
|
||||
[17:20:40] Agent 正在处理消息...
|
||||
[17:20:42] Agent 回复: 在呢
|
||||
[17:21:04] Agent 正在处理消息...
|
||||
[17:21:23] Agent 回复: 已经完成,不需要额外回复了。
|
||||
[17:21:23] Agent 正在处理消息...
|
||||
[17:21:26] Agent 回复: 拍下发你,不满意退款
|
||||
[17:21:42] Agent 正在处理消息...
|
||||
[17:21:44] Agent 回复: 30,拍下吧
|
||||
[17:26:08] Agent 正在处理消息...
|
||||
[17:26:12] Agent 回复: 在呢,啥事
|
||||
[17:26:17] Agent 正在处理消息...
|
||||
[17:26:23] Agent 回复: 发图来看看
|
||||
[17:26:41] Agent 正在处理消息...
|
||||
[17:26:58] Agent 回复: 25,拍下发你
|
||||
[17:38:48] Agent 正在处理消息...
|
||||
[17:38:53] Agent 回复: 在呢,发图我看看。
|
||||
[17:38:53] Agent 正在处理消息...
|
||||
[17:38:55] Agent 回复: 已收到您的订单,付款后我马上安排哈
|
||||
[17:38:55] Agent 正在处理消息...
|
||||
[17:38:58] Agent 回复: 图发过来我看看。
|
||||
[17:38:58] Agent 正在处理消息...
|
||||
[17:39:01] Agent 回复: 已经收到付款啦,把需要的图发我吧。
|
||||
[17:57:48] Agent 正在处理消息...
|
||||
[17:57:52] Agent 处理失败: name 'asyncio' is not defined
|
||||
[17:59:06] Agent 正在处理消息...
|
||||
[17:59:09] Agent 回复: 在呢,啥事
|
||||
[17:59:23] Agent 正在处理消息...
|
||||
[17:59:31] Agent 回复: 发图来看看
|
||||
[17:59:31] Agent 正在处理消息...
|
||||
[17:59:35] Agent 回复: 发图来看看
|
||||
[18:01:52] Agent 正在处理消息...
|
||||
[18:02:05] Agent 回复含无效内容,已拦截: 已经完成回复,不需要额外操作。
|
||||
[18:05:02] Agent 正在处理消息...
|
||||
[18:05:05] Agent 回复: 在呢
|
||||
[18:05:20] Agent 正在处理消息...
|
||||
[18:05:37] Agent 回复含无效内容,已拦截: 不需要额外回复,已完成报价和记录。
|
||||
[18:08:56] Agent 正在处理消息...
|
||||
[18:09:02] Agent 回复: 发图来我看看
|
||||
[18:09:02] Agent 正在处理消息...
|
||||
[18:09:05] Agent 回复: 已收到您的订单,付款后我马上安排哈
|
||||
[18:09:52] Agent 正在处理消息...
|
||||
[18:09:57] Agent 回复: 发图来看看
|
||||
[18:09:57] Agent 正在处理消息...
|
||||
[18:10:02] Agent 回复: 已收到,付款后我马上安排哈
|
||||
[18:10:47] Agent 正在处理消息...
|
||||
[18:10:49] Agent 回复: 发图过来我看看
|
||||
[18:10:50] Agent 正在处理消息...
|
||||
[18:10:53] Agent 回复: 已收到您的订单,付款后我马上安排哈
|
||||
[18:10:55] Agent 正在处理消息...
|
||||
[18:10:58] Agent 回复: 把图发我看看哦
|
||||
[18:10:58] Agent 正在处理消息...
|
||||
[18:11:01] Agent 回复: 收到付款,我马上安排。
|
||||
[18:18:20] Agent 正在处理消息...
|
||||
[18:18:23] Agent 回复: 在呢
|
||||
[18:20:11] Agent 正在处理消息...
|
||||
[18:20:14] Agent 回复: 在呢
|
||||
[18:20:48] Agent 正在处理消息...
|
||||
[18:21:17] Agent 回复: 30,拍下我安排
|
||||
[18:21:17] Agent 正在处理消息...
|
||||
[18:21:20] Agent 回复: 拍下发你,不满意退款
|
||||
[18:22:12] Agent 正在处理消息...
|
||||
[18:22:14] Agent 回复: 发图来我看看
|
||||
[18:22:15] Agent 正在处理消息...
|
||||
[18:22:15] Agent 决定不回复此消息
|
||||
[18:22:30] Agent 正在处理消息...
|
||||
[18:22:33] Agent 回复: 发图来我看看
|
||||
[18:22:33] Agent 正在处理消息...
|
||||
[18:22:35] Agent 回复: 收到款了,马上安排。
|
||||
[18:22:46] Agent 正在处理消息...
|
||||
[18:22:49] Agent 回复: 发图来看看
|
||||
[18:22:54] Agent 正在处理消息...
|
||||
[18:22:56] Agent 回复: 收到了,马上弄
|
||||
[18:30:27] Agent 正在处理消息...
|
||||
[18:30:29] Agent 回复: 在呢
|
||||
[18:31:40] Agent 正在处理消息...
|
||||
[18:31:57] Agent 回复: 30,拍下发你
|
||||
[18:31:57] Agent 正在处理消息...
|
||||
[18:32:00] Agent 回复: 拍下我安排
|
||||
[18:32:16] Agent 正在处理消息...
|
||||
[18:32:18] Agent 回复: 发图来我看看
|
||||
[18:32:19] Agent 正在处理消息...
|
||||
[18:32:19] Agent 决定不回复此消息
|
||||
[18:32:20] Agent 正在处理消息...
|
||||
[18:32:23] Agent 回复: 图发我看看
|
||||
[18:32:24] Agent 正在处理消息...
|
||||
[18:32:26] Agent 回复: 收到了,马上安排
|
||||
[18:35:54] Agent 正在处理消息...
|
||||
[18:35:56] Agent 回复: 在呢
|
||||
[18:39:30] Agent 正在处理消息...
|
||||
[18:39:33] Agent 回复: 在呢
|
||||
[18:39:45] Agent 正在处理消息...
|
||||
[18:40:00] Agent 回复含无效内容,已拦截: 已经完成,无需额外回复。 ← 命中pattern: ['无需', '无需额外', '已经完成']
|
||||
[18:40:18] Agent 正在处理消息...
|
||||
[18:40:24] Agent 回复: 发图来看看
|
||||
[18:40:24] Agent 正在处理消息...
|
||||
[18:40:24] Agent 决定不回复此消息
|
||||
[18:40:24] Agent 正在处理消息...
|
||||
[18:40:26] Agent 回复: 发图来看看
|
||||
[18:40:26] Agent 正在处理消息...
|
||||
[18:40:29] Agent 回复: 收到了,马上发你
|
||||
[18:45:45] Agent 正在处理消息...
|
||||
[18:45:47] Agent 回复: 发图来我看看
|
||||
[18:46:02] Agent 正在处理消息...
|
||||
[18:46:04] Agent 回复: 图发我看看
|
||||
[18:46:14] Agent 正在处理消息...
|
||||
[18:46:14] Agent 决定不回复此消息
|
||||
[18:46:48] Agent 正在处理消息...
|
||||
[18:47:00] Agent 回复: 25,拍下发你
|
||||
[19:02:22] Agent 正在处理消息...
|
||||
[19:02:25] Agent 回复: 在呢
|
||||
[19:02:38] Agent 正在处理消息...
|
||||
[19:02:54] Agent 回复: 30,拍下发你
|
||||
[19:02:54] Agent 正在处理消息...
|
||||
[19:03:02] Agent 回复: 拍下我马上安排
|
||||
[19:03:02] Agent 正在处理消息...
|
||||
[19:03:06] Agent 回复: 拍下我安排,好了发你。
|
||||
[19:03:31] Agent 正在处理消息...
|
||||
[19:03:31] Agent 决定不回复此消息
|
||||
[19:03:37] Agent 正在处理消息...
|
||||
[19:03:39] Agent 回复: 收到,马上给你安排。
|
||||
[19:06:46] Agent 正在处理消息...
|
||||
[19:06:46] Agent 决定不回复此消息
|
||||
[19:07:13] Agent 正在处理消息...
|
||||
[19:07:13] Agent 决定不回复此消息
|
||||
[19:15:06] Agent 正在处理消息...
|
||||
[19:15:11] Agent 回复: 没什么,拍下我尽快发你哈。
|
||||
[19:15:26] Agent 正在处理消息...
|
||||
[19:15:26] Agent 决定不回复此消息
|
||||
[19:17:17] Agent 正在处理消息...
|
||||
[19:17:24] Agent 回复: 在呢
|
||||
[19:17:41] Agent 正在处理消息...
|
||||
[19:18:00] Agent 回复: 30,拍下我安排
|
||||
[19:18:00] Agent 正在处理消息...
|
||||
[19:18:10] Agent 回复: 拍下我马上发你
|
||||
[19:18:51] Agent 正在处理消息...
|
||||
[19:18:51] Agent 决定不回复此消息
|
||||
[19:19:41] Agent 正在处理消息...
|
||||
[19:19:41] Agent 决定不回复此消息
|
||||
[19:19:59] Agent 正在处理消息...
|
||||
[19:19:59] Agent 决定不回复此消息
|
||||
[19:21:44] Agent 正在处理消息...
|
||||
[19:21:48] Agent 回复: 在做了,快了
|
||||
[19:22:08] Agent 正在处理消息...
|
||||
[19:22:22] Agent 回复: 满意,拍下我尽快发你。
|
||||
[19:22:50] Agent 正在处理消息...
|
||||
[19:22:55] Agent 回复: 拍下吧,我马上安排。
|
||||
[19:29:37] Agent 正在处理消息...
|
||||
[19:29:44] Agent 回复: 在呢,发图来看看
|
||||
[19:30:30] Agent 正在处理消息...
|
||||
[19:30:30] Agent 决定不回复此消息
|
||||
[19:30:41] Agent 正在处理消息...
|
||||
[19:30:41] Agent 决定不回复此消息
|
||||
[19:31:37] Agent 正在处理消息...
|
||||
[19:31:37] Agent 决定不回复此消息
|
||||
[19:31:47] Agent 正在处理消息...
|
||||
[19:31:47] Agent 决定不回复此消息
|
||||
[19:32:07] Agent 正在处理消息...
|
||||
[19:32:25] Agent 回复: 25,拍下我安排
|
||||
[19:33:03] Agent 正在处理消息...
|
||||
[19:33:03] Agent 决定不回复此消息
|
||||
[19:33:07] Agent 正在处理消息...
|
||||
[19:33:10] Agent 回复: 收到了,马上安排
|
||||
[19:33:32] Agent 正在处理消息...
|
||||
[19:33:34] Agent 回复: 发图来我看看
|
||||
[19:33:40] Agent 正在处理消息...
|
||||
[19:33:40] [Workflow] AI 通知已发送: 图发你了,先看下效果,没问题把邮箱发我我给你发过来。
|
||||
[19:33:42] Agent 回复: 把你现在的图发我看看
|
||||
[19:33:48] Agent 正在处理消息...
|
||||
[22:02:37] Agent 正在处理消息...
|
||||
[22:02:55] Agent 回复: 17,拍下我安排
|
||||
[22:03:12] Agent 正在处理消息...
|
||||
[22:03:49] Agent 回复: 两张50,拍下
|
||||
[22:03:53] Agent 正在处理消息...
|
||||
[22:03:55] Agent 回复: 拍下后发你确认,不满意退款。
|
||||
[22:09:51] Agent 正在处理消息...
|
||||
[22:44:15] Agent 正在处理消息...
|
||||
[22:44:18] Agent 回复: 在呢
|
||||
[23:17:53] Agent 正在处理消息...
|
||||
[23:17:56] Agent 回复: 发图来我看看
|
||||
[23:18:21] Agent 正在处理消息...
|
||||
[23:19:00] Agent 回复含无效内容,已拦截: 不需要额外操作,等待客户回复即可。 ← 命中pattern: ['不需要额外']
|
||||
[23:19:18] Agent 正在处理消息...
|
||||
[23:19:53] Agent 回复: 20,拍下我安排
|
||||
[23:20:30] Agent 正在处理消息...
|
||||
[23:20:40] Agent 正在处理消息...
|
||||
[23:20:44] Agent 正在处理消息...
|
||||
[23:20:47] Agent 回复: 什么问题,你说清楚点
|
||||
[23:21:03] Agent 正在处理消息...
|
||||
[23:21:08] Agent 正在处理消息...
|
||||
[23:21:12] Agent 回复: 20,拍下我发你
|
||||
[23:21:43] Agent 正在处理消息...
|
||||
[23:21:49] Agent 回复: 15,真的最低了,拍下吧
|
||||
[00:16:18] Agent 正在处理消息...
|
||||
[00:16:43] Agent 回复含无效内容,已拦截: 已经完成报价和记录,无需额外回复。 ← 命中pattern: ['无需', '无需额外', '已经完成']
|
||||
[00:16:47] Agent 正在处理消息...
|
||||
[00:16:47] Agent 决定不回复此消息
|
||||
[00:18:45] Agent 正在处理消息...
|
||||
[00:18:45] Agent 决定不回复此消息
|
||||
@@ -1,131 +0,0 @@
|
||||
[09:51:23] Agent 正在处理消息...
|
||||
[09:51:29] Agent 回复: 基本都可以,你发图来看看。
|
||||
[09:51:38] Agent 正在处理消息...
|
||||
[09:51:46] Agent 回复: 你发图过来先看看。
|
||||
[09:52:01] Agent 正在处理消息...
|
||||
[10:41:14] Agent 正在处理消息...
|
||||
[10:41:20] Agent 回复: 在呢,发图吧,做多张有优惠。
|
||||
[10:44:58] Agent 正在处理消息...
|
||||
[10:45:02] Agent 回复: 店家呀,你要找图直接发图就好啦。
|
||||
[10:45:25] Agent 正在处理消息...
|
||||
[10:45:29] Agent 回复: 稍等,我查一下给你重发哈。
|
||||
[10:51:25] Agent 正在处理消息...
|
||||
[10:51:28] Agent 回复: 发图来我看看哈
|
||||
[10:51:34] Agent 正在处理消息...
|
||||
[10:52:11] Agent 回复: 25,拍下发你,人脸可能会有轻微变化,不满意可退。
|
||||
[10:53:49] Agent 正在处理消息...
|
||||
[10:53:56] Agent 回复: 收到啦,麻烦把要处理的图发我一下哈。
|
||||
[10:54:39] Agent 正在处理消息...
|
||||
[10:54:39] Agent 决定不回复此消息
|
||||
[10:54:47] Agent 正在处理消息...
|
||||
[10:54:50] Agent 回复: 是高清优化难免的,整体效果会好很多,拍下给你安排。
|
||||
[10:55:37] Agent 正在处理消息...
|
||||
[10:55:39] Agent 回复: 半小时内就能好,拍下安排哈。
|
||||
[10:55:48] Agent 正在处理消息...
|
||||
[10:55:55] Agent 回复: 给你便宜5块,20,拍下吧。
|
||||
[10:56:13] [Workflow] AI 通知已发送: 图发你预览了,看下效果,没问题把邮箱发我我给你发原图。
|
||||
[10:57:13] Agent 正在处理消息...
|
||||
[10:57:13] Agent 决定不回复此消息
|
||||
[10:58:06] Agent 正在处理消息...
|
||||
[10:58:10] Agent 回复: 这已经是最低啦,要做我就给你安排了。
|
||||
[11:00:20] Agent 正在处理消息...
|
||||
[11:00:50] Agent 回复: 25,拍下发你。
|
||||
[11:00:54] Agent 正在处理消息...
|
||||
[11:01:01] Agent 回复: 已安排,好了发你。
|
||||
[11:01:55] Agent 正在处理消息...
|
||||
[11:01:59] Agent 回复: 收到了,马上安排,好了发你。
|
||||
[11:02:11] [Workflow] AI 通知已发送: 图发好了,你看下效果,没问题把邮箱发我给你发过去。
|
||||
[11:52:54] Agent 正在处理消息...
|
||||
[11:53:00] Agent 回复: 得重新续费pro套餐了,直接拍店里对应的续费套餐就行。
|
||||
[13:06:48] Agent 正在处理消息...
|
||||
[13:06:52] Agent 回复: 快了,好了发你。
|
||||
[13:07:30] Agent 正在处理消息...
|
||||
[13:07:35] Agent 回复: 可以找,你发相关信息过来看看。
|
||||
[13:07:50] Agent 正在处理消息...
|
||||
[13:08:19] Agent 回复: 这张25,拍下发你,人脸会有轻微变化,介意的话可以考虑下哦。
|
||||
[13:08:19] Agent 正在处理消息...
|
||||
[13:08:23] Agent 回复: 都发过来吧,我一起看看给你算打包价。
|
||||
[13:08:52] Agent 正在处理消息...
|
||||
[13:09:18] Agent 回复: 这类不做哦。
|
||||
[13:09:40] Agent 正在处理消息...
|
||||
[13:09:44] Agent 回复: 放心拍,不满意包退。
|
||||
[13:10:05] Agent 正在处理消息...
|
||||
[13:10:08] Agent 回复: 那拍第一张就行,25,拍下发你,不满意退。
|
||||
[14:43:25] Agent 正在处理消息...
|
||||
[14:43:32] Agent 回复: 看到订单了,请问具体是遇到什么问题了呢?
|
||||
[14:44:00] Agent 正在处理消息...
|
||||
[14:44:33] Agent 回复: 15,拍下发你。
|
||||
[14:46:07] Agent 正在处理消息...
|
||||
[14:46:11] Agent 回复: pro权限到期了需要重新续费哦,当前pro月套餐是50元,直接拍下续费就可以继续使用了。
|
||||
[14:56:57] Agent 正在处理消息...
|
||||
[14:57:39] Agent 回复: 25,拍下发你。
|
||||
[14:57:39] Agent 正在处理消息...
|
||||
[14:57:51] Agent 回复: 放心拍,拍下就发,不满意退。
|
||||
[15:34:46] Agent 正在处理消息...
|
||||
[15:35:07] Agent 回复: 25,拍下发你。
|
||||
[17:28:45] Agent 正在处理消息...
|
||||
[17:28:51] Agent 回复: 1块做不了哦,最低10块,要做发图看看。
|
||||
[18:02:42] Agent 正在处理消息...
|
||||
[18:02:42] Agent 决定不回复此消息
|
||||
[18:02:48] Agent 正在处理消息...
|
||||
[18:02:53] Agent 回复: 麻烦把需要处理的图片发我一下哈。
|
||||
[18:03:32] Agent 正在处理消息...
|
||||
[18:03:39] Agent 正在处理消息...
|
||||
[18:03:42] Agent 回复: 快了,马上发你哈。
|
||||
[18:39:55] Agent 正在处理消息...
|
||||
[18:39:55] Agent 决定不回复此消息
|
||||
[18:41:08] Agent 正在处理消息...
|
||||
[18:41:15] Agent 回复: 你把需要的图片发我一下哈。
|
||||
[18:41:24] Agent 正在处理消息...
|
||||
[18:41:29] Agent 回复: 稍等我看看哈。
|
||||
[18:49:34] Agent 正在处理消息...
|
||||
[18:49:41] Agent 回复: 没事,客人要了直接拍就行,还是这个价。
|
||||
[18:49:46] Agent 正在处理消息...
|
||||
[18:50:12] Agent 回复: 这张25,拍下发你。
|
||||
[18:50:12] Agent 正在处理消息...
|
||||
[18:50:21] Agent 回复: 两张一共45,拍下一起发。
|
||||
[19:00:10] Agent 正在处理消息...
|
||||
[19:00:31] Agent 回复: 15,拍下吧
|
||||
[19:00:47] Agent 正在处理消息...
|
||||
[19:01:16] Agent 回复: 这张25,两张一共40,一起拍了吧
|
||||
[19:01:33] Agent 正在处理消息...
|
||||
[19:01:39] Agent 回复: 35,两块一起拍了吧
|
||||
[19:01:49] Agent 正在处理消息...
|
||||
[19:01:52] Agent 回复: 你拍下我改价,改好了付款就行。
|
||||
[19:02:04] Agent 正在处理消息...
|
||||
[19:02:04] Agent 决定不回复此消息
|
||||
[19:02:19] Agent 正在处理消息...
|
||||
[19:02:28] Agent 回复: 收到了,马上弄好发你。
|
||||
[19:03:50] [Workflow] AI 通知已发送: 图发你了,看下效果没问题把邮箱发我,我发你文件。
|
||||
[19:05:22] Agent 正在处理消息...
|
||||
[19:05:25] Agent 回复: 把图发过来吧,我这边好马上开始弄。
|
||||
[19:11:19] Agent 正在处理消息...
|
||||
[19:11:22] Agent 回复: 放心拍,弄好都给你处理干净,不满意退。
|
||||
[19:14:54] Agent 正在处理消息...
|
||||
[19:15:00] Workflow 回复: 好,你说一下哪里要改,或者发图告诉我
|
||||
[19:15:15] Agent 正在处理消息...
|
||||
[19:15:18] Agent 回复: 发图来我看看。
|
||||
[19:15:19] Agent 正在处理消息...
|
||||
[19:15:24] Agent 回复: 已经处理好了,预览图发你了,把邮箱给我,我发你原图文件。
|
||||
[19:15:31] Agent 正在处理消息...
|
||||
[19:15:34] Agent 回复: 你把邮箱地址发我就行,我发你原图文件哈。
|
||||
[19:15:50] Agent 正在处理消息...
|
||||
[19:16:20] Agent 回复: 这张25,拍下就行。
|
||||
[19:16:47] Agent 正在处理消息...
|
||||
[19:16:54] Agent 回复: 好的,尺寸改好了,拍下一起发。
|
||||
[19:17:03] Agent 正在处理消息...
|
||||
[19:17:07] Agent 回复: 知道了,会按这个要求帮你弄好,放心拍就行。
|
||||
[19:18:16] Agent 正在处理消息...
|
||||
[19:18:25] Agent 正在处理消息...
|
||||
[19:18:30] Agent 回复: 给你算40,这已经很优惠了,拍下吧。
|
||||
[19:19:31] Agent 正在处理消息...
|
||||
[19:19:31] Agent 决定不回复此消息
|
||||
[19:19:37] Agent 正在处理消息...
|
||||
[19:19:44] Agent 回复: 已经收到款了,我马上安排,弄好发给你。
|
||||
[19:19:53] Agent 正在处理消息...
|
||||
[19:19:56] Agent 回复: 放心哈,我正在处理,很快就能弄好发你。
|
||||
[19:21:56] [Workflow] AI 通知已发送: 已经处理好啦,你先看看效果,没问题把邮箱发我,我发你原图哦。
|
||||
[19:22:59] Agent 正在处理消息...
|
||||
[19:22:59] Agent 决定不回复此消息
|
||||
[20:19:01] Agent 正在处理消息...
|
||||
[20:19:06] Agent 回复: 没事,想要了直接拍下就行,不满意包退哈。
|
||||
@@ -1,136 +0,0 @@
|
||||
[10:22:10] Agent 正在处理消息...
|
||||
[10:22:36] Agent 回复: 25,拍下吧。
|
||||
[10:25:57] Agent 正在处理消息...
|
||||
[10:26:27] Agent 回复含无效内容,已拦截: 不需要额外回复,等待客户下一步即可。 ← 命中pattern: ['不需要额外']
|
||||
[10:26:27] Agent 正在处理消息...
|
||||
[10:26:54] Agent 回复含无效内容,已拦截: 不需要额外操作,等待客户回应即可。 ← 命中pattern: ['不需要额外']
|
||||
[10:30:21] Agent 正在处理消息...
|
||||
[10:30:25] Agent 回复: 放心拍,不满意退。
|
||||
[10:32:59] Agent 正在处理消息...
|
||||
[10:33:28] Agent 回复: 15,拍下发你。
|
||||
[10:33:28] Agent 正在处理消息...
|
||||
[10:33:32] Agent 回复: 15拍下,马上安排。
|
||||
[10:37:29] Agent 正在处理消息...
|
||||
[10:37:29] Agent 决定不回复此消息
|
||||
[10:37:34] Agent 正在处理消息...
|
||||
[10:37:39] Agent 回复: 收到啦,马上帮你弄,好了发你。
|
||||
[10:37:54] [Workflow] AI 通知已发送: 图已经发你啦,你看下效果,没问题把邮箱发我我发你原文件。
|
||||
[11:10:58] Agent 正在处理消息...
|
||||
[11:11:00] Agent 正在处理消息...
|
||||
[11:11:02] Agent 回复: 在呢
|
||||
[11:11:20] Agent 正在处理消息...
|
||||
[11:11:27] Agent 回复: 这个不接哦,我们只做图片相关的哈
|
||||
[11:11:40] Agent 回复: 15,拍下发你哈。
|
||||
[11:11:40] Agent 正在处理消息...
|
||||
[11:11:46] Agent 正在处理消息...
|
||||
[11:11:47] Agent 回复: 总共20,拍下弄好发你。
|
||||
[11:11:49] Agent 回复: 发图来看看
|
||||
[11:11:58] Agent 正在处理消息...
|
||||
[11:12:01] Agent 回复: 知道了,20搞定,拍下吧。
|
||||
[11:12:23] Agent 正在处理消息...
|
||||
[11:12:26] Agent 回复: 没问题,拍下安排。
|
||||
[11:12:47] Agent 正在处理消息...
|
||||
[11:12:55] Agent 回复: 行吧,10就10,拍下吧。
|
||||
[11:13:19] Agent 正在处理消息...
|
||||
[11:13:22] Agent 回复: 直接拍这个宝贝就行,拍下备注好要求。
|
||||
[11:13:50] Agent 正在处理消息...
|
||||
[11:14:19] Agent 回复: 已经处理好了,等待客户回应就行。
|
||||
[11:14:19] Agent 正在处理消息...
|
||||
[11:14:24] Agent 回复: 25,拍下就能安排了
|
||||
[11:14:37] Agent 正在处理消息...
|
||||
[11:14:42] Agent 回复: 出版信息我们找不到哦,我们只做图片高清处理,要做直接拍下。
|
||||
[11:14:49] Agent 正在处理消息...
|
||||
[11:14:53] Agent 回复: 高清图我们可以安排,拍下试试,不满意退。
|
||||
[11:15:04] Agent 正在处理消息...
|
||||
[11:15:13] Agent 正在处理消息...
|
||||
[11:15:13] Agent 决定不回复此消息
|
||||
[11:22:03] Agent 正在处理消息...
|
||||
[11:22:08] Agent 回复: 直接在这里下单就行,拍下我就弄。
|
||||
[11:23:01] Agent 正在处理消息...
|
||||
[11:23:01] Agent 决定不回复此消息
|
||||
[11:25:09] Agent 正在处理消息...
|
||||
[11:25:15] Agent 回复: 看图片情况呢,你发图我给你说具体价格。
|
||||
[11:26:05] Agent 正在处理消息...
|
||||
[12:32:22] Agent 正在处理消息...
|
||||
[12:32:25] Agent 回复: 发图来我看看
|
||||
[12:39:48] Agent 正在处理消息...
|
||||
[12:40:28] Agent 回复含无效内容,已拦截: 已经回复客户报价了,不需要额外操作。 ← 命中pattern: ['不需要额外']
|
||||
[12:40:28] Agent 正在处理消息...
|
||||
[12:41:01] Agent 回复含无效内容,已拦截: 已经处理完成,不需要额外操作。 ← 命中pattern: ['不需要额外']
|
||||
[12:41:01] Agent 正在处理消息...
|
||||
[12:41:07] Agent 回复: 拍下发你,不满意可退,还有其他图一起发的话有打包优惠。
|
||||
[12:41:20] Agent 正在处理消息...
|
||||
[12:41:23] Agent 回复: 发图来我看看。
|
||||
[12:44:10] Agent 正在处理消息...
|
||||
[12:44:49] Agent 回复含无效内容,已拦截: 已经给客户回复了报价,记录完成,不需要额外操作。 ← 命中pattern: ['不需要额外', '记录完成']
|
||||
[12:44:49] Agent 正在处理消息...
|
||||
[12:44:53] Agent 回复: 可以,拍下发你,不满意退。
|
||||
[12:46:55] Agent 正在处理消息...
|
||||
[12:46:59] Agent 回复: 放心拍,没问题,不满意退哈。
|
||||
[12:49:20] Agent 正在处理消息...
|
||||
[12:49:22] Agent 回复: 快了,好了发你哈
|
||||
[12:55:59] Agent 正在处理消息...
|
||||
[12:56:02] Agent 回复: 发图过来我先看看哈。
|
||||
[12:56:12] Agent 正在处理消息...
|
||||
[12:56:17] Agent 回复: 图发我看看哦,做好给你改到这个尺寸。
|
||||
[12:56:20] Agent 正在处理消息...
|
||||
[12:56:49] Agent 回复含无效内容,已拦截: 不需要额外回复,之前已经发送了报价给客户。 ← 命中pattern: ['不需要额外']
|
||||
[12:57:22] Agent 正在处理消息...
|
||||
[12:57:43] Agent 回复含无效内容,已拦截: 不需要额外回复,已经给出了报价和打包优惠的信息。 ← 命中pattern: ['不需要额外']
|
||||
[12:57:49] Agent 正在处理消息...
|
||||
[12:57:54] Agent 回复: 知道啦,拍下我就开始安排了。
|
||||
[12:58:22] Agent 正在处理消息...
|
||||
[12:58:26] Agent 回复: 直接拍下这个商品就行,今天就能弄好,你上来我发你。
|
||||
[13:01:02] Agent 正在处理消息...
|
||||
[13:01:05] Agent 回复: 稍等哈
|
||||
[13:44:03] Agent 正在处理消息...
|
||||
[13:44:29] Agent 正在处理消息...
|
||||
[13:44:31] Agent 回复: 在呢
|
||||
[13:44:39] Agent 回复: 已经帮你记录好报价啦,等待客户进一步回复即可。
|
||||
[13:44:39] Agent 正在处理消息...
|
||||
[13:44:43] Agent 回复: 还是这张对吧,25拍下就行。
|
||||
[13:47:49] Agent 正在处理消息...
|
||||
[13:47:54] Agent 回复: 稍等,我找一下马上发你
|
||||
[13:49:10] Agent 正在处理消息...
|
||||
[13:49:13] Agent 回复: 马上好
|
||||
[13:50:23] Agent 正在处理消息...
|
||||
[13:50:39] Agent 回复: 25,拍下发你。
|
||||
[13:50:39] Agent 正在处理消息...
|
||||
[13:50:59] Agent 回复: 两张打包50,拍下吧。
|
||||
[14:36:40] Agent 正在处理消息...
|
||||
[14:37:11] Agent 回复含无效内容,已拦截: 已经处理完成,无需额外回复。 ← 命中pattern: ['无需', '无需额外']
|
||||
[14:37:45] Agent 正在处理消息...
|
||||
[14:37:54] Agent 正在处理消息...
|
||||
[14:37:57] Agent 回复: 没问题,拍下安排。
|
||||
[15:19:46] Agent 正在处理消息...
|
||||
[15:20:14] Agent 回复含无效内容,已拦截: 已经完成回复,无需额外内容。 ← 命中pattern: ['无需', '无需额外', '已经完成']
|
||||
[15:20:15] Agent 正在处理消息...
|
||||
[15:20:17] Agent 回复: 好的,拍下安排哈。
|
||||
[15:27:19] Agent 正在处理消息...
|
||||
[15:27:46] Agent 回复: 25,拍下发你。
|
||||
[15:31:52] Agent 正在处理消息...
|
||||
[15:31:57] Agent 回复: 在呢,有需要发图看看哈。
|
||||
[15:32:07] Agent 正在处理消息...
|
||||
[15:32:39] Agent 回复: 这张25,拍下哈。
|
||||
[15:32:46] Agent 正在处理消息...
|
||||
[15:32:56] Agent 回复: 没问题,可以给到这个要求,拍下安排吧。
|
||||
[15:33:07] Agent 正在处理消息...
|
||||
[15:33:10] Agent 回复: 放心拍,发你高清版,不满意退哈。
|
||||
[15:33:36] Agent 正在处理消息...
|
||||
[15:33:40] Agent 回复: 都是拍下发哦,不满意直接退,放心哈。
|
||||
[15:34:24] Agent 正在处理消息...
|
||||
[15:34:28] Agent 回复: 拍下后不满意直接退哈,没风险的。
|
||||
[15:35:04] Agent 正在处理消息...
|
||||
[15:35:09] Agent 回复: 我们这边都是先拍下再安排哦,不满意直接退款,没损失的。
|
||||
[15:35:51] Agent 正在处理消息...
|
||||
[15:35:55] Agent 回复: 放心哈,达不到你要的效果直接退,绝对不扯皮。
|
||||
[15:36:47] Agent 正在处理消息...
|
||||
[15:36:51] Agent 回复: 那给你算20,拍下吧,不满意直接退。
|
||||
[15:44:23] Agent 正在处理消息...
|
||||
[15:44:26] Agent 回复: 好的,等你消息。
|
||||
[15:48:56] Agent 正在处理消息...
|
||||
[15:49:32] Agent 回复: 这张20,两张一起算35,拍下吧。
|
||||
[15:49:33] Agent 正在处理消息...
|
||||
[15:49:37] Agent 回复: 那再给你优惠5块,两张一共30就好,这个价格已经很划算了哦。
|
||||
[15:55:18] Agent 正在处理消息...
|
||||
[15:55:22] Agent 回复: 好的,你把QQ号码发给我,我加你哈。
|
||||
2
run.py
2
run.py
@@ -159,7 +159,7 @@ def run_tianwang_multi(num_workers: int, enable_agent: bool, host: str, port: in
|
||||
logger.info(f"AI Agent: {'已启用' if enable_agent else '未启用'}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
coordinator = Coordinator(num_workers=num_workers or 0, enable_agent=enable_agent)
|
||||
coordinator = Coordinator(num_workers=num_workers or 1, enable_agent=enable_agent)
|
||||
|
||||
def _signal_handler(signum, frame):
|
||||
logger.info("收到退出信号,正在停止多进程协调器...")
|
||||
|
||||
@@ -14,9 +14,13 @@ import hashlib
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.websocket_logger_setup import install_log_record_factory
|
||||
|
||||
install_log_record_factory()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(asctime)s] %(levelname)s: %(message)s'
|
||||
format='[v%(app_version)s][%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
74
scripts/test_alicdn_download.py
Normal file
74
scripts/test_alicdn_download.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
TEST_URL = "https://img.alicdn.com/imgextra/i1/O1CN01959PmC2MK7jvMhqXF_!!4611686018427385312-0-amp.jpg"
|
||||
OUTPUT_DIR = Path(__file__).resolve().parents[1] / "tmp_alicdn_download"
|
||||
|
||||
CONTENT_TYPE_TO_SUFFIX = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/avif": ".avif",
|
||||
}
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/133.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
"Referer": "https://www.taobao.com/",
|
||||
}
|
||||
|
||||
|
||||
async def download_once(client: httpx.AsyncClient, url: str):
|
||||
response = await client.get(url, headers=DEFAULT_HEADERS)
|
||||
print(f"HTTP {response.status_code}")
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
print(f"Content-Type: {content_type}")
|
||||
if response.status_code != 200:
|
||||
print(response.text[:300])
|
||||
response.raise_for_status()
|
||||
|
||||
suffix = CONTENT_TYPE_TO_SUFFIX.get(content_type, ".bin")
|
||||
output_path = OUTPUT_DIR / f"alicdn_test{suffix}"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(response.content)
|
||||
print(f"Saved to: {output_path}")
|
||||
print(f"Size: {output_path.stat().st_size} bytes")
|
||||
|
||||
|
||||
async def main():
|
||||
timeout = httpx.Timeout(60.0, connect=20.0)
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
last_error = None
|
||||
referers = [
|
||||
"https://www.taobao.com/",
|
||||
"https://item.taobao.com/",
|
||||
"https://detail.tmall.com/",
|
||||
]
|
||||
for idx, referer in enumerate(referers, 1):
|
||||
try:
|
||||
DEFAULT_HEADERS["Referer"] = referer
|
||||
print(f"Attempt {idx} with Referer={referer}")
|
||||
await download_once(client, TEST_URL)
|
||||
print("Download success")
|
||||
return
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
print(f"Attempt {idx} failed: {type(e).__name__}: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
raise RuntimeError(f"All attempts failed: {last_error}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
144
scripts/test_image_pipeline_e2e.py
Normal file
144
scripts/test_image_pipeline_e2e.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from services.service_gemini import GeminiExtractV2Service
|
||||
from services.service_tuhui_upload import upload_to_tuhui
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[1] / "tmp_e2e_pipeline"
|
||||
BASE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
TUHUI_WEB_BASE = os.getenv("TUHUI_WEB_BASE_URL", "https://aidg168.uk").rstrip("/")
|
||||
TUHUI_DIRECT_BASE = os.getenv("TUHUI_DIRECT_BASE_URL", "http://156.226.181.204:8002").rstrip("/")
|
||||
TUHUI_API_BASES = [f"{TUHUI_DIRECT_BASE}/api", f"{TUHUI_WEB_BASE}/api"]
|
||||
TUHUI_PHONE = os.getenv("TUHUI_PHONE", "17520145271")
|
||||
TUHUI_PASSWORD = os.getenv("TUHUI_PASSWORD", "zuowei1216")
|
||||
|
||||
|
||||
def build_test_input() -> Path:
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = BASE_DIR / f"input_{ts}.png"
|
||||
img = Image.new("RGBA", (768, 768), (228, 244, 249, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rounded_rectangle((40, 40, 728, 728), radius=48, fill=(33, 114, 147, 255))
|
||||
draw.ellipse((120, 120, 340, 340), fill=(96, 214, 255, 255))
|
||||
draw.rectangle((390, 160, 640, 460), fill=(11, 54, 72, 255))
|
||||
draw.text((110, 540), "TW Gemini E2E Test", fill=(255, 255, 255, 255))
|
||||
img.save(path)
|
||||
return path
|
||||
|
||||
|
||||
async def login_tuhui(client: httpx.AsyncClient) -> tuple[str, str]:
|
||||
last_error = None
|
||||
for api_base in TUHUI_API_BASES:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{api_base}/auth/login",
|
||||
json={"phone": TUHUI_PHONE, "password": TUHUI_PASSWORD},
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["access_token"], api_base
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
raise last_error or RuntimeError("图绘登录失败")
|
||||
|
||||
|
||||
async def create_order_and_pay(client: httpx.AsyncClient, api_base: str, token: str, work_id: int) -> dict:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
order_resp = await client.post(
|
||||
f"{api_base}/orders/create",
|
||||
headers=headers,
|
||||
json={"work_id": work_id, "payment_method": "balance"},
|
||||
timeout=30.0,
|
||||
)
|
||||
order_resp.raise_for_status()
|
||||
order = order_resp.json()
|
||||
|
||||
pay_resp = await client.post(
|
||||
f"{api_base}/orders/pay/{order['id']}",
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
pay_resp.raise_for_status()
|
||||
payment = pay_resp.json()
|
||||
return {"order": order, "payment": payment}
|
||||
|
||||
|
||||
async def download_work(client: httpx.AsyncClient, api_base: str, token: str, work_id: int) -> Path:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
resp = await client.get(
|
||||
f"{api_base}/works/{work_id}/download",
|
||||
headers=headers,
|
||||
timeout=60.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
filename = resp.headers.get("content-disposition", "").split("filename=")[-1].strip('"') or f"work_{work_id}.bin"
|
||||
dest = BASE_DIR / filename
|
||||
dest.write_bytes(resp.content)
|
||||
return dest
|
||||
|
||||
|
||||
async def main():
|
||||
print("== 构建测试图 ==")
|
||||
input_path = build_test_input()
|
||||
output_path = BASE_DIR / f"gemini_{input_path.stem}.png"
|
||||
print(f"输入图: {input_path}")
|
||||
|
||||
print("== Gemini 处理 ==")
|
||||
gemini = GeminiExtractV2Service()
|
||||
success, message, data = await gemini.extract_pattern(
|
||||
str(input_path),
|
||||
str(output_path),
|
||||
custom_prompt="根据原图生成一张更完整、更干净的科技风背景素材图,保持主体布局清晰。",
|
||||
aspect_ratio="1:1",
|
||||
)
|
||||
print({"success": success, "message": message, "data": data})
|
||||
if not success:
|
||||
raise RuntimeError(f"Gemini 处理失败: {message}")
|
||||
|
||||
print("== 上传图绘 ==")
|
||||
title = f"E2E测试图_{datetime.now().strftime('%m%d_%H%M%S')}"
|
||||
upload_result = await upload_to_tuhui(
|
||||
image_path=str(output_path),
|
||||
title=title,
|
||||
description="自动化端到端测试图,0元下载校验。",
|
||||
price=0,
|
||||
category="设计素材",
|
||||
tags="E2E测试,自动化",
|
||||
designer_name="自动化测试",
|
||||
)
|
||||
print(upload_result.as_dict())
|
||||
if not upload_result.success:
|
||||
raise RuntimeError(f"图绘上传失败: {upload_result.message}")
|
||||
|
||||
print("== 登录图绘并创建0元订单 ==")
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
token, api_base = await login_tuhui(client)
|
||||
pay_info = await create_order_and_pay(client, api_base, token, upload_result.work_id)
|
||||
print(pay_info)
|
||||
|
||||
print("== 下载作品 ==")
|
||||
downloaded = await download_work(client, api_base, token, upload_result.work_id)
|
||||
print({"downloaded_file": str(downloaded), "size": downloaded.stat().st_size})
|
||||
|
||||
print("== 测试完成 ==")
|
||||
print(
|
||||
{
|
||||
"work_id": upload_result.work_id,
|
||||
"detail_url": upload_result.download_url,
|
||||
"processed_output": str(output_path),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Binary file not shown.
@@ -3,6 +3,7 @@ import logging
|
||||
import httpx
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from services.service_designer_alert import designer_alert_service
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
@@ -53,6 +54,10 @@ class DispatchService:
|
||||
return designer
|
||||
|
||||
logger.warning(f"[Dispatch]{u_tag} 派单被拒: {data.get('reason')} body={body}")
|
||||
await designer_alert_service.notify_if_needed(
|
||||
trigger=f"dispatch_rejected:{data.get('reason') or 'unknown'}",
|
||||
customer_id=user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if response.status_code == 401:
|
||||
|
||||
378
services/service_auto_image_pipeline.py
Normal file
378
services/service_auto_image_pipeline.py
Normal file
@@ -0,0 +1,378 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from PIL import Image
|
||||
|
||||
from db.customer_db import CustomerDatabase
|
||||
from db.image_tasks_db import TaskStatus, db as task_db
|
||||
from services.service_gemini import GeminiExtractV2Service
|
||||
from services.service_tuhui_upload import upload_to_tuhui
|
||||
from services.service_wecom_bot import wecom_bot_service
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
AUTO_PROCESS_PRICE = int(os.getenv("AUTO_PROCESS_DEFAULT_PRICE", "12"))
|
||||
AUTO_PROCESS_CATEGORY = os.getenv("AUTO_PROCESS_CATEGORY", "设计素材")
|
||||
AUTO_PROCESS_ROOT = Path(
|
||||
os.getenv("AUTO_PROCESS_ROOT", str(Path(__file__).resolve().parents[1] / "runtime" / "auto_processed"))
|
||||
)
|
||||
_DOWNLOAD_REFERERS = (
|
||||
"https://www.taobao.com/",
|
||||
"https://item.taobao.com/",
|
||||
"https://detail.tmall.com/",
|
||||
)
|
||||
_DOWNLOAD_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/133.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
}
|
||||
_CONTENT_TYPE_SUFFIX = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/avif": ".avif",
|
||||
"image/gif": ".gif",
|
||||
}
|
||||
_DESIGNER_ALIAS_PREFIXES = ("青木", "星野", "白川", "南栀", "言川", "木也", "安可", "拾光", "云岸", "知禾")
|
||||
_DESIGNER_ALIAS_SUFFIXES = ("设计", "studio", "视觉", "创意", "图像", "工坊", "素材", "像素")
|
||||
|
||||
|
||||
def _safe_name(text: str, fallback: str = "image") -> str:
|
||||
cleaned = re.sub(r"[^0-9A-Za-z\u4e00-\u9fa5_-]+", "_", str(text or "").strip())
|
||||
cleaned = cleaned.strip("_")
|
||||
return cleaned[:40] or fallback
|
||||
|
||||
|
||||
def _looks_like_bad_title(text: str) -> bool:
|
||||
value = str(text or "").strip().lower()
|
||||
if not value:
|
||||
return True
|
||||
if "http" in value or "www" in value or "alicdn" in value or "imgextra" in value:
|
||||
return True
|
||||
if re.search(r"\b(o1cn|jpg|jpeg|png|webp|gif)\b", value):
|
||||
return True
|
||||
if value.count("_") >= 3 and not re.search(r"[\u4e00-\u9fa5]{2,}", value):
|
||||
return True
|
||||
alnum = re.sub(r"[^0-9a-z_]+", "", value)
|
||||
if alnum and len(alnum) >= 16 and not re.search(r"[\u4e00-\u9fa5]", value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _pick_clean_title_part(raw: str) -> str:
|
||||
cleaned = _safe_name(raw, "")
|
||||
if not cleaned or _looks_like_bad_title(cleaned):
|
||||
return ""
|
||||
parts = [part for part in cleaned.split("_") if part]
|
||||
meaningful = [part for part in parts if not _looks_like_bad_title(part) and len(part) >= 2]
|
||||
if meaningful:
|
||||
cleaned = "_".join(meaningful[:3])
|
||||
if _looks_like_bad_title(cleaned):
|
||||
return ""
|
||||
return cleaned[:30]
|
||||
|
||||
|
||||
def _suffix_from_url(url: str) -> str:
|
||||
path = urlparse(str(url or "")).path
|
||||
suffix = Path(path).suffix.lower()
|
||||
if suffix in {".png", ".jpg", ".jpeg", ".webp"}:
|
||||
return suffix
|
||||
return ".png"
|
||||
|
||||
|
||||
def _build_processing_prompt(intent: str, requirement_text: str, analysis: Dict) -> str:
|
||||
base_prompt = str((analysis or {}).get("gemini_prompt") or "").strip()
|
||||
req = str(requirement_text or "").strip()
|
||||
if base_prompt:
|
||||
return base_prompt
|
||||
if intent == "repair":
|
||||
return f"根据客户需求“{req or '高清修复'}”,保留主体和构图,做高清修复并补足细节。"
|
||||
return f"根据客户需求“{req or '找原图'}”,严格参考原图元素与构图,生成完整干净的高质量素材图。"
|
||||
|
||||
|
||||
def _build_upload_title(intent: str, analysis: Dict, requirement_text: str, idx: int) -> str:
|
||||
analysis = analysis or {}
|
||||
suggested = _pick_clean_title_part(str(analysis.get("title_suggest") or ""))
|
||||
if suggested:
|
||||
return suggested
|
||||
subject = _pick_clean_title_part(str(analysis.get("subject") or ""))
|
||||
proc_type = _pick_clean_title_part(str(analysis.get("proc_type") or ""))
|
||||
|
||||
parts = [part for part in (subject, proc_type) if part]
|
||||
if parts:
|
||||
base = "_".join(parts[:2])
|
||||
else:
|
||||
base = "图片素材"
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def _build_designer_alias() -> str:
|
||||
return f"{random.choice(_DESIGNER_ALIAS_PREFIXES)}{random.choice(_DESIGNER_ALIAS_SUFFIXES)}"
|
||||
|
||||
|
||||
class AutoImagePipelineService:
|
||||
def __init__(self):
|
||||
self.customer_db = CustomerDatabase()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_download_path(dest_path: Path, content_type: str, image_url: str) -> Path:
|
||||
normalized_type = str(content_type or "").split(";", 1)[0].strip().lower()
|
||||
suffix = _CONTENT_TYPE_SUFFIX.get(normalized_type, "")
|
||||
if not suffix:
|
||||
guessed, _ = mimetypes.guess_type(str(image_url or ""))
|
||||
suffix = _CONTENT_TYPE_SUFFIX.get(str(guessed or "").lower(), "")
|
||||
suffix = suffix or dest_path.suffix or ".bin"
|
||||
return dest_path.with_suffix(suffix)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_image_for_gemini(image_path: Path) -> Path:
|
||||
suffix = image_path.suffix.lower()
|
||||
with Image.open(image_path) as img:
|
||||
is_animated = bool(getattr(img, "is_animated", False)) or int(getattr(img, "n_frames", 1) or 1) > 1
|
||||
needs_convert = suffix in {".avif", ".webp", ".gif"} or is_animated or img.mode not in ("RGB", "L")
|
||||
if not needs_convert:
|
||||
return image_path
|
||||
|
||||
normalized_path = image_path.with_suffix(".jpg")
|
||||
if is_animated:
|
||||
try:
|
||||
img.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
img.save(normalized_path, format="JPEG", quality=95)
|
||||
logger.info(
|
||||
f"[AutoImagePipeline] 已转换图片格式供Gemini使用: src={image_path} normalized={normalized_path}"
|
||||
)
|
||||
return normalized_path
|
||||
|
||||
async def _download_image(self, image_url: str, dest_path: Path) -> Path:
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
timeout = httpx.Timeout(60.0, connect=20.0)
|
||||
last_error: Optional[Exception] = None
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
for referer in _DOWNLOAD_REFERERS:
|
||||
for attempt in range(1, 4):
|
||||
headers = dict(_DOWNLOAD_HEADERS)
|
||||
headers["Referer"] = referer
|
||||
try:
|
||||
response = await client.get(image_url, headers=headers)
|
||||
if response.status_code in (403, 420, 429):
|
||||
raise httpx.HTTPStatusError(
|
||||
f"download blocked status={response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
response.raise_for_status()
|
||||
resolved_path = self._resolve_download_path(
|
||||
dest_path,
|
||||
response.headers.get("content-type", ""),
|
||||
image_url,
|
||||
)
|
||||
resolved_path.write_bytes(response.content)
|
||||
logger.info(
|
||||
f"[AutoImagePipeline] 图片下载成功 status={response.status_code} "
|
||||
f"referer={referer} path={resolved_path}"
|
||||
)
|
||||
return resolved_path
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(
|
||||
f"[AutoImagePipeline] 图片下载失败 attempt={attempt}/3 "
|
||||
f"referer={referer} url={image_url} err={e}"
|
||||
)
|
||||
if attempt < 3:
|
||||
await asyncio.sleep(attempt)
|
||||
|
||||
raise RuntimeError(f"下载原图失败: {last_error}")
|
||||
|
||||
@staticmethod
|
||||
def _format_transfer_notice(
|
||||
customer_id: str,
|
||||
acc_id: str,
|
||||
designer_name: str,
|
||||
requirement_text: str,
|
||||
intent: str,
|
||||
image_urls: List[str],
|
||||
) -> str:
|
||||
lines = [
|
||||
"【AI自动转设计师】",
|
||||
f"店铺:{acc_id or '-'}",
|
||||
f"客户:{customer_id or '-'}",
|
||||
f"需求:{requirement_text or '-'}",
|
||||
f"类型:{'高清修复' if intent == 'repair' else '找原图'}",
|
||||
f"默认价格:{AUTO_PROCESS_PRICE}元",
|
||||
]
|
||||
if image_urls:
|
||||
lines.append("原图URL:")
|
||||
lines.extend(image_urls[:5])
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _format_finish_notice(
|
||||
customer_id: str,
|
||||
acc_id: str,
|
||||
designer_name: str,
|
||||
links: List[Dict[str, str]],
|
||||
failures: List[str],
|
||||
) -> str:
|
||||
lines = [
|
||||
"【AI处理完成】",
|
||||
f"店铺:{acc_id or '-'}",
|
||||
f"客户:{customer_id or '-'}",
|
||||
f"默认价格:{AUTO_PROCESS_PRICE}元",
|
||||
]
|
||||
if links:
|
||||
lines.append("处理结果:")
|
||||
for idx, item in enumerate(links, 1):
|
||||
lines.append(f"{idx}. 图绘链接:{item.get('download_url') or '-'}")
|
||||
lines.append(f" 处理后图片:{item.get('image_url') or '-'}")
|
||||
lines.append(f" 原图URL:{item.get('source_url') or '-'}")
|
||||
if failures:
|
||||
lines.append("失败项:")
|
||||
lines.extend(failures[:5])
|
||||
return "\n".join(lines)
|
||||
|
||||
async def process_and_notify(
|
||||
self,
|
||||
*,
|
||||
session_key: str,
|
||||
customer_id: str,
|
||||
acc_id: str,
|
||||
designer_name: str,
|
||||
requirement_text: str,
|
||||
image_urls: List[str],
|
||||
intent: str = "",
|
||||
) -> Dict:
|
||||
image_urls = [str(url).strip() for url in (image_urls or []) if str(url).strip()]
|
||||
if not image_urls:
|
||||
return {"success": False, "message": "no_images"}
|
||||
image_urls = image_urls[:1]
|
||||
|
||||
profile = self.customer_db.get_customer(session_key)
|
||||
analysis = {}
|
||||
if getattr(profile, "last_image_analysis", ""):
|
||||
try:
|
||||
analysis = json.loads(profile.last_image_analysis)
|
||||
except Exception:
|
||||
analysis = {}
|
||||
|
||||
if not intent:
|
||||
intent = "repair" if "修复" in requirement_text else "find_original"
|
||||
|
||||
await wecom_bot_service.send_text(
|
||||
self._format_transfer_notice(
|
||||
customer_id=customer_id,
|
||||
acc_id=acc_id,
|
||||
designer_name=designer_name,
|
||||
requirement_text=requirement_text,
|
||||
intent=intent,
|
||||
image_urls=image_urls,
|
||||
)
|
||||
)
|
||||
|
||||
pipeline_root = AUTO_PROCESS_ROOT / _safe_name(customer_id, "customer")
|
||||
pipeline_root.mkdir(parents=True, exist_ok=True)
|
||||
gemini_service = GeminiExtractV2Service()
|
||||
uploaded_links: List[Dict[str, str]] = []
|
||||
failures: List[str] = []
|
||||
|
||||
for idx, image_url in enumerate(image_urls, 1):
|
||||
digest = hashlib.md5(f"{customer_id}|{acc_id}|{image_url}".encode("utf-8")).hexdigest()[:10]
|
||||
input_path = pipeline_root / f"{digest}_src{_suffix_from_url(image_url)}"
|
||||
output_path = pipeline_root / f"{digest}_out.png"
|
||||
title = _build_upload_title(intent, analysis, requirement_text, idx)
|
||||
prompt = _build_processing_prompt(intent, requirement_text, analysis)
|
||||
task_id = task_db.add_task(
|
||||
customer_id=customer_id,
|
||||
platform="qianniu",
|
||||
original_image=image_url,
|
||||
operation=intent or "auto_process",
|
||||
requirements=requirement_text,
|
||||
status=TaskStatus.PROCESSING.value,
|
||||
)
|
||||
try:
|
||||
input_path = await self._download_image(image_url, input_path)
|
||||
input_path = self._normalize_image_for_gemini(input_path)
|
||||
success, message, data = await gemini_service.extract_pattern(
|
||||
str(input_path),
|
||||
str(output_path),
|
||||
custom_prompt=prompt,
|
||||
aspect_ratio=str((analysis or {}).get("aspect_ratio") or "1:1"),
|
||||
)
|
||||
if not success or not output_path.exists():
|
||||
if task_id:
|
||||
task_db.update_status(task_id, TaskStatus.FAILED.value)
|
||||
failures.append(f"{idx}. Gemini失败:{message}")
|
||||
continue
|
||||
|
||||
upload_result = await upload_to_tuhui(
|
||||
image_path=str(output_path),
|
||||
title=title,
|
||||
description=requirement_text or prompt[:120],
|
||||
price=AUTO_PROCESS_PRICE,
|
||||
category=AUTO_PROCESS_CATEGORY,
|
||||
tags="AI处理,自动转接",
|
||||
designer_name=_build_designer_alias(),
|
||||
)
|
||||
if not upload_result.success:
|
||||
if task_id:
|
||||
task_db.update_status(task_id, TaskStatus.FAILED.value)
|
||||
failures.append(f"{idx}. 图绘上传失败:{upload_result.message}")
|
||||
continue
|
||||
|
||||
if task_id:
|
||||
task_db.update_status(task_id, TaskStatus.COMPLETED.value, upload_result.download_url)
|
||||
uploaded_links.append(
|
||||
{
|
||||
"download_url": upload_result.download_url,
|
||||
"image_url": upload_result.image_url,
|
||||
"source_url": image_url,
|
||||
"work_id": str(upload_result.work_id),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
if task_id:
|
||||
task_db.update_status(task_id, TaskStatus.FAILED.value)
|
||||
failures.append(f"{idx}. 处理异常:{e}")
|
||||
|
||||
await wecom_bot_service.send_text(
|
||||
self._format_finish_notice(
|
||||
customer_id=customer_id,
|
||||
acc_id=acc_id,
|
||||
designer_name=designer_name,
|
||||
links=uploaded_links,
|
||||
failures=failures,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": bool(uploaded_links),
|
||||
"uploaded": uploaded_links,
|
||||
"failures": failures,
|
||||
}
|
||||
|
||||
|
||||
auto_image_pipeline_service = AutoImagePipelineService()
|
||||
76
services/service_designer_alert.py
Normal file
76
services/service_designer_alert.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
from db.chat_log_db import get_waiting_customer_pool
|
||||
from db.pending_transfer_db import count_open_pending_transfers
|
||||
from services.service_wecom_bot import wecom_bot_service
|
||||
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
DESIGNER_ALERT_START_HOUR = int(os.getenv("DESIGNER_ALERT_START_HOUR", "8"))
|
||||
DESIGNER_ALERT_END_HOUR = int(os.getenv("DESIGNER_ALERT_END_HOUR", "24"))
|
||||
DESIGNER_ALERT_COOLDOWN_SECONDS = int(os.getenv("DESIGNER_ALERT_COOLDOWN_SECONDS", "300"))
|
||||
DESIGNER_ALERT_POOL_WINDOW_MINUTES = int(os.getenv("DESIGNER_ALERT_POOL_WINDOW_MINUTES", "30"))
|
||||
|
||||
|
||||
class DesignerAlertService:
|
||||
def __init__(self):
|
||||
self._last_alert_at = 0.0
|
||||
|
||||
@staticmethod
|
||||
def _in_active_window(now: datetime) -> bool:
|
||||
hour = now.hour
|
||||
return DESIGNER_ALERT_START_HOUR <= hour < DESIGNER_ALERT_END_HOUR
|
||||
|
||||
@staticmethod
|
||||
def _render_shop_lines(pool: Dict) -> str:
|
||||
shops = pool.get("shops") or []
|
||||
if not shops:
|
||||
return "- 暂无店铺明细"
|
||||
lines = []
|
||||
for item in shops[:10]:
|
||||
acc_id = str(item.get("acc_id") or "")
|
||||
waiting = int(item.get("waiting_customers") or 0)
|
||||
lines.append(f"- {acc_id}:{waiting}人")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def notify_if_needed(self, *, trigger: str = "", customer_id: str = "", acc_id: str = "") -> bool:
|
||||
now = datetime.now()
|
||||
if not self._in_active_window(now):
|
||||
return False
|
||||
|
||||
now_ts = time.time()
|
||||
if now_ts - self._last_alert_at < max(DESIGNER_ALERT_COOLDOWN_SECONDS, 30):
|
||||
return False
|
||||
|
||||
pending_count = count_open_pending_transfers()
|
||||
pool = get_waiting_customer_pool(DESIGNER_ALERT_POOL_WINDOW_MINUTES)
|
||||
waiting_total = int(pool.get("total_waiting_customers") or 0)
|
||||
if pending_count <= 0 and waiting_total <= 0:
|
||||
return False
|
||||
|
||||
content = (
|
||||
"【设计师在线提醒】\n"
|
||||
f"当前时间:{now.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
"8点到24点内检测到暂无设计师接单,有客户待转接。\n"
|
||||
f"触发来源:{trigger or '-'}\n"
|
||||
f"当前会话:{customer_id or '-'} / {acc_id or '-'}\n"
|
||||
f"待转接池:{pending_count}人\n"
|
||||
f"当前客户池:{waiting_total}人(近{pool.get('window_minutes') or DESIGNER_ALERT_POOL_WINDOW_MINUTES}分钟最后一条仍是客户消息)\n"
|
||||
"店铺分布:\n"
|
||||
f"{self._render_shop_lines(pool)}\n"
|
||||
"群里如果有设计师在线,麻烦看一下。"
|
||||
)
|
||||
ok = await wecom_bot_service.send_text(content)
|
||||
if ok:
|
||||
self._last_alert_at = now_ts
|
||||
logger.info(
|
||||
f"[DesignerAlert] 已发送企微提醒 trigger={trigger} pending={pending_count} waiting={waiting_total}"
|
||||
)
|
||||
return ok
|
||||
|
||||
|
||||
designer_alert_service = DesignerAlertService()
|
||||
@@ -1,105 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gemini印花提取V2服务 - 使用服务
|
||||
更经济的选择:1.4毛/张
|
||||
"""
|
||||
"""Gemini 出图服务。固定走老张 Gemini 原生出图接口。"""
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import Dict
|
||||
|
||||
import aiohttp
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from utils.metrics_tracker import emit as metrics_emit
|
||||
|
||||
|
||||
from utils.service_base import BaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
load_dotenv()
|
||||
GEMINI_IMAGE_MODEL = os.getenv("GEMINI_IMAGE_MODEL", "gemini-3.1-flash-image-preview")
|
||||
GEMINI_IMAGE_FALLBACK_MODEL = os.getenv("GEMINI_IMAGE_FALLBACK_MODEL", "gemini-2.5-flash-image")
|
||||
GEMINI_IMAGE_SIZE = os.getenv("GEMINI_IMAGE_SIZE", "1K")
|
||||
GEMINI_THINKING_LEVEL = os.getenv("GEMINI_THINKING_LEVEL", "MINIMAL")
|
||||
|
||||
GEMINI_API_KEY = os.getenv(
|
||||
"GEMINI_API_KEY",
|
||||
"sk-8i7uYE0RtnQwDImV8a5f7014DcAb46F6BcEb72Df92218aC8",
|
||||
)
|
||||
GEMINI_IMAGE_MODEL = os.getenv("GEMINI_IMAGE_MODEL", "gemini-3-pro-image-preview")
|
||||
GEMINI_IMAGE_SIZE = os.getenv("GEMINI_IMAGE_SIZE", "2K")
|
||||
GEMINI_PERSON_GENERATION = os.getenv("GEMINI_PERSON_GENERATION", "")
|
||||
|
||||
|
||||
class GeminiExtractV2Service(BaseService):
|
||||
"""Gemini印花提取V2服务类 - 使用服务,更经济"""
|
||||
"""固定单接口的 Gemini 出图服务。"""
|
||||
|
||||
SERVICE_NAME = "gemini_extract_v2"
|
||||
API_BASE_URL = "https://api.laozhang.ai/v1beta/models"
|
||||
DEFAULT_PROMPT = (
|
||||
"提取印花图案,把褶皱移除。补齐缺失的部分,要生成完整,细节丰富,"
|
||||
"严格按照原图的元素位置生成平面的印花图,不要相似的,相似度要100%,生成高质量的印刷图"
|
||||
)
|
||||
|
||||
# 多API配置,按优先级排序(便宜的优先使用)
|
||||
API_CONFIGS = [
|
||||
|
||||
|
||||
|
||||
|
||||
# {
|
||||
# "name": "西风接口$0.003逆向",
|
||||
# "api_key": "sk-UT9aupbfHI4rc3RUn8x5D8gN5Kk31yvLZQu8M3BCY5Nja1Fc",
|
||||
# "api_url": "https://api.apiqik.com/v1/chat/completions" ,
|
||||
# "api_model": "gemini-2.5-flash-image",
|
||||
# "max_retries": 3, # 贵接口少重试
|
||||
# "cost": "低"
|
||||
# },
|
||||
|
||||
|
||||
{
|
||||
"name": "西风接口$0.014",
|
||||
"api_key": "sk-uRuvzLfIHsc3BiHZ2cyebk0cYsZ8NR9rLL326QqXCKIy9EpK",
|
||||
"api_url": "https://api.apiqik.online/v1beta/models",
|
||||
"api_model": GEMINI_IMAGE_MODEL,
|
||||
"max_retries": 2,
|
||||
"cost": "中",
|
||||
"use_gemini_format": True # 使用Gemini原生API格式
|
||||
},
|
||||
{
|
||||
"name": "西风接口Fallback",
|
||||
"api_key": "sk-uRuvzLfIHsc3BiHZ2cyebk0cYsZ8NR9rLL326QqXCKIy9EpK",
|
||||
"api_url": "https://api.apiqik.online/v1beta/models",
|
||||
"api_model": GEMINI_IMAGE_FALLBACK_MODEL,
|
||||
"max_retries": 1,
|
||||
"cost": "中",
|
||||
"use_gemini_format": True
|
||||
},
|
||||
|
||||
{
|
||||
"name": "最贵的",
|
||||
"api_key": "sk-8i7uYE0RtnQwDImV8a5f7014DcAb46F6BcEb72Df92218aC8",
|
||||
"api_url": "https://api.laozhang.ai/v1/chat/completions",
|
||||
"api_model": GEMINI_IMAGE_MODEL,
|
||||
"max_retries": 1,
|
||||
"cost": "高"
|
||||
}
|
||||
]
|
||||
|
||||
# 默认提示词
|
||||
DEFAULT_PROMPT = "提取印花图案,把褶皱移除。补齐缺失的部分,要生成完整,细节丰富,严格按照原图的元素位置生成平面的印花图,不要相似的,相似度要100%,生成高质量的印刷图"
|
||||
# DEFAULT_PROMPT = "生成图片,把衣服的图案展开起来做成数码印花印刷平面图。去掉皱褶,生成图案增强细节。排除衣服图案以外内容"
|
||||
def __init__(self):
|
||||
super().__init__(name="gemini_extract_v2")
|
||||
self.session = None
|
||||
super().__init__(name=self.SERVICE_NAME)
|
||||
|
||||
def image_to_base64(self, image_path: str) -> str:
|
||||
"""将图片文件转换为base64编码字符串"""
|
||||
try:
|
||||
@staticmethod
|
||||
def _image_to_base64(image_path: str) -> str:
|
||||
if not os.path.exists(image_path):
|
||||
logger.error(f"文件不存在: {image_path}")
|
||||
return None
|
||||
|
||||
return ""
|
||||
try:
|
||||
with open(image_path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
return encoded_string
|
||||
|
||||
return base64.b64encode(image_file.read()).decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.error(f"Base64转换失败: {e}")
|
||||
return None
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _guess_mime_type(image_path: str) -> str:
|
||||
mime_type, _ = mimetypes.guess_type(str(image_path))
|
||||
return mime_type or "image/png"
|
||||
|
||||
@staticmethod
|
||||
def _build_generation_config(
|
||||
aspect_ratio: str,
|
||||
image_size: str,
|
||||
person_generation: str,
|
||||
thinking_level: str,
|
||||
) -> Dict:
|
||||
valid_ratios = {"1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5"}
|
||||
valid_sizes = {"1K", "2K", "4K"}
|
||||
|
||||
image_config = {}
|
||||
if aspect_ratio in valid_ratios:
|
||||
image_config["aspectRatio"] = aspect_ratio
|
||||
size_val = (image_size or GEMINI_IMAGE_SIZE or "").upper().strip()
|
||||
if size_val in valid_sizes:
|
||||
image_config["imageSize"] = size_val
|
||||
person_val = (person_generation or GEMINI_PERSON_GENERATION or "").strip()
|
||||
if person_val:
|
||||
image_config["personGeneration"] = person_val
|
||||
|
||||
generation_config = {"responseModalities": ["IMAGE"]}
|
||||
if image_config:
|
||||
generation_config["imageConfig"] = image_config
|
||||
|
||||
return generation_config
|
||||
|
||||
@staticmethod
|
||||
def _extract_image_bytes(result: Dict) -> bytes:
|
||||
candidates = result.get("candidates") or []
|
||||
if not candidates:
|
||||
raise ValueError("响应缺少 candidates")
|
||||
parts = ((candidates[0] or {}).get("content") or {}).get("parts") or []
|
||||
for part in parts:
|
||||
inline_data = part.get("inlineData") or {}
|
||||
encoded = inline_data.get("data")
|
||||
if encoded:
|
||||
return base64.b64decode(encoded)
|
||||
finish_reason = candidates[0].get("finishReason") or ""
|
||||
if finish_reason == "NO_IMAGE":
|
||||
raise ValueError("模型未返回图片(NO_IMAGE)")
|
||||
raise ValueError("响应中未找到 inlineData 图片")
|
||||
|
||||
@staticmethod
|
||||
def _save_image(image_data: bytes, output_path: str) -> Dict:
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
file_size = os.path.getsize(output_path)
|
||||
return {
|
||||
"output_path": output_path,
|
||||
"file_size": file_size,
|
||||
"api_used": "laozhang_gemini_native",
|
||||
}
|
||||
|
||||
async def extract_pattern(
|
||||
self,
|
||||
@@ -111,385 +120,68 @@ class GeminiExtractV2Service(BaseService):
|
||||
person_generation: str = "",
|
||||
thinking_level: str = "",
|
||||
) -> tuple[bool, str, dict]:
|
||||
"""
|
||||
使用多API配置进行印花图案提取
|
||||
|
||||
Args:
|
||||
input_path: 输入图片路径
|
||||
output_path: 输出图片路径
|
||||
custom_prompt: 自定义提示词
|
||||
|
||||
Returns:
|
||||
tuple: (success, message, data)
|
||||
"""
|
||||
# 转换图片为Base64
|
||||
img64 = self.image_to_base64(input_path)
|
||||
img64 = self._image_to_base64(input_path)
|
||||
if not img64:
|
||||
return False, "图片编码失败", {}
|
||||
|
||||
# 使用自定义提示词或默认提示词
|
||||
prompt = custom_prompt or self.DEFAULT_PROMPT
|
||||
|
||||
# 按优先级逐个尝试API配置
|
||||
for config_index, config in enumerate(self.API_CONFIGS):
|
||||
logger.info(f"尝试使用API: {config['name']} (成本: {config['cost']})")
|
||||
metrics_emit("gemini_request", model=config.get("api_model", ""), provider=config.get("name", ""))
|
||||
|
||||
# 对每个API配置进行重试
|
||||
for attempt in range(config['max_retries']):
|
||||
try:
|
||||
logger.info(f"开始Gemini V2印花提取 - {config['name']} (第{attempt + 1}/{config['max_retries']}次尝试): {input_path}")
|
||||
|
||||
# 准备请求数据和URL
|
||||
if config.get('use_gemini_format', False):
|
||||
# Gemini原生API格式
|
||||
api_url = f"{config['api_url']}/{config['api_model']}:generateContent?key={config['api_key']}"
|
||||
prompt = str(custom_prompt or self.DEFAULT_PROMPT).strip()
|
||||
api_url = f"{self.API_BASE_URL}/{GEMINI_IMAGE_MODEL}:generateContent"
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
"Authorization": f"Bearer {GEMINI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# 有效比例列表(Auto 不传 aspectRatio)
|
||||
valid_ratios = {"1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5"}
|
||||
valid_sizes = {"1K", "2K", "4K"}
|
||||
valid_thinking = {"MINIMAL", "LOW", "MEDIUM", "HIGH"}
|
||||
image_config = {}
|
||||
if aspect_ratio in valid_ratios:
|
||||
image_config["aspectRatio"] = aspect_ratio
|
||||
size_val = (image_size or GEMINI_IMAGE_SIZE or "").upper().strip()
|
||||
if size_val in valid_sizes:
|
||||
image_config["imageSize"] = size_val
|
||||
person_val = (person_generation or GEMINI_PERSON_GENERATION or "").strip()
|
||||
if person_val:
|
||||
# 中转接口若支持该字段会生效;不设置时不发送,保证兼容
|
||||
image_config["personGeneration"] = person_val
|
||||
thinking_val = (thinking_level or GEMINI_THINKING_LEVEL or "").upper().strip()
|
||||
thinking_config = {}
|
||||
if thinking_val in valid_thinking:
|
||||
thinking_config["thinkingLevel"] = thinking_val
|
||||
|
||||
data = {
|
||||
payload = {
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"inlineData": {
|
||||
"mimeType": "image/jpeg",
|
||||
"data": img64
|
||||
}
|
||||
},
|
||||
{
|
||||
"text": prompt
|
||||
}
|
||||
{"inlineData": {"mimeType": self._guess_mime_type(input_path), "data": img64}},
|
||||
{"text": prompt},
|
||||
]
|
||||
}
|
||||
],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["IMAGE"],
|
||||
**({"imageConfig": image_config} if image_config else {}),
|
||||
**({"thinkingConfig": thinking_config} if thinking_config else {}),
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
f"Gemini 生成配置: 比例={aspect_ratio} 尺寸={image_config.get('imageSize', '默认')} "
|
||||
f"person={image_config.get('personGeneration', '默认')} thinking={thinking_config.get('thinkingLevel', '默认')}"
|
||||
)
|
||||
else:
|
||||
# OpenAI兼容格式
|
||||
api_url = config['api_url']
|
||||
headers = {
|
||||
"Authorization": f"Bearer {config['api_key']}",
|
||||
"Content-Type": "application/json"
|
||||
"generationConfig": self._build_generation_config(
|
||||
aspect_ratio=aspect_ratio,
|
||||
image_size=image_size,
|
||||
person_generation=person_generation,
|
||||
thinking_level=thinking_level,
|
||||
),
|
||||
}
|
||||
|
||||
data = {
|
||||
"model": config['api_model'],
|
||||
"stream": False,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/png;base64,{img64}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
logger.info(f"正在请求{config['name']}服务 (第{attempt + 1}次)...")
|
||||
|
||||
# 发送异步请求
|
||||
metrics_emit("gemini_request", model=GEMINI_IMAGE_MODEL, provider="laozhang_gemini_native")
|
||||
timeout = aiohttp.ClientTimeout(total=300, connect=30)
|
||||
connector = aiohttp.TCPConnector(limit=10, limit_per_host=5)
|
||||
|
||||
for attempt in range(1, 3):
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
|
||||
async with session.post(api_url, headers=headers, json=data) as response:
|
||||
logger.info(f"Gemini 出图开始 attempt={attempt}/2 model={GEMINI_IMAGE_MODEL} input={input_path}")
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(api_url, headers=headers, json=payload) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
logger.error(f"{config['name']} API请求失败 (第{attempt + 1}次): {response.status} - {error_text}")
|
||||
|
||||
# 如果是当前API配置的最后一次重试
|
||||
if attempt == config['max_retries'] - 1:
|
||||
logger.warning(f"{config['name']} 所有重试已用完,切换到下一个API配置")
|
||||
break
|
||||
|
||||
# 当前API配置内部重试
|
||||
base_wait_time = 2
|
||||
wait_time = base_wait_time * (attempt + 1)
|
||||
logger.info(f"等待{wait_time}秒后重试{config['name']}...")
|
||||
await asyncio.sleep(wait_time)
|
||||
logger.error(f"Gemini API请求失败 attempt={attempt}: {response.status} - {error_text}")
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(attempt)
|
||||
continue
|
||||
|
||||
return False, f"Gemini API请求失败: {response.status}", {}
|
||||
result = await response.json()
|
||||
# Gemini 偶发只返回文本不返回图片:NO_IMAGE 时快速重试/降级
|
||||
if config.get('use_gemini_format', False):
|
||||
finish_reason = ""
|
||||
try:
|
||||
finish_reason = (
|
||||
(result.get("candidates") or [{}])[0].get("finishReason", "")
|
||||
)
|
||||
except Exception:
|
||||
finish_reason = ""
|
||||
if finish_reason == "NO_IMAGE":
|
||||
logger.warning(
|
||||
f"{config['name']} 返回 NO_IMAGE (模型={config.get('api_model')}),第{attempt + 1}次"
|
||||
)
|
||||
metrics_emit("gemini_no_image", model=config.get("api_model", ""), provider=config.get("name", ""))
|
||||
if attempt == config['max_retries'] - 1:
|
||||
logger.warning(f"{config['name']} NO_IMAGE 重试已用完,切换下一个配置")
|
||||
break
|
||||
await asyncio.sleep(1 + attempt)
|
||||
continue
|
||||
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, AssertionError) as e:
|
||||
logger.error(f"{config['name']} 网络连接错误 (第{attempt + 1}次): {str(e)}")
|
||||
|
||||
# 如果是当前API配置的最后一次重试
|
||||
if attempt == config['max_retries'] - 1:
|
||||
logger.warning(f"{config['name']} 网络重试已用完,切换到下一个API配置")
|
||||
break
|
||||
|
||||
# 当前API配置内部重试
|
||||
base_wait_time = 2
|
||||
wait_time = base_wait_time * (attempt + 1)
|
||||
logger.info(f"等待{wait_time}秒后重试{config['name']}...")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
|
||||
logger.info(f"{config['name']} 服务请求成功 (第{attempt + 1}次),正在处理响应...")
|
||||
|
||||
# 处理API响应并提取图片
|
||||
success, message, data = await self._process_api_response(result, output_path, config['name'], config)
|
||||
|
||||
if success:
|
||||
logger.info(f"使用 {config['name']} 成功完成印花提取")
|
||||
metrics_emit("gemini_success", model=config.get("api_model", ""), provider=config.get("name", ""))
|
||||
image_bytes = self._extract_image_bytes(result)
|
||||
data = self._save_image(image_bytes, output_path)
|
||||
metrics_emit("gemini_success", model=GEMINI_IMAGE_MODEL, provider="laozhang_gemini_native")
|
||||
try:
|
||||
from utils.api_cost_tracker import record
|
||||
|
||||
record("gemini_extract", count=1)
|
||||
except Exception:
|
||||
pass
|
||||
return True, f"Gemini V2印花提取完成 - 使用{config['name']}", data
|
||||
else:
|
||||
logger.warning(f"{config['name']} 响应处理失败: {message}")
|
||||
|
||||
# 如果是当前API配置的最后一次重试
|
||||
if attempt == config['max_retries'] - 1:
|
||||
logger.warning(f"{config['name']} 所有重试已用完,切换到下一个API配置")
|
||||
break
|
||||
|
||||
# 当前API配置内部重试
|
||||
base_wait_time = 2
|
||||
wait_time = base_wait_time * (attempt + 1)
|
||||
logger.info(f"等待{wait_time}秒后重试{config['name']}...")
|
||||
await asyncio.sleep(wait_time)
|
||||
return True, "Gemini 出图完成", data
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 出图异常 attempt={attempt}: {e}")
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(attempt)
|
||||
continue
|
||||
return False, f"Gemini 出图失败: {e}", {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{config['name']} API调用异常 (第{attempt + 1}次): {str(e)}")
|
||||
|
||||
# 如果是当前API配置的最后一次重试
|
||||
if attempt == config['max_retries'] - 1:
|
||||
logger.warning(f"{config['name']} 异常重试已用完,切换到下一个API配置")
|
||||
break
|
||||
|
||||
# 当前API配置内部重试
|
||||
base_wait_time = 2
|
||||
wait_time = base_wait_time * (attempt + 1)
|
||||
logger.info(f"等待{wait_time}秒后重试{config['name']}...")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
|
||||
# 所有API配置都尝试过了,返回失败
|
||||
return False, "所有API配置都已尝试失败", {}
|
||||
|
||||
async def _process_api_response(self, result: dict, output_path: str, api_name: str, config: dict) -> tuple[bool, str, dict]:
|
||||
"""处理API响应并提取图片"""
|
||||
try:
|
||||
# 根据API格式提取内容
|
||||
if config.get('use_gemini_format', False):
|
||||
# Gemini原生API格式: candidates[0].content.parts[0]
|
||||
content_parts = result['candidates'][0]['content']['parts']
|
||||
|
||||
# 查找包含图片数据的part
|
||||
image_data = None
|
||||
for part in content_parts:
|
||||
# 注意:响应中使用驼峰命名 inlineData
|
||||
if 'inlineData' in part:
|
||||
# 提取Base64图片数据
|
||||
base64_data = part['inlineData']['data']
|
||||
logger.info(f"{api_name} 找到Gemini格式的inlineData图片")
|
||||
try:
|
||||
image_data = base64.b64decode(base64_data)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"{api_name} Base64解码失败: {e}")
|
||||
return False, f"Base64解码失败: {e}", {}
|
||||
|
||||
if not image_data:
|
||||
logger.error(f"{api_name} 在Gemini响应中未找到图片数据")
|
||||
return False, "未找到图片数据", {}
|
||||
|
||||
# 直接保存图片
|
||||
return await self._save_image(image_data, output_path, api_name)
|
||||
|
||||
else:
|
||||
# OpenAI兼容格式: choices[0].message.content
|
||||
content = result['choices'][0]['message']['content']
|
||||
logger.info(f"{api_name} 收到内容: {content[:200]}...")
|
||||
|
||||
# 使用原有的URL/Base64提取逻辑
|
||||
return await self._extract_and_save_image(content, output_path, api_name)
|
||||
|
||||
except KeyError as e:
|
||||
logger.error(f"{api_name} 响应格式不正确,缺少字段: {e}")
|
||||
logger.error(f"响应内容: {json.dumps(result, ensure_ascii=False)[:500]}")
|
||||
return False, f"响应格式错误: {e}", {}
|
||||
except Exception as e:
|
||||
logger.error(f"{api_name} 处理响应时发生异常: {e}")
|
||||
return False, f"处理异常: {e}", {}
|
||||
|
||||
async def _save_image(self, image_data: bytes, output_path: str, api_name: str) -> tuple[bool, str, dict]:
|
||||
"""保存图片文件"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
logger.info(f"{api_name} 图片已保存到: {output_path}")
|
||||
|
||||
# 验证保存的图片
|
||||
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
|
||||
file_size = os.path.getsize(output_path)
|
||||
logger.info(f"{api_name} 图片保存成功,文件大小: {file_size} bytes")
|
||||
|
||||
return True, f"{api_name} 印花提取完成", {
|
||||
'output_path': output_path,
|
||||
'file_size': file_size,
|
||||
'api_used': api_name
|
||||
}
|
||||
else:
|
||||
logger.error(f"{api_name} 保存的图片文件无效")
|
||||
return False, "保存的图片文件无效", {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{api_name} 保存图片时发生错误: {e}")
|
||||
return False, f"保存图片失败: {e}", {}
|
||||
|
||||
async def _extract_and_save_image(self, content: str, output_path: str, api_name: str) -> tuple[bool, str, dict]:
|
||||
"""从响应内容中提取并保存图片(URL或Base64格式)"""
|
||||
# 查找和处理图片数据
|
||||
image_data = None
|
||||
|
||||
# 方法1: 查找URL链接 (优先检查URL格式)
|
||||
url_match = re.search(r'https?://[^\s\)]+\.(?:png|jpg|jpeg|gif|webp)', content)
|
||||
if url_match:
|
||||
image_url = url_match.group(0)
|
||||
logger.info(f"{api_name} 找到图片URL: {image_url}")
|
||||
|
||||
# 图片下载重试机制
|
||||
download_retries = 3
|
||||
for download_attempt in range(download_retries):
|
||||
try:
|
||||
logger.info(f"{api_name} 开始下载图片 (第{download_attempt + 1}/{download_retries}次尝试): {image_url}")
|
||||
|
||||
# 异步下载图片,增加超时时间
|
||||
timeout = aiohttp.ClientTimeout(total=300, connect=60)
|
||||
connector = aiohttp.TCPConnector(limit=5, limit_per_host=2)
|
||||
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=timeout,
|
||||
connector=connector,
|
||||
headers=headers
|
||||
) as download_session:
|
||||
logger.info(f"{api_name} 正在发送HTTP请求...")
|
||||
async with download_session.get(image_url) as img_response:
|
||||
logger.info(f"{api_name} 收到HTTP响应: {img_response.status}")
|
||||
if img_response.status == 200:
|
||||
image_data = await img_response.read()
|
||||
logger.info(f"{api_name} 图片下载成功,大小: {len(image_data)} bytes")
|
||||
break # 成功则跳出重试循环
|
||||
else:
|
||||
logger.error(f"{api_name} 图片下载失败,HTTP状态码: {img_response.status}")
|
||||
if download_attempt == download_retries - 1:
|
||||
return False, "图片下载失败", {}
|
||||
else:
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{api_name} 下载图片时发生异常 (第{download_attempt + 1}次): {type(e).__name__}: {str(e)}")
|
||||
if download_attempt == download_retries - 1:
|
||||
return False, f"图片下载异常: {str(e)}", {}
|
||||
else:
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
else:
|
||||
# 方法2: 查找标准格式 data:image/type;base64,data
|
||||
base64_match = re.search(r'data:image/[^;]+;base64,([A-Za-z0-9+/=]+)', content)
|
||||
|
||||
if base64_match:
|
||||
base64_data = base64_match.group(1)
|
||||
logger.info(f"{api_name} 找到标准格式的Base64数据")
|
||||
try:
|
||||
image_data = base64.b64decode(base64_data)
|
||||
except Exception as e:
|
||||
logger.error(f"{api_name} Base64解码失败: {e}")
|
||||
return False, f"Base64解码失败: {e}", {}
|
||||
else:
|
||||
# 方法3: 查找纯Base64数据(长字符串)
|
||||
base64_match = re.search(r'([A-Za-z0-9+/=]{100,})', content)
|
||||
if base64_match:
|
||||
base64_data = base64_match.group(1)
|
||||
logger.info(f"{api_name} 找到纯Base64数据")
|
||||
try:
|
||||
image_data = base64.b64decode(base64_data)
|
||||
except Exception as e:
|
||||
logger.error(f"{api_name} Base64解码失败: {e}")
|
||||
return False, f"Base64解码失败: {e}", {}
|
||||
else:
|
||||
logger.error(f"{api_name} 在响应中未找到图片数据")
|
||||
return False, "未找到图片数据", {}
|
||||
|
||||
# 检查图片数据
|
||||
if not image_data:
|
||||
logger.error(f"{api_name} 图片数据为空")
|
||||
return False, "图片数据为空", {}
|
||||
|
||||
# 保存图片
|
||||
return await self._save_image(image_data, output_path, api_name)
|
||||
return False, "Gemini 出图失败", {}
|
||||
|
||||
async def correct_perspective(
|
||||
self,
|
||||
@@ -497,29 +189,16 @@ class GeminiExtractV2Service(BaseService):
|
||||
output_path: str,
|
||||
level: str = "mild",
|
||||
) -> tuple[bool, str, dict]:
|
||||
"""
|
||||
透视矫正:先把有透视畸变的图还原为正面平铺视图,再做后续处理。
|
||||
|
||||
Args:
|
||||
input_path: 本地图片路径
|
||||
output_path: 矫正后输出路径
|
||||
level: "mild" 或 "strong"
|
||||
"""
|
||||
if level == "strong":
|
||||
prompt = (
|
||||
"这张图存在明显透视畸变(俯拍/斜拍/贴墙)。"
|
||||
"请对图片进行透视矫正:将主体变换为正面平铺视图,"
|
||||
"使所有边缘变成水平或垂直,去除梯形形变,"
|
||||
"保持图案颜色和细节完全不变,只矫正几何形状,输出矫正后的完整图片。"
|
||||
"这张图存在明显透视畸变。请把主体矫正为正面平铺视图,"
|
||||
"所有边缘尽量水平或垂直,保持图案颜色和细节不变,只做几何矫正。"
|
||||
)
|
||||
else:
|
||||
prompt = (
|
||||
"这张图存在轻微透视畸变(衣物悬挂/桌面斜拍)。"
|
||||
"请做轻度透视矫正:将主体调整为尽量正视角,"
|
||||
"消除轻微的梯形拉伸感,保持图案颜色和细节不变,输出矫正后的图片。"
|
||||
"这张图存在轻微透视畸变。请做轻度透视矫正,"
|
||||
"消除斜拍拉伸感,保持图案颜色和细节不变。"
|
||||
)
|
||||
|
||||
# 透视矫正使用 1:1 比例避免比例失真
|
||||
return await self.extract_pattern(
|
||||
input_path=input_path,
|
||||
output_path=output_path,
|
||||
@@ -528,40 +207,17 @@ class GeminiExtractV2Service(BaseService):
|
||||
)
|
||||
|
||||
async def cleanup(self):
|
||||
"""清理资源"""
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
return None
|
||||
|
||||
|
||||
# 便捷函数
|
||||
async def extract_pattern_v2(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
custom_prompt: str = None,
|
||||
aspect_ratio: str = "1:1",
|
||||
) -> tuple[bool, str, dict]:
|
||||
"""Gemini V2印花提取便捷函数"""
|
||||
service = GeminiExtractV2Service()
|
||||
try:
|
||||
return await service.extract_pattern(input_path, output_path, custom_prompt, aspect_ratio)
|
||||
finally:
|
||||
await service.cleanup()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试代码
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
service = GeminiExtractV2Service()
|
||||
|
||||
input_path = "F:/api/134.png"
|
||||
output_path = "test_output_v2.png"
|
||||
|
||||
success, message, data = await service.extract_pattern(input_path, output_path)
|
||||
|
||||
print(f"结果: {success}")
|
||||
print(f"消息: {message}")
|
||||
print(f"数据: {data}")
|
||||
|
||||
await service.cleanup()
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
@@ -20,6 +20,13 @@ logger = logging.getLogger("cs_agent")
|
||||
|
||||
|
||||
ANALYSIS_PROMPT = """你是一个电商图片处理评估专家。
|
||||
客户需求如下:
|
||||
{customer_requirement}
|
||||
|
||||
请结合客户需求和图片内容一起判断,不要只看图片本身。
|
||||
如果客户明确说了“找原图/找图/素材/大图”,类型优先判断为“找原图/素材提取”类;
|
||||
如果客户明确说了“修复/高清/清晰/放大”,类型优先判断为“高清修复”类。
|
||||
|
||||
请仔细分析这张图片,输出以下字段,每行一个,不要多余内容:
|
||||
|
||||
敏感内容: <yes|no>
|
||||
@@ -61,6 +68,26 @@ ANALYSIS_PROMPT = """你是一个电商图片处理评估专家。
|
||||
"""
|
||||
|
||||
|
||||
def _sanitize_title_part(text: str) -> str:
|
||||
value = str(text or "").strip()
|
||||
value = value.replace("/", "_").replace("\\", "_")
|
||||
value = " ".join(value.split())
|
||||
return value[:20]
|
||||
|
||||
|
||||
def _build_title_suggest(subject: str, proc_type: str, customer_requirement: str) -> str:
|
||||
subject_part = _sanitize_title_part(subject)
|
||||
proc_part = _sanitize_title_part(proc_type)
|
||||
req_part = _sanitize_title_part(customer_requirement)
|
||||
|
||||
parts = [part for part in (subject_part, proc_part) if part]
|
||||
if parts:
|
||||
return "_".join(parts[:2])
|
||||
if req_part:
|
||||
return req_part
|
||||
return "图片识别结果"
|
||||
|
||||
|
||||
class ImageAnalyzerService:
|
||||
"""图片分析服务 - 后台静默运行,不影响主流程"""
|
||||
|
||||
@@ -101,7 +128,7 @@ class ImageAnalyzerService:
|
||||
logger.debug(f"[ImageAnalyzer] 获取尺寸失败: {e}")
|
||||
return (0, 0)
|
||||
|
||||
async def analyze(self, image_url: str) -> Dict[str, Any]:
|
||||
async def analyze(self, image_url: str, customer_requirement: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
异步分析图片,返回结构化结果
|
||||
|
||||
@@ -121,6 +148,7 @@ class ImageAnalyzerService:
|
||||
"perspective": no|mild|strong,
|
||||
"aspect_ratio": 比例,
|
||||
"gemini_prompt": 处理提示词,
|
||||
"title_suggest": 推荐标题,
|
||||
"note": 备注,
|
||||
"price_suggest": 建议价格,
|
||||
"width": 宽度,
|
||||
@@ -133,7 +161,8 @@ class ImageAnalyzerService:
|
||||
return self._fallback(image_url, "未配置 API Key")
|
||||
|
||||
# 缓存检查
|
||||
cache_key = image_url
|
||||
customer_requirement = str(customer_requirement or "").strip()
|
||||
cache_key = f"{image_url}|{customer_requirement}"
|
||||
now = time.monotonic()
|
||||
cached = self._analysis_cache.get(cache_key)
|
||||
if cached:
|
||||
@@ -149,6 +178,9 @@ class ImageAnalyzerService:
|
||||
try:
|
||||
client = AsyncOpenAI(base_url=self.base_url, api_key=self.api_key)
|
||||
|
||||
prompt_text = ANALYSIS_PROMPT.format(
|
||||
customer_requirement=customer_requirement or "未提供明确补充需求"
|
||||
)
|
||||
response = await asyncio.wait_for(
|
||||
client.chat.completions.create(
|
||||
model=self.vision_model,
|
||||
@@ -156,7 +188,7 @@ class ImageAnalyzerService:
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": image_url}},
|
||||
{"type": "text", "text": ANALYSIS_PROMPT}
|
||||
{"type": "text", "text": prompt_text}
|
||||
]
|
||||
}],
|
||||
max_tokens=500
|
||||
@@ -164,10 +196,18 @@ class ImageAnalyzerService:
|
||||
timeout=30
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
if not response.choices:
|
||||
return self._fallback(image_url, "API 返回空 choices")
|
||||
content = response.choices[0].message.content or ""
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
result = self._parse_result(image_url, content)
|
||||
result["customer_requirement"] = customer_requirement
|
||||
result["title_suggest"] = _build_title_suggest(
|
||||
result.get("subject", ""),
|
||||
result.get("proc_type", ""),
|
||||
customer_requirement,
|
||||
)
|
||||
result["elapsed"] = round(elapsed, 2)
|
||||
|
||||
# 获取尺寸
|
||||
@@ -239,6 +279,7 @@ class ImageAnalyzerService:
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"customer_requirement": "",
|
||||
"complexity": complexity,
|
||||
"reason": extract("原因"),
|
||||
"subject": extract("主体"),
|
||||
@@ -265,6 +306,11 @@ class ImageAnalyzerService:
|
||||
"difficulty": extract("难点", ""),
|
||||
"suggest_method": extract("建议方案", "AI处理"),
|
||||
"gemini_prompt": extract("提示词"),
|
||||
"title_suggest": _build_title_suggest(
|
||||
extract("主体"),
|
||||
extract("类型"),
|
||||
"",
|
||||
),
|
||||
"note": extract("备注"),
|
||||
"price_min": price_min,
|
||||
"price_max": price_max,
|
||||
@@ -278,6 +324,7 @@ class ImageAnalyzerService:
|
||||
from datetime import datetime
|
||||
return {
|
||||
"url": url,
|
||||
"customer_requirement": "",
|
||||
"complexity": "normal",
|
||||
"reason": reason,
|
||||
"subject": "",
|
||||
@@ -304,6 +351,7 @@ class ImageAnalyzerService:
|
||||
"difficulty": "",
|
||||
"suggest_method": "",
|
||||
"gemini_prompt": "",
|
||||
"title_suggest": "图片识别结果",
|
||||
"note": "",
|
||||
"price_min": 15,
|
||||
"price_max": 20,
|
||||
|
||||
@@ -6,35 +6,135 @@
|
||||
import os
|
||||
import httpx
|
||||
import logging
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from typing import Iterator, Optional
|
||||
from urllib.parse import urlparse
|
||||
from dotenv import load_dotenv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
load_dotenv()
|
||||
|
||||
# 图绘平台配置
|
||||
TUHUI_BASE_URL = os.getenv("TUHUI_BASE_URL", "http://127.0.0.1:8002")
|
||||
TUHUI_BASE_URL = os.getenv("TUHUI_BASE_URL", "https://aidg168.uk")
|
||||
TUHUI_FALLBACK_BASE_URL = "https://aidg168.uk"
|
||||
TUHUI_DIRECT_BASE_URL = os.getenv("TUHUI_DIRECT_BASE_URL", "http://156.226.181.204:8002")
|
||||
TUHUI_WEB_BASE_URL = os.getenv("TUHUI_WEB_BASE_URL", "https://aidg168.uk").rstrip("/")
|
||||
TUHUI_PHONE = os.getenv("TUHUI_PHONE", "17520145271") # 图绘账号手机号
|
||||
TUHUI_PASSWORD = os.getenv("TUHUI_PASSWORD", "zuowei1216") # 图绘账号密码
|
||||
TUHUI_DEFAULT_PRICE = int(os.getenv("TUHUI_DEFAULT_PRICE", "20")) # 默认定价(元)
|
||||
TUHUI_DEFAULT_CATEGORY = os.getenv("TUHUI_DEFAULT_CATEGORY", "设计素材")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TuhuiUploadResult:
|
||||
"""图绘上传结果。主返回 URL 为站内作品页,保留三元组解包兼容。"""
|
||||
success: bool
|
||||
download_url: str
|
||||
work_id: int
|
||||
image_url: str = ""
|
||||
thumbnail_url: str = ""
|
||||
watermarked_url: str = ""
|
||||
message: str = ""
|
||||
|
||||
def __iter__(self) -> Iterator[object]:
|
||||
# 兼容历史调用:ok, download_url, work_id = result
|
||||
yield self.success
|
||||
yield self.download_url
|
||||
yield self.work_id
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"success": self.success,
|
||||
"download_url": self.download_url,
|
||||
"work_id": self.work_id,
|
||||
"image_url": self.image_url,
|
||||
"thumbnail_url": self.thumbnail_url,
|
||||
"watermarked_url": self.watermarked_url,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
class TuhuiUploadService:
|
||||
"""图绘平台上传服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = TUHUI_BASE_URL
|
||||
self.base_url = TUHUI_BASE_URL.rstrip("/")
|
||||
self.base_urls = []
|
||||
for candidate in (
|
||||
TUHUI_FALLBACK_BASE_URL.rstrip("/"),
|
||||
TUHUI_DIRECT_BASE_URL.rstrip("/"),
|
||||
self.base_url,
|
||||
):
|
||||
if candidate and candidate not in self.base_urls:
|
||||
self.base_urls.append(candidate)
|
||||
if self.base_urls:
|
||||
self.base_url = self.base_urls[0]
|
||||
self.phone = TUHUI_PHONE
|
||||
self.password = TUHUI_PASSWORD
|
||||
self.default_price = TUHUI_DEFAULT_PRICE
|
||||
self.access_token = None
|
||||
self.user_id = None
|
||||
|
||||
@staticmethod
|
||||
def _build_api_url(base_url: str, path: str) -> str:
|
||||
normalized = path if path.startswith("/") else f"/{path}"
|
||||
if base_url.endswith("/api"):
|
||||
return f"{base_url}{normalized}"
|
||||
return f"{base_url}/api{normalized}"
|
||||
|
||||
def _api_url(self, path: str) -> str:
|
||||
return self._build_api_url(self.base_url, path)
|
||||
|
||||
@staticmethod
|
||||
def _build_work_url(work_id: int) -> str:
|
||||
return f"{TUHUI_WEB_BASE_URL}/detail/{int(work_id)}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_asset_url(raw_url: str) -> str:
|
||||
url = str(raw_url or "").strip()
|
||||
if not url:
|
||||
return ""
|
||||
if url.startswith("/"):
|
||||
return f"{TUHUI_WEB_BASE_URL}{url}"
|
||||
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return url
|
||||
|
||||
host = (parsed.netloc or "").lower()
|
||||
if host in {
|
||||
"tuhui.cloud",
|
||||
"www.tuhui.cloud",
|
||||
"aidg168.uk",
|
||||
"www.aidg168.uk",
|
||||
"156.226.181.204:8002",
|
||||
"1.12.50.92:8002",
|
||||
"127.0.0.1:8002",
|
||||
}:
|
||||
path = parsed.path or ""
|
||||
if parsed.query:
|
||||
path = f"{path}?{parsed.query}"
|
||||
if parsed.fragment:
|
||||
path = f"{path}#{parsed.fragment}"
|
||||
return f"{TUHUI_WEB_BASE_URL}{path}"
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def _guess_file_meta(image_path: str) -> tuple[str, str]:
|
||||
path = Path(image_path)
|
||||
filename = path.name or "image.jpg"
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
return filename, mime_type or "application/octet-stream"
|
||||
|
||||
async def login(self) -> bool:
|
||||
"""登录图绘平台获取 token"""
|
||||
last_error = ""
|
||||
for base_url in self.base_urls:
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/auth/login",
|
||||
self._build_api_url(base_url, "/auth/login"),
|
||||
json={
|
||||
"phone": self.phone,
|
||||
"password": self.password
|
||||
@@ -47,13 +147,17 @@ class TuhuiUploadService:
|
||||
self.access_token = data.get("access_token")
|
||||
user = data.get("user", {})
|
||||
self.user_id = user.get("id")
|
||||
logger.info(f"图绘平台登录成功,用户 ID: {self.user_id}")
|
||||
self.base_url = base_url
|
||||
logger.info(f"图绘平台登录成功,用户 ID: {self.user_id},base={self.base_url}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"图绘平台登录失败:{response.status_code} {response.text}")
|
||||
return False
|
||||
|
||||
last_error = f"{response.status_code} {response.text}"
|
||||
logger.warning(f"图绘平台登录失败,base={base_url}:{last_error}")
|
||||
except Exception as e:
|
||||
logger.error(f"图绘平台登录异常:{e}")
|
||||
last_error = str(e)
|
||||
logger.warning(f"图绘平台登录异常,base={base_url}:{type(e).__name__}: {e!r}")
|
||||
|
||||
logger.error(f"图绘平台登录失败:{last_error}")
|
||||
return False
|
||||
|
||||
async def upload_image(
|
||||
@@ -62,8 +166,10 @@ class TuhuiUploadService:
|
||||
title: str,
|
||||
description: str = "",
|
||||
price: Optional[int] = None,
|
||||
category: str = "高清修复"
|
||||
) -> Tuple[bool, str, int]:
|
||||
category: str = TUHUI_DEFAULT_CATEGORY,
|
||||
tags: str = "",
|
||||
designer_name: str = "",
|
||||
) -> TuhuiUploadResult:
|
||||
"""
|
||||
上传图片到图绘平台
|
||||
|
||||
@@ -75,36 +181,44 @@ class TuhuiUploadService:
|
||||
category: 分类
|
||||
|
||||
Returns:
|
||||
(success, image_url, work_id)
|
||||
TuhuiUploadResult
|
||||
- success: 是否上传成功
|
||||
- image_url: 图片 URL
|
||||
- download_url: 站内作品页地址
|
||||
- image_url: 原图 URL(保留,便于需要时取用)
|
||||
- thumbnail_url: 缩略图 URL
|
||||
- watermarked_url: 水印图 URL
|
||||
- work_id: 作品 ID
|
||||
"""
|
||||
try:
|
||||
# 如果 token 过期,重新登录
|
||||
if not self.access_token:
|
||||
if not await self.login():
|
||||
return False, "登录失败", 0
|
||||
return TuhuiUploadResult(False, "", 0, message="登录失败")
|
||||
|
||||
# 准备上传数据
|
||||
price = price or self.default_price
|
||||
price = self.default_price if price is None else price
|
||||
|
||||
# 读取图片文件
|
||||
if not os.path.exists(image_path):
|
||||
logger.error(f"图片文件不存在:{image_path}")
|
||||
return False, "文件不存在", 0
|
||||
return TuhuiUploadResult(False, "", 0, message="文件不存在")
|
||||
|
||||
filename, mime_type = self._guess_file_meta(image_path)
|
||||
with open(image_path, "rb") as f:
|
||||
files = {
|
||||
"original_image": ("image.jpg", f, "image/jpeg")
|
||||
"file": (filename, f, mime_type)
|
||||
}
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"price": str(price),
|
||||
"category": category
|
||||
"category": category,
|
||||
}
|
||||
if tags:
|
||||
data["tags"] = tags
|
||||
if designer_name:
|
||||
data["designer_name"] = str(designer_name).strip()
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.access_token}"
|
||||
@@ -112,7 +226,7 @@ class TuhuiUploadService:
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/works",
|
||||
self._api_url("/upload"),
|
||||
files=files,
|
||||
data=data,
|
||||
headers=headers,
|
||||
@@ -120,11 +234,39 @@ class TuhuiUploadService:
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
work_data = response.json()
|
||||
work_id = work_data.get("id")
|
||||
image_url = work_data.get("original_image", "")
|
||||
logger.info(f"图绘平台上传成功,作品 ID: {work_id}, URL: {image_url}")
|
||||
return True, image_url, work_id
|
||||
payload = response.json()
|
||||
if not payload.get("success", False):
|
||||
logger.error(f"图绘平台上传返回失败:{payload}")
|
||||
return TuhuiUploadResult(
|
||||
False,
|
||||
"",
|
||||
0,
|
||||
message=str(payload.get("message", "上传失败")),
|
||||
)
|
||||
|
||||
work_id = int(payload.get("work_id") or payload.get("work", {}).get("id") or 0)
|
||||
image_url = self._normalize_asset_url(
|
||||
payload.get("image_url") or payload.get("work", {}).get("original_image") or ""
|
||||
)
|
||||
thumbnail_url = self._normalize_asset_url(
|
||||
payload.get("thumbnail_url") or payload.get("work", {}).get("thumbnail_image") or ""
|
||||
)
|
||||
watermarked_url = self._normalize_asset_url(
|
||||
payload.get("watermarked_url") or payload.get("work", {}).get("watermarked_image") or ""
|
||||
)
|
||||
download_url = self._build_work_url(work_id) if work_id else ""
|
||||
logger.info(
|
||||
f"图绘平台上传成功,作品 ID: {work_id}, 站内地址: {download_url}, 原图: {image_url}"
|
||||
)
|
||||
return TuhuiUploadResult(
|
||||
True,
|
||||
download_url,
|
||||
work_id,
|
||||
image_url=image_url,
|
||||
thumbnail_url=thumbnail_url,
|
||||
watermarked_url=watermarked_url,
|
||||
message=str(payload.get("message", "上传成功")),
|
||||
)
|
||||
else:
|
||||
logger.error(f"图绘平台上传失败:{response.status_code} {response.text}")
|
||||
|
||||
@@ -135,13 +277,13 @@ class TuhuiUploadService:
|
||||
if await self.login():
|
||||
# 重新上传
|
||||
return await self.upload_image(
|
||||
image_path, title, description, price, category
|
||||
image_path, title, description, price, category, tags, designer_name
|
||||
)
|
||||
|
||||
return False, f"上传失败:{response.text}", 0
|
||||
return TuhuiUploadResult(False, "", 0, message=f"上传失败:{response.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"图绘平台上传异常:{e}")
|
||||
return False, f"上传异常:{e}", 0
|
||||
return TuhuiUploadResult(False, "", 0, message=f"上传异常:{e}")
|
||||
|
||||
|
||||
# 单例
|
||||
@@ -160,13 +302,16 @@ async def upload_to_tuhui(
|
||||
image_path: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
price: int = 20
|
||||
) -> Tuple[bool, str, int]:
|
||||
price: int = 20,
|
||||
category: str = TUHUI_DEFAULT_CATEGORY,
|
||||
tags: str = "",
|
||||
designer_name: str = "",
|
||||
) -> TuhuiUploadResult:
|
||||
"""
|
||||
便捷函数:上传图片到图绘平台
|
||||
|
||||
Returns:
|
||||
(success, image_url, work_id)
|
||||
TuhuiUploadResult
|
||||
"""
|
||||
service = get_tuhui_service()
|
||||
return await service.upload_image(image_path, title, description, price)
|
||||
return await service.upload_image(image_path, title, description, price, category, tags, designer_name)
|
||||
|
||||
51
services/service_wecom_bot.py
Normal file
51
services/service_wecom_bot.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger("cs_agent")
|
||||
|
||||
WECOM_BOT_WEBHOOK = os.getenv(
|
||||
"WECOM_BOT_WEBHOOK",
|
||||
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=cc88bdef-a13f-4d7e-bdb6-ee51b68b8205",
|
||||
).strip()
|
||||
|
||||
|
||||
class WecomBotService:
|
||||
def __init__(self, webhook_url: str = WECOM_BOT_WEBHOOK):
|
||||
self.webhook_url = str(webhook_url or "").strip()
|
||||
|
||||
async def send_text(self, content: str) -> bool:
|
||||
text = str(content or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if not self.webhook_url:
|
||||
logger.warning("[WeComBot] 未配置 webhook,跳过发送")
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": text[:3500],
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(self.webhook_url, json=payload)
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"[WeComBot] 发送失败 HTTP {response.status_code}: {response.text}")
|
||||
return False
|
||||
data = response.json()
|
||||
ok = int(data.get("errcode", -1)) == 0
|
||||
if not ok:
|
||||
logger.warning(f"[WeComBot] 发送失败: {data}")
|
||||
return ok
|
||||
except Exception as e:
|
||||
logger.warning(f"[WeComBot] 发送异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
wecom_bot_service = WecomBotService()
|
||||
@@ -26,12 +26,18 @@ description: 找原图/高清修复客服 - 需求收集、阶段引导与转接
|
||||
|
||||
## 上下文承接逻辑(让AI变聪明的关键)
|
||||
|
||||
- **回访/二次进店识别(最高优先级)**:
|
||||
- 客户说「之前聊过 / 上次 / 你看聊天记录 / 我发过了 / 前面发了图」 => **必须先调用 lookup_chat_history_tool 查历史**,根据历史回复,严禁再要图。
|
||||
- 客户问「做好了吗 / 多久能好 / 怎么样了 / 好了吗」 => 这是进度追问,先查历史确认之前的需求,用第一人称回:'我在看哈,稍等'。
|
||||
- **短句识别**:
|
||||
- 「有吗 / 有没有 / 找到了吗」 => 进度追问。回:'设计师正在看哈,稍等'。
|
||||
- 「有吗 / 有没有 / 找到了吗」 => 进度追问。回:'我在看哈,稍等'。
|
||||
- 「就这一个 / 没有了 / 就这些」 => 拿图完成。立即引导转接。
|
||||
- 「高清 / 重新发 / 发我」 => 催办。正面承接。
|
||||
- **多图关联识别**:
|
||||
- 客户发第二张图时提到「上一张」「前面那张」「局部」「细节」 => 按【同一需求补充】处理,不要当成新单。
|
||||
- **情绪升级识别**:
|
||||
- 客户连续表达不满(质问、辱骂、威胁投诉)=> 立即转人工,话术:'亲亲抱歉,我马上叫设计师来处理'。
|
||||
- 严禁在客户愤怒时继续机械重复同一句话。
|
||||
|
||||
## 业务红线
|
||||
- **绝对不说**:'客服'、'师傅'、'专员'、'AI做的'、'修复'(如果是找原图单)、'处理'。
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from core.quote_state_machine import QuoteStateMachine
|
||||
|
||||
|
||||
class _State:
|
||||
def __init__(self):
|
||||
self.pending_image_urls = []
|
||||
self.pending_requirements = []
|
||||
self.quote_phase = "idle"
|
||||
self.quote_ready_turns = 0
|
||||
|
||||
|
||||
class GoldenReplayTests(unittest.TestCase):
|
||||
def test_replay_collect_then_ready_then_quote(self):
|
||||
sm = QuoteStateMachine(delay_turns=1)
|
||||
st = _State()
|
||||
|
||||
replay = [
|
||||
{"event": "image", "url": "a.jpg", "want_phase": "collecting"},
|
||||
{"event": "image", "url": "b.jpg", "want_phase": "collecting"},
|
||||
{"event": "finish", "want_phase": "ready_to_quote", "want_defer": True},
|
||||
{"event": "progress", "want_phase": "ready_to_quote", "want_defer": False},
|
||||
]
|
||||
|
||||
for step in replay:
|
||||
if step["event"] == "image":
|
||||
st.pending_image_urls.append(step["url"])
|
||||
sm.refresh(st)
|
||||
self.assertEqual(st.quote_phase, step["want_phase"])
|
||||
elif step["event"] == "finish":
|
||||
deferred = sm.should_defer_batch_quote(st, mark_ready=True)
|
||||
self.assertEqual(st.quote_phase, step["want_phase"])
|
||||
self.assertEqual(deferred, step["want_defer"])
|
||||
elif step["event"] == "progress":
|
||||
deferred = sm.should_defer_batch_quote(st, mark_ready=False)
|
||||
self.assertEqual(st.quote_phase, step["want_phase"])
|
||||
self.assertEqual(deferred, step["want_defer"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,330 +0,0 @@
|
||||
"""
|
||||
AI Agent 对话测试脚本
|
||||
从数据库加载聊天记录,测试 AI 回复效果
|
||||
"""
|
||||
import sqlite3
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# 颜色代码
|
||||
COLORS = {
|
||||
'header': '\033[95m\033[1m',
|
||||
'customer': '\033[94m',
|
||||
'agent': '\033[92m',
|
||||
'system': '\033[90m',
|
||||
'price': '\033[93m',
|
||||
'error': '\033[91m',
|
||||
'cyan': '\033[96m',
|
||||
'reset': '\033[0m',
|
||||
}
|
||||
|
||||
# Windows PowerShell defaults to GBK in some environments.
|
||||
# Make stdout/stderr robust for Unicode logs used by this test script.
|
||||
for stream_name in ("stdout", "stderr"):
|
||||
stream = getattr(sys, stream_name, None)
|
||||
if stream and hasattr(stream, "reconfigure"):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure project root is importable when running as `uv run tests/test_ai_chat.py`.
|
||||
PROJECT_ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
DB_PATH = Path(PROJECT_ROOT) / "db" / "chat_log_db" / "chats.db"
|
||||
|
||||
def cprint(text, color='reset'):
|
||||
print(f"{COLORS.get(color, '')}{text}{COLORS['reset']}")
|
||||
|
||||
def check_database():
|
||||
"""检查数据库内容"""
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM chat_logs")
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
if count == 0:
|
||||
cprint(f"\n✗ 数据库为空,没有聊天记录", 'error')
|
||||
cprint("提示:需要先有一些聊天记录才能测试", 'system')
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
cprint(f"\n✓ 数据库连接成功!共 {count} 条聊天记录", 'system')
|
||||
|
||||
# 获取客户列表
|
||||
cursor = conn.execute("""
|
||||
SELECT customer_id, customer_name, COUNT(*) as cnt, MAX(timestamp) as last
|
||||
FROM chat_logs
|
||||
GROUP BY customer_id
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
customers = cursor.fetchall()
|
||||
|
||||
cprint(f"\n找到 {len(customers)} 个客户:", 'cyan')
|
||||
for i, (cid, name, cnt, last) in enumerate(customers, 1):
|
||||
cprint(f" {i:2d}. {name or cid:30s} | {cnt:4d}条 | 最后:{last}", 'customer')
|
||||
|
||||
conn.close()
|
||||
return customers
|
||||
|
||||
except Exception as e:
|
||||
cprint(f"\n✗ 数据库检查失败:{e}", 'error')
|
||||
return None
|
||||
|
||||
async def test_customer_conversation(customer_id, customer_name, limit=5):
|
||||
"""测试某个客户的对话"""
|
||||
cprint(f"\n{'='*70}", 'cyan')
|
||||
cprint(f"测试客户:{customer_name or customer_id}", 'header')
|
||||
cprint(f"{'='*70}\n", 'cyan')
|
||||
|
||||
# 获取对话记录
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.execute("""
|
||||
SELECT direction, message, timestamp
|
||||
FROM chat_logs
|
||||
WHERE customer_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?
|
||||
""", (customer_id, limit))
|
||||
conversations = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not conversations:
|
||||
cprint(" 该客户没有对话记录", 'system')
|
||||
return
|
||||
|
||||
# 初始化 AI Agent
|
||||
try:
|
||||
from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage
|
||||
agent = CustomerServiceAgent(skills_dir="skills")
|
||||
cprint("✓ AI Agent 已加载", 'system')
|
||||
except Exception as e:
|
||||
cprint(f"✗ AI Agent 加载失败:{e}", 'error')
|
||||
return
|
||||
|
||||
# 模拟对话
|
||||
for i, (direction, message, timestamp) in enumerate(conversations, 1):
|
||||
if direction == 'in':
|
||||
# 客户消息
|
||||
cprint(f"\n【消息 {i}/{len(conversations)}】{timestamp}", 'system')
|
||||
cprint(f"客户:{message}", 'customer')
|
||||
|
||||
# 创建测试消息
|
||||
test_msg = CustomerMessage(
|
||||
msg_id=f"test_{i}",
|
||||
acc_id="test_shop",
|
||||
msg=message,
|
||||
from_id=customer_id,
|
||||
from_name=customer_name or "测试",
|
||||
cy_id=customer_id,
|
||||
acc_type="AliWorkbench",
|
||||
msg_type=0,
|
||||
cy_name=customer_name or "测试",
|
||||
goods_name="专业找图",
|
||||
goods_order=""
|
||||
)
|
||||
|
||||
# 获取 AI 回复
|
||||
start = datetime.now()
|
||||
try:
|
||||
response = await agent.process_message(test_msg)
|
||||
elapsed = (datetime.now() - start).total_seconds() * 1000
|
||||
|
||||
if response.should_reply:
|
||||
cprint(f"AI [{elapsed:.0f}ms]: {response.reply}", 'agent')
|
||||
|
||||
# 检测特殊内容
|
||||
if any(kw in response.reply for kw in ['元', '块', '价格']):
|
||||
cprint(" ↳ [价格信息]", 'price')
|
||||
if response.need_transfer:
|
||||
cprint(" ↳ [转人工]", 'error')
|
||||
else:
|
||||
cprint("[AI 静默]", 'system')
|
||||
|
||||
except Exception as e:
|
||||
cprint(f"✗ AI 回复失败:{e}", 'error')
|
||||
|
||||
elif direction == 'out':
|
||||
cprint(f"\n[历史回复] {timestamp}", 'system')
|
||||
cprint(f"客服:{message}", 'system')
|
||||
|
||||
cprint(f"\n{'='*70}", 'cyan')
|
||||
|
||||
async def test_all_customers(customers, limit_per_customer=5):
|
||||
"""批量测试所有客户"""
|
||||
cprint(f"\n{'='*70}", 'header')
|
||||
cprint(f" 开始批量测试 {len(customers)} 个客户", 'header')
|
||||
cprint(f" 每个客户测试前 {limit_per_customer} 条消息", 'header')
|
||||
cprint(f"{'='*70}\n", 'header')
|
||||
|
||||
total_msgs = 0
|
||||
total_replies = 0
|
||||
|
||||
for i, (cid, name, cnt, _) in enumerate(customers, 1):
|
||||
cprint(f"\n\n{'='*70}", 'cyan')
|
||||
cprint(f"进度:{i}/{len(customers)} - {name or cid} ({cnt}条消息)", 'cyan')
|
||||
cprint(f"{'='*70}", 'cyan')
|
||||
|
||||
if cnt == 0:
|
||||
cprint(" 跳过(无消息记录)", 'system')
|
||||
continue
|
||||
|
||||
# 获取对话记录
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.execute("""
|
||||
SELECT direction, message, timestamp
|
||||
FROM chat_logs
|
||||
WHERE customer_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?
|
||||
""", (cid, limit_per_customer))
|
||||
conversations = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
# 初始化 AI Agent(只初始化一次)
|
||||
try:
|
||||
from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage
|
||||
if i == 1: # 第一个客户时初始化
|
||||
agent = CustomerServiceAgent(skills_dir="skills")
|
||||
cprint("✓ AI Agent 已加载", 'system')
|
||||
except Exception as e:
|
||||
cprint(f"✗ AI Agent 加载失败:{e}", 'error')
|
||||
return
|
||||
|
||||
# 模拟对话
|
||||
for j, (direction, message, timestamp) in enumerate(conversations, 1):
|
||||
if direction == 'in':
|
||||
total_msgs += 1
|
||||
|
||||
# 创建测试消息
|
||||
test_msg = CustomerMessage(
|
||||
msg_id=f"test_{i}_{j}",
|
||||
acc_id="test_shop",
|
||||
msg=message,
|
||||
from_id=cid,
|
||||
from_name=name or "测试",
|
||||
cy_id=cid,
|
||||
acc_type="AliWorkbench",
|
||||
msg_type=0,
|
||||
cy_name=name or "测试",
|
||||
goods_name="专业找图",
|
||||
goods_order=""
|
||||
)
|
||||
|
||||
# 获取 AI 回复
|
||||
start = datetime.now()
|
||||
try:
|
||||
response = await agent.process_message(test_msg)
|
||||
elapsed = (datetime.now() - start).total_seconds() * 1000
|
||||
|
||||
if response.should_reply:
|
||||
total_replies += 1
|
||||
cprint(f"\n[{i}/{len(customers)}] {name or cid} - 消息 {j}", 'system')
|
||||
cprint(f"客户:{message}", 'customer')
|
||||
cprint(f"AI [{elapsed:.0f}ms]: {response.reply}", 'agent')
|
||||
|
||||
# 检测特殊内容
|
||||
if any(kw in response.reply for kw in ['元', '块', '价格']):
|
||||
cprint(" ↳ [价格信息]", 'price')
|
||||
if response.need_transfer:
|
||||
cprint(" ↳ [转人工]", 'error')
|
||||
else:
|
||||
cprint(f"\n[{i}/{len(customers)}] [AI 静默]", 'system')
|
||||
|
||||
except Exception as e:
|
||||
cprint(f"✗ AI 回复失败:{e}", 'error')
|
||||
|
||||
# 每个客户之间休息一下
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 统计结果
|
||||
cprint(f"\n\n{'='*70}", 'header')
|
||||
cprint(f" 批量测试完成!", 'header')
|
||||
cprint(f"{'='*70}", 'header')
|
||||
cprint(f"\n统计:", 'system')
|
||||
cprint(f" 测试客户数:{len(customers)}", 'cyan')
|
||||
cprint(f" 处理消息数:{total_msgs}", 'cyan')
|
||||
cprint(f" AI 回复数:{total_replies}", 'cyan')
|
||||
if total_msgs > 0:
|
||||
reply_rate = (total_replies / total_msgs) * 100
|
||||
cprint(f" 回复率:{reply_rate:.1f}%", 'cyan')
|
||||
|
||||
async def main():
|
||||
cprint("="*70, 'header')
|
||||
cprint(" AI Agent 对话测试", 'header')
|
||||
cprint(" 从数据库加载聊天记录,测试 AI 回复效果", 'header')
|
||||
cprint("="*70, 'header')
|
||||
|
||||
# 检查数据库
|
||||
customers = check_database()
|
||||
if not customers:
|
||||
return
|
||||
|
||||
# 选择测试模式
|
||||
cprint(f"\n请选择测试模式:", 'cyan')
|
||||
cprint(f" 1. 交互式测试 (手动选择客户)", 'customer')
|
||||
cprint(f" 2. 批量测试所有客户 (自动)", 'agent')
|
||||
cprint(f" 3. 快速测试前 5 个客户", 'price')
|
||||
cprint(f" q. 退出", 'system')
|
||||
|
||||
mode = input("\n选择:").strip().lower()
|
||||
|
||||
if mode == 'q':
|
||||
cprint("\n测试结束!", 'system')
|
||||
return
|
||||
|
||||
try:
|
||||
if mode == '1':
|
||||
# 交互式测试
|
||||
cprint(f"\n请输入客户编号 (1-{len(customers)}) 进行测试:", 'cyan')
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("\n选择:").strip()
|
||||
|
||||
if choice.lower() == 'q':
|
||||
cprint("\n测试结束!", 'system')
|
||||
return
|
||||
|
||||
choice_num = int(choice)
|
||||
if 1 <= choice_num <= len(customers):
|
||||
cid, name, cnt, _ = customers[choice_num - 1]
|
||||
await test_customer_conversation(cid, name or cid, limit=min(cnt, 10))
|
||||
else:
|
||||
cprint(f"请输入 1-{len(customers)} 之间的数字", 'error')
|
||||
|
||||
except ValueError:
|
||||
cprint("请输入有效数字或 q 退出", 'error')
|
||||
except KeyboardInterrupt:
|
||||
cprint("\n\n测试中断", 'error')
|
||||
return
|
||||
except Exception as e:
|
||||
cprint(f"错误:{e}", 'error')
|
||||
|
||||
elif mode == '2':
|
||||
# 批量测试所有客户
|
||||
await test_all_customers(customers, limit_per_customer=5)
|
||||
|
||||
elif mode == '3':
|
||||
# 快速测试前 5 个客户
|
||||
top_5 = customers[:5]
|
||||
cprint(f"\n快速测试前 5 个客户...", 'cyan')
|
||||
await test_all_customers(top_5, limit_per_customer=5)
|
||||
|
||||
else:
|
||||
cprint("无效的选择", 'error')
|
||||
|
||||
except KeyboardInterrupt:
|
||||
cprint("\n\n测试中断", 'error')
|
||||
except Exception as e:
|
||||
cprint(f"错误:{e}", 'error')
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except Exception as e:
|
||||
cprint(f"\n程序异常:{e}", 'error')
|
||||
@@ -1,89 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from core.pydantic_ai_agent import CustomerMessage, CustomerServiceAgent
|
||||
|
||||
|
||||
class BatchQuoteReplyFormatTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_batch_reply_contains_per_image_and_options(self):
|
||||
agent = CustomerServiceAgent()
|
||||
cid = "__batch_quote_case__"
|
||||
st = agent._get_conversation_state(cid)
|
||||
st.pending_image_urls = ["https://img.alicdn.com/a.jpg", "https://img.alicdn.com/b.jpg"]
|
||||
st.pending_requirements = ["去背景", "加急"]
|
||||
|
||||
msg = CustomerMessage(
|
||||
msg_id="m-batch-1",
|
||||
acc_id="test_shop",
|
||||
msg="发完了,统一报价",
|
||||
from_id=cid,
|
||||
from_name="t",
|
||||
cy_id=cid,
|
||||
acc_type="AliWorkbench",
|
||||
msg_type=0,
|
||||
cy_name="t",
|
||||
goods_name="专业找图",
|
||||
goods_order="",
|
||||
)
|
||||
|
||||
fake_r1 = {
|
||||
"complexity": "normal",
|
||||
"reason": "常规处理",
|
||||
"price_min": 15,
|
||||
"price_max": 25,
|
||||
"price_suggest": 20,
|
||||
"feasibility": "yes",
|
||||
"risk": "low",
|
||||
"aspect_ratio": "1:1",
|
||||
"perspective": "no",
|
||||
}
|
||||
fake_r2 = {
|
||||
"complexity": "complex",
|
||||
"reason": "细节较多",
|
||||
"price_min": 20,
|
||||
"price_max": 30,
|
||||
"price_suggest": 25,
|
||||
"feasibility": "yes",
|
||||
"risk": "low",
|
||||
"aspect_ratio": "1:1",
|
||||
"perspective": "no",
|
||||
}
|
||||
|
||||
with patch("image.image_analyzer.image_analyzer.analyze", new=AsyncMock(side_effect=[fake_r1, fake_r2])):
|
||||
with patch("core.workflow.workflow.image_analysis_result", new=AsyncMock(return_value=None)):
|
||||
res = await agent._quote_pending_images(st, msg)
|
||||
|
||||
self.assertFalse(res.get("need_transfer", False))
|
||||
reply = res.get("reply", "")
|
||||
self.assertIn("图1", reply)
|
||||
self.assertIn("图2", reply)
|
||||
self.assertIn("可选", reply)
|
||||
self.assertIn("打包", reply)
|
||||
self.assertIn("共", reply)
|
||||
|
||||
async def test_single_image_reply_avoids_batch_wording(self):
|
||||
agent = CustomerServiceAgent()
|
||||
results = [
|
||||
(
|
||||
"https://img.alicdn.com/a.jpg",
|
||||
{
|
||||
"complexity": "normal",
|
||||
"reason": "常规处理",
|
||||
"price_suggest": 20,
|
||||
},
|
||||
)
|
||||
]
|
||||
reply = agent._build_batch_quote_reply(
|
||||
results=results,
|
||||
total_suggest=20,
|
||||
bundle_price=20,
|
||||
req_fee={"extra": 0, "hits": []},
|
||||
)
|
||||
self.assertIn("这张", reply)
|
||||
self.assertNotIn("这批", reply)
|
||||
self.assertNotIn("先给你分图报下", reply)
|
||||
self.assertNotIn("可选:A", reply)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,54 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from evolution.mvp import Finding, Sample, can_publish_candidate, evaluate_samples
|
||||
|
||||
|
||||
class EvolutionMvpTest(unittest.TestCase):
|
||||
def test_evaluate_detects_risk_without_transfer(self):
|
||||
samples = [
|
||||
Sample(
|
||||
customer_id="c1",
|
||||
acc_id="shop",
|
||||
in_ts="2026-02-28 10:00:00",
|
||||
in_text="我要投诉并退款,你们骗人",
|
||||
out_ts="2026-02-28 10:00:10",
|
||||
out_text="这个我不清楚,稍后再说",
|
||||
latency_sec=10,
|
||||
)
|
||||
]
|
||||
findings = evaluate_samples(samples)
|
||||
kinds = {f.kind for f in findings}
|
||||
self.assertIn("risk_not_transferred", kinds)
|
||||
self.assertIn("weak_reply", kinds)
|
||||
|
||||
def test_publish_gate(self):
|
||||
samples = [
|
||||
Sample(
|
||||
customer_id=f"c{i}",
|
||||
acc_id="shop",
|
||||
in_ts="2026-02-28 10:00:00",
|
||||
in_text="你好",
|
||||
out_ts="2026-02-28 10:00:05",
|
||||
out_text="您好",
|
||||
latency_sec=5,
|
||||
)
|
||||
for i in range(35)
|
||||
]
|
||||
findings: list[Finding] = []
|
||||
policy = {
|
||||
"publish_gate": {
|
||||
"min_sample_count": 30,
|
||||
"max_high_findings_rate": 0.1,
|
||||
"max_ai_fail_rate": 5.0,
|
||||
"max_transfer_rate": 45.0,
|
||||
}
|
||||
}
|
||||
with patch("utils.metrics_tracker.get_runtime_summary", return_value={"rates": {"ai_fail_rate": 1.0, "transfer_rate": 10.0}}):
|
||||
ok, report = can_publish_candidate(samples, findings, runtime_hours=24, policy=policy)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(report["sample_count"], 35)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,24 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from utils.intent_analyzer import detect_intent
|
||||
|
||||
|
||||
class IntentAnalyzerTests(unittest.TestCase):
|
||||
def test_keyword_fallback_for_price(self):
|
||||
d = detect_intent("这个怎么收费")
|
||||
self.assertEqual(d.intent, "询价")
|
||||
self.assertEqual(d.source, "keyword")
|
||||
|
||||
def test_keyword_fallback_for_greeting(self):
|
||||
d = detect_intent("你好 在吗")
|
||||
self.assertEqual(d.intent, "打招呼")
|
||||
self.assertEqual(d.source, "keyword")
|
||||
|
||||
def test_unknown_intent(self):
|
||||
d = detect_intent("abc123")
|
||||
self.assertEqual(d.intent, "")
|
||||
self.assertIn(d.source, ("none", ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,26 +0,0 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from core.websocket_client_v2 import QingjianAPIClient
|
||||
|
||||
|
||||
class MultiWorkerRoutingTest(unittest.TestCase):
|
||||
def test_only_one_worker_owns_customer_when_no_explicit_shards(self):
|
||||
os.environ["AI_CS_WORKER_COUNT"] = "4"
|
||||
key = "shop_x:tb123456"
|
||||
owners = 0
|
||||
for wid in range(4):
|
||||
os.environ["AI_CS_WORKER_ID"] = str(wid)
|
||||
c = QingjianAPIClient(enable_agent=False)
|
||||
c.shard_keys = set() # 模拟当前无分片表
|
||||
if c._is_owned_by_this_worker(key):
|
||||
owners += 1
|
||||
self.assertEqual(owners, 1)
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("AI_CS_WORKER_COUNT", None)
|
||||
os.environ.pop("AI_CS_WORKER_ID", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,40 +0,0 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from websockets.protocol import State
|
||||
|
||||
from core.websocket_client_v2 import QingjianAPIClient
|
||||
|
||||
|
||||
class _DummyWS:
|
||||
def __init__(self):
|
||||
self.state = State.OPEN
|
||||
self.sent = []
|
||||
|
||||
async def send(self, msg_json: str):
|
||||
self.sent.append(msg_json)
|
||||
|
||||
|
||||
class OutboundCooldownTest(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
os.environ["OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS"] = "5"
|
||||
|
||||
async def test_skip_second_reply_within_cooldown(self):
|
||||
c = QingjianAPIClient(enable_agent=False)
|
||||
c.websocket = _DummyWS()
|
||||
msg = {
|
||||
"acc_id": "shop_a",
|
||||
"from_id": "u001",
|
||||
"from_name": "u001",
|
||||
"acc_type": "AliWorkbench",
|
||||
}
|
||||
await c.send_reply(msg, "第一条")
|
||||
await c.send_reply(msg, "第二条")
|
||||
self.assertEqual(len(c.websocket.sent), 1)
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,34 +0,0 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from core.websocket_client_v2 import QingjianAPIClient
|
||||
|
||||
|
||||
class OversizeGuardTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
os.environ["MAX_SERVICE_SIZE_LONGEST_METERS"] = "10"
|
||||
os.environ["MAX_SERVICE_SIZE_AREA_SQM"] = "20"
|
||||
|
||||
def test_extract_size_pairs(self):
|
||||
c = QingjianAPIClient(enable_agent=False)
|
||||
pairs = c._extract_size_pairs_m("15*6.4米 高度")
|
||||
self.assertTrue(len(pairs) >= 1)
|
||||
self.assertEqual(pairs[0], (15.0, 6.4))
|
||||
|
||||
def test_oversize_hits(self):
|
||||
c = QingjianAPIClient(enable_agent=False)
|
||||
r = c._oversize_reply_if_needed("15*6.4米")
|
||||
self.assertIn("做不了", r)
|
||||
|
||||
def test_normal_size_not_hit(self):
|
||||
c = QingjianAPIClient(enable_agent=False)
|
||||
r = c._oversize_reply_if_needed("2.4*1.2米")
|
||||
self.assertEqual(r, "")
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop("MAX_SERVICE_SIZE_LONGEST_METERS", None)
|
||||
os.environ.pop("MAX_SERVICE_SIZE_AREA_SQM", None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,70 +0,0 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from core.websocket_client_v2 import QingjianAPIClient
|
||||
|
||||
|
||||
class SystemInquiryRulesTest(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.rules = {
|
||||
"enabled": True,
|
||||
"default_action": "silent",
|
||||
"default_reply": "已收到",
|
||||
"sender_keywords": ["系统客服", "官方客服"],
|
||||
"message_keywords": ["系统询单", "代客咨询"],
|
||||
"shops": {
|
||||
"shop_reply": {
|
||||
"enabled": True,
|
||||
"action": "reply",
|
||||
"reply": "店铺回复模板",
|
||||
"sender_keywords": ["机器人客服"],
|
||||
"message_keywords": ["询单"],
|
||||
}
|
||||
},
|
||||
}
|
||||
os.environ["SYSTEM_INQUIRY_ENABLED"] = "true"
|
||||
os.environ["SYSTEM_INQUIRY_SHOPS"] = ""
|
||||
|
||||
async def test_detect_by_sender_keyword(self):
|
||||
client = QingjianAPIClient(enable_agent=False)
|
||||
client._system_inquiry_rules = self.rules
|
||||
policy = client._resolve_system_inquiry_policy("shop_a")
|
||||
data = {"acc_id": "shop_a", "from_name": "平台系统客服", "from_id": "kefu001", "msg": "你好"}
|
||||
self.assertTrue(client._match_system_inquiry(data, policy))
|
||||
|
||||
async def test_shop_rule_reply_action(self):
|
||||
client = QingjianAPIClient(enable_agent=False)
|
||||
client._system_inquiry_rules = self.rules
|
||||
client.send_reply = AsyncMock()
|
||||
client.transfer_to_human = AsyncMock()
|
||||
|
||||
data = {
|
||||
"acc_id": "shop_reply",
|
||||
"from_name": "机器人客服A",
|
||||
"from_id": "robot_01",
|
||||
"msg": "有个询单请处理",
|
||||
"acc_type": "AliWorkbench",
|
||||
}
|
||||
handled = await client._handle_system_inquiry(data)
|
||||
self.assertTrue(handled)
|
||||
client.send_reply.assert_awaited_once()
|
||||
client.transfer_to_human.assert_not_awaited()
|
||||
|
||||
async def test_shop_whitelist_blocks_other_shops(self):
|
||||
os.environ["SYSTEM_INQUIRY_SHOPS"] = "shop_only"
|
||||
client = QingjianAPIClient(enable_agent=False)
|
||||
client._system_inquiry_rules = self.rules
|
||||
client.send_reply = AsyncMock()
|
||||
data = {"acc_id": "shop_other", "from_name": "系统客服", "from_id": "sys_1", "msg": "系统询单"}
|
||||
handled = await client._handle_system_inquiry(data)
|
||||
self.assertFalse(handled)
|
||||
client.send_reply.assert_not_awaited()
|
||||
|
||||
def tearDown(self):
|
||||
for k in ("SYSTEM_INQUIRY_ENABLED", "SYSTEM_INQUIRY_SHOPS"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,20 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from core.websocket_client_v2 import QingjianAPIClient
|
||||
|
||||
|
||||
class TransferGreetingContextTest(unittest.TestCase):
|
||||
def test_transfer_greeting_is_non_empty(self):
|
||||
c = QingjianAPIClient(enable_agent=False)
|
||||
text = c._pick_transfer_greeting()
|
||||
self.assertTrue(isinstance(text, str) and len(text) > 0)
|
||||
|
||||
def test_transfer_greeting_contains_presence_phrase(self):
|
||||
c = QingjianAPIClient(enable_agent=False)
|
||||
for _ in range(10):
|
||||
text = c._pick_transfer_greeting()
|
||||
self.assertTrue(("在" in text) or ("我在" in text))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Binary file not shown.
Reference in New Issue
Block a user