Compare commits

...

42 Commits

Author SHA1 Message Date
ab173d2d0f fix: transfer on delivery handoff requests 2026-03-15 16:05:45 +08:00
311124bc9b fix: transfer on file handoff messages 2026-03-15 15:54:33 +08:00
d191ad8eac fix: alert wecom on brain fallback 2026-03-14 15:33:49 +08:00
87f9e8724d fix: add designer work schedule guidance 2026-03-14 08:37:24 +08:00
1b136d17ad fix: handle designer schedule questions 2026-03-14 08:35:06 +08:00
5b36693c2e feat: alert wecom when no designer is available 2026-03-13 10:42:14 +08:00
5a38fa9e6c docs: add recent update log 2026-03-12 15:52:50 +08:00
71d3f713c9 fix: ignore malformed image urls from card payloads 2026-03-12 15:32:08 +08:00
823f5eac76 fix: transfer when customer asks for payment link 2026-03-12 15:28:05 +08:00
f3e8ea16c6 fix: only greet on first message in a session 2026-03-11 21:22:10 +08:00
3d1d955256 fix: send immediate greeting for each inbound message 2026-03-11 18:49:16 +08:00
8a67c25887 feat: improve first-turn and delayed-image replies 2026-03-11 18:42:18 +08:00
ebca1eaff6 fix: block leaked history summaries in replies 2026-03-11 18:33:17 +08:00
2c003e9a7d fix: clean generated tuhui titles 2026-03-10 15:48:27 +08:00
3f45a4badd fix: randomize tuhui designer alias 2026-03-10 14:35:31 +08:00
c399b8cfc1 fix: anonymize tuhui designer and clean titles 2026-03-10 14:22:23 +08:00
a082364e34 fix: simplify auto process titles and notices 2026-03-10 14:20:26 +08:00
7aa2dff569 fix: normalize tuhui asset urls 2026-03-10 13:40:54 +08:00
64571f4544 chore: switch tuhui defaults to new domain 2026-03-10 13:05:36 +08:00
e0c9f46162 feat: derive tuhui title from image analysis 2026-03-09 16:07:06 +08:00
ba5644371f feat: include processed image url in wecom notice 2026-03-09 15:50:27 +08:00
5fcce98583 fix: normalize animated images before gemini 2026-03-09 14:57:41 +08:00
a2119f3b6d fix: harden outbound leak guard and title naming 2026-03-09 14:34:04 +08:00
d3b55798e5 fix: normalize image formats before gemini 2026-03-09 11:27:14 +08:00
23c2f37a67 fix: use resolved download path for gemini input 2026-03-09 11:04:17 +08:00
bcd162ef22 fix: harden alicdn image downloads 2026-03-09 10:51:12 +08:00
2ab27eb914 fix: streamline gemini flow and add e2e test 2026-03-08 23:58:17 +08:00
82284ce3fb feat: automate image pipeline and simplify gemini flow 2026-03-08 23:42:18 +08:00
3a78eb304a feat: improve routing logs and tuhui integration 2026-03-08 17:34:56 +08:00
39de916b89 fix: retry stalled transfers on follow-up messages 2026-03-08 17:33:51 +08:00
fddd879ba0 fix: harden image handling and update docs 2026-03-08 13:20:18 +08:00
2e3409d8c5 feat: queue pending transfers until designers are available 2026-03-08 12:43:40 +08:00
5a5bde1ba5 fix: block leaked history content before outbound send 2026-03-08 12:36:57 +08:00
613d375845 fix: reduce mysql connection pressure 2026-03-08 12:29:49 +08:00
54231cbd5c newtw66 2026-03-08 11:54:39 +08:00
3c52061861 fix: block leaked tool output and thinking text 2026-03-06 21:58:50 +08:00
07053ce1ad newtw5 2026-03-06 15:06:06 +08:00
8460d00379 newtw4 2026-03-06 14:42:23 +08:00
3020ae4691 newtw3 2026-03-06 14:39:42 +08:00
f06bfb1fa0 newtw3 2026-03-06 14:25:10 +08:00
afb2b78c15 newtw2 2026-03-06 13:23:32 +08:00
4ba636e98c chore: remove cached pyc files from git tracking
Made-with: Cursor
2026-03-06 12:49:20 +08:00
59 changed files with 3516 additions and 2322 deletions

View File

@@ -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.py802 行)
**问题**`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.0API 不兼容。
**修复**:已改为 `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.py802 行)→ 领域拆分
- **P1 #8** 下载函数重复实现4 处)→ 抽取公共模块
- **P2 #10** TODO/FIXME 残留7 处)→ 实现或移入 issue tracker

191
README.md
View File

@@ -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
├── config/ # 配置文件
├── db/ # 数据库模块
├── image/ # 图片处理模块
├── services/ # 外部服务集成
├── utils/ # 工具模块
├── skills/ # Agent 技能定义
└── run.py # 统一入口(--api-only / --tianwang / 默认 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/ # 配置文件
├── 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 三层都会做防泄露清洗;如果线上仍出现历史摘要外发,优先确认服务是否已经重启到最新代码。

View 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`
## 备注
- 当前文档只记录“已经提交进仓库”的更新。
- 未提交的本地修改不在本记录中。
- 如果后面还要继续追加,可以直接在这个文件后面按日期补充。

View File

@@ -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()

View File

@@ -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

View File

@@ -1,39 +1,189 @@
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:
"""
【核心工具】执行转人工逻辑。
获取设计师姓名并生成精准转接指令。
"""
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
else:
# 3. 设计师下线:返回特定信号
logger.warning("[Tool] 派单失败:设计师们已下线或不在位")
return "ERROR_NO_DESIGNER_ONLINE"
async def check_order_status_tool(ctx: RunContext[Any], customer_id: str = Field(description="客户ID")) -> str:
"""查询订单状态。"""
return "设计师正在后台加急处理中,请稍等哈。"
designer_name = await dispatch_service.assign_designer()
if designer_name:
magic_cmd = f"正在为您转接|[转移会话],{designer_name},无原因"
logger.info(f"[Tool] 成功呼叫设计师: {designer_name},立即触发转接")
# 抛出异常以提前终止 AI 后续处理,节省等待时间
raise TransferSuccessException(magic_cmd)
else:
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}"
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] 工具箱已更新:含转人工、历史记录查询、订单查询。")

View File

@@ -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]}")
# 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}
)
content = (msg.content or "")
logger.info(f"[Engine] 收到来自 {msg.platform} 的消息: {msg.user_id} -> {content[:50]}")
# 兜底回复
return StandardResponse(
reply_content="你好我是AI助手有什么可以帮你的",
reply_content="稍等哈,设计师马上来。",
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
)

View File

@@ -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()

View File

@@ -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,7 +417,26 @@ class SystemOrchestrator:
if std_msg.msg_id in self._processed_msg_ids: return
self._processed_msg_ids.append(std_msg.msg_id)
# 进入防抖(使用 session_key 隔离不同店铺)
# 同一轮对话只在第一句先发固定承接,不经过 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] = []
self._pending_messages[session_key].append(std_msg)
@@ -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
# 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]
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)
# 只在“明确又要转接”时注入冷却提示,普通问候/新需求不注入
transfer_intent = self._has_transfer_intent(combined_content)
if is_in_cooldown and transfer_intent:
final_msg.content = f"【系统:当前已向设计师发出转接请求,请勿再次调用转接工具】\n{final_msg.content}"
# 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,
)
std_res = await self.brain.think_and_reply(final_msg, history=history)
# 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
# E. 发送并记录时间
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}
)
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):

View File

@@ -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 大脑:
@@ -54,94 +372,255 @@ class CustomerServiceBrain:
# --- 统一口径后的 System Prompt ---
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}"
)
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)}"
)
result = await self.agent.run(full_input, message_history=history)
# --- 终极修复:强制截获工具返回的转接指令 ---
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
full_input = f"【当前客户ID{msg.user_id}\n{recent_context}现在的对话:{user_content}"
start_time = time.time()
# 暴力扫描所有消息片段,寻找转接暗号
found_magic = ""
# ===== 详细日志:发给 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}
)
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:
found_magic = content
# 如果 AI 弄丢了暗号,我们强行给它补回来
if found_magic and "[转移会话]" not in reply_text:
logger.info(f"[Brain] 检测到 AI 弄丢了转接暗号,正在强制恢复: {found_magic[:30]}...")
reply_text = found_magic
# ----------------------------------------
transfer_cmd = content
# 清理可能的乱码/代码标记
import re
reply_text = re.sub(r'\[\]<\|[^|]+\|>', '', reply_text) # 清理 []<|xxx|>
reply_text = re.sub(r'<\|[^|]+\|>', '', reply_text) # 清理 <|xxx|>
reply_text = re.sub(r'\[Function[^\]]*\]', '', reply_text) # 清理 [FunctionXxx]
reply_text = re.sub(r'<think[^>]*>.*', '', reply_text, flags=re.DOTALL) # 清理 <think_xxx>内部思考泄漏
reply_text = re.sub(r'</?think[^>]*>', '', reply_text) # 清理 think 标签
reply_text = re.sub(r'```[^`]*```', '', reply_text) # 清理代码块
reply_text = re.sub(r'\{["\'][^}]+\}', '', reply_text) # 清理 JSON
reply_text = reply_text.strip()
if 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 = ""
raw_output = getattr(result, 'output', None) or getattr(result, 'data', None)
if isinstance(raw_output, str):
reply_text = raw_output
# 清理模型泄露的内部标记/工具原文
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})

View File

@@ -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", ""),
}
)

View File

@@ -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) # 额外元数据(如埋点、调试信息)

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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", ""),
)

View File

@@ -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}"

View File

@@ -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,7 +136,11 @@ class _PyMySQLCompatConn:
self._conn.rollback()
except Exception:
pass
self._conn.close()
# 归还连接到池而不是关闭
if self._use_pool:
_return_conn(self._conn)
else:
self._conn.close()
def execute(self, query: str, args=None):
cur = self._conn.cursor()
@@ -59,7 +154,10 @@ class _PyMySQLCompatConn:
self._conn.commit()
def close(self):
self._conn.close()
if self._use_pool:
_return_conn(self._conn)
else:
self._conn.close()
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
@@ -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.

View File

@@ -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,24 +295,41 @@ 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():
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
REPLACE INTO customer_profiles (customer_id, profile_json, last_update)
VALUES (%s, %s, %s)
""",
(
profile.customer_id,
json.dumps(asdict(profile), ensure_ascii=False),
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
),
)
conn.commit()
return
last_error = None
for attempt in range(max_retries):
try:
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
REPLACE INTO customer_profiles (customer_id, profile_json, last_update)
VALUES (%s, %s, %s)
""",
(
profile.customer_id,
json.dumps(asdict(profile), ensure_ascii=False),
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
),
)
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
View 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

View File

@@ -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.

View File

@@ -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 决定不回复此消息

View File

@@ -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 回复: 没事,想要了直接拍下就行,不满意包退哈。

View File

@@ -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
View File

@@ -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("收到退出信号,正在停止多进程协调器...")

View File

@@ -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__)

View 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())

View 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())

View File

@@ -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:

View 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()

View 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()

View File

@@ -1,106 +1,115 @@
#!/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配置按优先级排序便宜的优先使用
API_CONFIGS = [
API_BASE_URL = "https://api.laozhang.ai/v1beta/models"
DEFAULT_PROMPT = (
"提取印花图案,把褶皱移除。补齐缺失的部分,要生成完整,细节丰富,"
"严格按照原图的元素位置生成平面的印花图不要相似的相似度要100%,生成高质量的印刷图"
)
# {
# "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
def image_to_base64(self, image_path: str) -> str:
"""将图片文件转换为base64编码字符串"""
super().__init__(name=self.SERVICE_NAME)
@staticmethod
def _image_to_base64(image_path: str) -> str:
if not os.path.exists(image_path):
logger.error(f"文件不存在: {image_path}")
return ""
try:
if not os.path.exists(image_path):
logger.error(f"文件不存在: {image_path}")
return None
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,
input_path: str,
@@ -111,415 +120,85 @@ 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']}"
headers = {
"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 = {
"contents": [
{
"role": "user",
"parts": [
{
"inlineData": {
"mimeType": "image/jpeg",
"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"
}
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}次)...")
# 发送异步请求
timeout = aiohttp.ClientTimeout(total=300, connect=30)
connector = aiohttp.TCPConnector(limit=10, limit_per_host=5)
try:
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
async with session.post(api_url, headers=headers, json=data) 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)
continue
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", ""))
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)
continue
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
prompt = str(custom_prompt or self.DEFAULT_PROMPT).strip()
api_url = f"{self.API_BASE_URL}/{GEMINI_IMAGE_MODEL}:generateContent"
headers = {
"Authorization": f"Bearer {GEMINI_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"contents": [
{
"parts": [
{"inlineData": {"mimeType": self._guess_mime_type(input_path), "data": img64}},
{"text": prompt},
]
}
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):
],
"generationConfig": self._build_generation_config(
aspect_ratio=aspect_ratio,
image_size=image_size,
person_generation=person_generation,
thinking_level=thinking_level,
),
}
metrics_emit("gemini_request", model=GEMINI_IMAGE_MODEL, provider="laozhang_gemini_native")
timeout = aiohttp.ClientTimeout(total=300, connect=30)
for attempt in range(1, 3):
try:
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"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()
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:
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)
from utils.api_cost_tracker import record
record("gemini_extract", count=1)
except Exception:
pass
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}", {}
return False, "Gemini 出图失败", {}
async def correct_perspective(
self,
input_path: str,
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())

View File

@@ -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,

View File

@@ -6,55 +6,159 @@
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"""
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/api/auth/login",
json={
"phone": self.phone,
"password": self.password
},
timeout=10.0
)
if response.status_code == 200:
data = response.json()
self.access_token = data.get("access_token")
user = data.get("user", {})
self.user_id = user.get("id")
logger.info(f"图绘平台登录成功,用户 ID: {self.user_id}")
return True
else:
logger.error(f"图绘平台登录失败:{response.status_code} {response.text}")
return False
except Exception as e:
logger.error(f"图绘平台登录异常:{e}")
return False
last_error = ""
for base_url in self.base_urls:
try:
async with httpx.AsyncClient() as client:
response = await client.post(
self._build_api_url(base_url, "/auth/login"),
json={
"phone": self.phone,
"password": self.password
},
timeout=10.0
)
if response.status_code == 200:
data = response.json()
self.access_token = data.get("access_token")
user = data.get("user", {})
self.user_id = user.get("id")
self.base_url = base_url
logger.info(f"图绘平台登录成功,用户 ID: {self.user_id}base={self.base_url}")
return True
last_error = f"{response.status_code} {response.text}"
logger.warning(f"图绘平台登录失败base={base_url}{last_error}")
except Exception as e:
last_error = str(e)
logger.warning(f"图绘平台登录异常base={base_url}{type(e).__name__}: {e!r}")
logger.error(f"图绘平台登录失败:{last_error}")
return False
async def upload_image(
self,
@@ -62,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)

View 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()

View File

@@ -26,12 +26,18 @@ description: 找原图/高清修复客服 - 需求收集、阶段引导与转接
## 上下文承接逻辑让AI变聪明的关键
- **回访/二次进店识别(最高优先级)**
- 客户说「之前聊过 / 上次 / 你看聊天记录 / 我发过了 / 前面发了图」 => **必须先调用 lookup_chat_history_tool 查历史**,根据历史回复,严禁再要图。
- 客户问「做好了吗 / 多久能好 / 怎么样了 / 好了吗」 => 这是进度追问,先查历史确认之前的需求,用第一人称回:'我在看哈,稍等'。
- **短句识别**
- 「有吗 / 有没有 / 找到了吗」 => 进度追问。回:'设计师正在看哈,稍等'。
- 「有吗 / 有没有 / 找到了吗」 => 进度追问。回:'在看哈,稍等'。
- 「就这一个 / 没有了 / 就这些」 => 拿图完成。立即引导转接。
- 「高清 / 重新发 / 发我」 => 催办。正面承接。
- **多图关联识别**
- 客户发第二张图时提到「上一张」「前面那张」「局部」「细节」 => 按【同一需求补充】处理,不要当成新单。
- **情绪升级识别**
- 客户连续表达不满(质问、辱骂、威胁投诉)=> 立即转人工,话术:'亲亲抱歉,我马上叫设计师来处理'。
- 严禁在客户愤怒时继续机械重复同一句话。
## 业务红线
- **绝对不说**'客服'、'师傅'、'专员'、'AI做的'、'修复'(如果是找原图单)、'处理'。

View File

@@ -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)

View File

@@ -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')

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)