Compare commits

...

48 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
006b035de4 newtw 2026-03-06 12:44:57 +08:00
fa61b11b02 refactor: migrate workflow to v2 core and archive legacy modules 2026-03-04 21:52:24 +08:00
e1ce17f2aa chore: harden runtime checks and split websocket inbound/outbound flows 2026-03-02 18:17:09 +08:00
89eb94486d fix: add missing websocket_quote_flow module for runtime imports 2026-03-02 16:06:11 +08:00
b5153048c4 refactor: split websocket flows and add brain action decision pipeline 2026-03-02 16:04:33 +08:00
4022ed8f7a feat: add per-shop persona routing for ai replies 2026-03-02 13:02:07 +08:00
183 changed files with 5531 additions and 20113 deletions

View File

@@ -1,20 +0,0 @@
# AI 客服配置
AI_CS_HOST=127.0.0.1
AI_CS_PORT=6060
# AI 客服 HTTP API 地址(本地)
AI_CS_API_URL=http://127.0.0.1:6060
# 天网服务器配置(公网 IP用户自行配置
# TIANWANG_PUBLIC_IP=你的公网 IP
# TIANWANG_PUBLIC_PORT=你的公网端口
# 天网回调地址AI 客服完成任务后回调天网)
TIANWANG_CALLBACK_URL=http://127.0.0.1:6060/api/task/callback
# API 接口
TASK_RECEIVE=/api/task/receive
TASK_STATUS=/api/task/status
TASK_CANCEL=/api/task/cancel
TASK_LIST=/api/task/list
HEALTH=/api/health

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Python cache/artifacts
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
.venv/
venv/
# Tool caches
.ruff_cache/
.pytest_cache/
.uv_cache/
.uv-cache/
.uv-cache-test/
# Runtime artifacts
logs/*.log
config/.runtime_metrics.jsonl
# <20><>ʱ<EFBFBD>
_archive/

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,11 +2,28 @@
"shops": {
"tb2801080146": {
"type": "gemini_api",
"hint": "【店铺类型】Gemini API 账号。客户问账号/pro/续费/没pro时按API客服回复续费/充值/套餐说明。"
"hint": "【店铺类型】Gemini API 账号。客户问账号/pro/续费/没pro时按API客服回复续费/充值/套餐说明。",
"persona": "技术型客服,表达清晰,回答直接,少废话,优先给可执行步骤和结论。"
},
"小威哥1216": {
"type": "find_image",
"hint": "【店铺类型】找原图/修图。"
"hint": "【店铺类型】找原图/修图。",
"persona": "修图老店主语气,接地气,像微信聊天,先承接再推进成交,不端着。"
},
"小威哥1216:媚媚": {
"type": "find_image",
"hint": "【店铺类型】找原图/修图。",
"persona": "女性店主口吻,亲切利落,话短不硬,先确认需求再自然推进下单。"
},
"tb7518056865:小林": {
"type": "find_image",
"hint": "【店铺类型】找原图/修图。",
"persona": "效率型客服,回复简短干脆,先给结论再补一句说明,避免重复。"
},
"tb637530900564:小威威": {
"type": "find_image",
"hint": "【店铺类型】找原图/修图。",
"persona": "熟客店主口吻,轻松自然,像真人在店里接单聊天,避免模板句。"
}
},
"goods_keywords": {
@@ -23,5 +40,10 @@
"gemini_api": "【店铺类型】Gemini API 账号。客户问账号/pro/续费/没pro时按API客服回复续费/充值/套餐说明,自然回复。",
"find_image": "【店铺类型】找原图/修图。"
},
"_comment": "新增店铺:在 shops 加 acc_id。新增商品类型在 goods_keywords 加关键词→类型。"
"type_personas": {
"gemini_api": "技术支持型客服,回答准确直给,先结论后解释,避免口水话。",
"find_image": "淘宝修图店主语气,口语自然,有温度但不啰嗦,先承接再推进成交。"
},
"default_persona": "淘宝老店主,说话自然,像真人微信聊天,不官腔、不背模板。",
"_comment": "新增店铺:在 shops 加 acc_id。可选 persona 设置店铺人设;未设置则走 type_personas/default_persona。"
}

29
core/adapters/base.py Normal file
View File

@@ -0,0 +1,29 @@
from abc import ABC, abstractmethod
from core.schema import StandardMessage, StandardResponse
class BaseAdapter(ABC):
"""
消息适配器基类 (Interface)
所有的平台接口(千牛、微信等)都必须继承并实现这几个方法
"""
@abstractmethod
async def translate_inbound(self, raw_msg: any) -> StandardMessage:
"""
[接收]:把各个平台的原始 JSON 数据,格式化为大脑认的 StandardMessage
"""
pass
@abstractmethod
async def translate_outbound(self, response: StandardResponse, user_id: str):
"""
[发送]:把大脑生成的 StandardResponse翻译回平台原生的接口发出去
"""
pass
@abstractmethod
def platform_id(self) -> str:
"""
标识当前平台名称 (如 'qianniu', 'wechat')
"""
pass

View File

@@ -0,0 +1,208 @@
import re
import logging
import json
from pathlib import Path
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 商家人工)。
"""
def __init__(self, ws_client=None):
self.ws_client = ws_client
self._default_group_id = "20252916034"
def platform_id(self) -> str:
return "qianniu"
def _resolve_group_id(self, acc_id: str) -> str:
try:
config_path = Path("config/transfer_groups.json")
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
return cfg.get(acc_id, self._default_group_id)
except Exception as e:
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]:
"""
返回: (标准消息, 消息方向)
direction: 'in' (客户发给商家), 'out' (商家人工在后台回复)
"""
if not isinstance(raw, dict): raw = {}
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通常说明是商家自己在说话
# 或者逆向接口通常有一个特定的标识,这里我们做一个通用的逻辑判断
direction = "in"
user_id = from_id
# 逻辑:如果发送者 ID 等于 店铺 ID说明是【商家人工回复】
if from_id == acc_id and acc_id != "":
direction = "out"
# 此时 cy_id (客户ID) 通常在另一个字段里
user_id = str(raw.get("cy_id") or "")
msg = StandardMessage(
platform=self.platform_id(),
msg_id=str(raw.get("msg_id", "")),
user_id=user_id,
user_name=str(raw.get("from_name", "")),
content=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
)
return msg, direction
async def translate_outbound(self, res: StandardResponse, user_id: str):
if not self.ws_client: return
if not res or (not res.should_reply and not res.need_transfer): return
meta = res.metadata if isinstance(res.metadata, dict) else {}
acc_id = meta.get("acc_id", "")
acc_type = meta.get("acc_type", "AliWorkbench")
if "[转移会话]" in res.reply_content:
content = res.reply_content
elif res.need_transfer:
group_id = self._resolve_group_id(acc_id)
content = f"正在为您转接|[转移会话],分组{group_id},无原因"
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}"
)
await self.ws_client.send(customer_id=user_id, acc_id=acc_id, acc_type=acc_type, content=content, msg_type=res.msg_type)
except Exception as e:
logger.error(f"[QianniuAdapter] 发送失败: {e}")
def _extract_urls(self, text: str) -> List[str]:
if not text:
return []
image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
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,201 +0,0 @@
from __future__ import annotations
import logging
import random
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from core.rules import Rule, RuleContext, RuleEngine, RuleResult
from services.risk_service import RiskService
if TYPE_CHECKING:
from core.pydantic_ai_agent import (
AgentResponse,
ConversationState,
CustomerMessage,
CustomerServiceAgent,
)
class AgentPreRuleService:
"""Pre-processing rule chain for short replies, cooldown, and text risk."""
def __init__(self, agent: "CustomerServiceAgent", risk_service: RiskService):
self.agent = agent
self.risk_service = risk_service
self.engine = self._build_engine()
async def run(
self,
*,
message: "CustomerMessage",
state: "ConversationState",
trace_id: str,
) -> Optional["AgentResponse"]:
ctx = RuleContext(data={"message": message, "state": state, "trace_id": trace_id})
result = await self.engine.run(ctx)
if not result.stop:
return None
response = result.payload.get("response")
return response
def _build_engine(self) -> RuleEngine:
return RuleEngine(
rules=[
Rule(
name="meaningless_short_text",
priority=10,
predicate=self._rule_pred_meaningless_short_text,
action=self._rule_act_meaningless_short_text,
),
Rule(
name="cooldown_silent",
priority=20,
predicate=self._rule_pred_cooldown_silent,
action=self._rule_act_cooldown_silent,
),
Rule(
name="manual_risk_block",
priority=30,
predicate=self._rule_pred_manual_risk_block,
action=self._rule_act_manual_risk_block,
),
Rule(
name="text_risk_block",
priority=40,
predicate=self._rule_pred_text_risk_block,
action=self._rule_act_text_risk_block,
),
]
)
async def _rule_pred_meaningless_short_text(self, ctx: RuleContext) -> bool:
message = ctx.get("message")
state = ctx.get("state")
return self.agent._should_handle_as_meaningless_short_text(state, message.msg)
async def _rule_act_meaningless_short_text(self, ctx: RuleContext) -> RuleResult:
from core.pydantic_ai_agent import AgentResponse
message = ctx.get("message")
state = ctx.get("state")
trace_id = ctx.get("trace_id", "")
ping = random.choice(("嗯咯", "嗯啦", "", ""))
state.last_reply_at = datetime.now()
self.agent._activity_log(
"agent_ping_reply",
trace_id=trace_id,
customer_id=message.from_id,
msg=message.msg,
reply=ping,
)
return RuleResult(
matched=True,
stop=True,
action="agent_ping_reply",
payload={"response": AgentResponse(reply=ping, should_reply=True, need_transfer=False)},
)
async def _rule_pred_cooldown_silent(self, ctx: RuleContext) -> bool:
message = ctx.get("message")
state = ctx.get("state")
return self.agent._in_cooldown(state, message.msg)
async def _rule_act_cooldown_silent(self, ctx: RuleContext) -> RuleResult:
from core.pydantic_ai_agent import AgentResponse
message = ctx.get("message")
state = ctx.get("state")
trace_id = ctx.get("trace_id", "")
elapsed = int((datetime.now() - state.last_reply_at).total_seconds()) if state.last_reply_at else 0
logger.info("[Agent] 冷却期静默(距上次回复 %ss%r", elapsed, message.msg)
self.agent._activity_log(
"agent_cooldown_silent",
trace_id=trace_id,
customer_id=message.from_id,
elapsed_s=elapsed,
)
return RuleResult(
matched=True,
stop=True,
action="agent_cooldown_silent",
payload={"response": AgentResponse(reply="", should_reply=False, need_transfer=False)},
)
async def _rule_pred_manual_risk_block(self, ctx: RuleContext) -> bool:
message = ctx.get("message")
decision = self.risk_service.check_manual_block(message.from_id)
ctx.set("manual_risk_decision", decision)
return decision.blocked
async def _rule_act_manual_risk_block(self, ctx: RuleContext) -> RuleResult:
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
message = ctx.get("message")
trace_id = ctx.get("trace_id", "")
decision = ctx.get("manual_risk_decision")
self.agent._activity_log(
"agent_manual_risk_reject",
trace_id=trace_id,
customer_id=message.from_id,
risk=(decision.profile if decision else {}),
)
return RuleResult(
matched=True,
stop=True,
action="agent_manual_risk_reject",
payload={
"response": AgentResponse(
reply="这边无法继续为你处理该类需求,给你转人工专员对接。",
should_reply=True,
need_transfer=True,
transfer_msg=TRANSFER_MESSAGE,
)
},
)
async def _rule_pred_text_risk_block(self, ctx: RuleContext) -> bool:
message = ctx.get("message")
decision = await self.risk_service.check_text_block(
message.msg,
political_detector=self.agent._is_political_inquiry,
map_detector=self.agent._is_map_inquiry,
)
ctx.set("text_risk_decision", decision)
return decision.blocked
async def _rule_act_text_risk_block(self, ctx: RuleContext) -> RuleResult:
from core.pydantic_ai_agent import AgentResponse
message = ctx.get("message")
state = ctx.get("state")
trace_id = ctx.get("trace_id", "")
decision = ctx.get("text_risk_decision")
state.pending_image_urls.clear()
state.pending_requirements.clear()
self.agent._sync_pending_quote_state(message.from_id, state)
reject_text = self.risk_service.build_reject_text(decision.category if decision else "other")
reply = await self.agent._rewrite_reply_with_ai(
message=message,
state=state,
reply=reject_text,
scene="risk_reject",
)
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", reply)
self.agent._activity_log(
"agent_risk_reject",
trace_id=trace_id,
customer_id=message.from_id,
risk_category=(decision.category if decision else "other"),
risk_source=(decision.source if decision else "unknown"),
reply=reply,
)
return RuleResult(
matched=True,
stop=True,
action="agent_risk_reject",
payload={"response": AgentResponse(reply=reply, should_reply=True, need_transfer=False)},
)
logger = logging.getLogger("cs_agent")

View File

@@ -1,163 +0,0 @@
from __future__ import annotations
def _attach_skill_docs(prompt: str, *skill_docs: str) -> str:
docs = [d for d in skill_docs if d]
if not docs:
return prompt
return prompt + "\n\n=== 角色技能 ===\n" + "\n\n".join(docs)
def build_system_prompt(reply_persona: str, skill_pre_sales: str, skill_style: str) -> str:
base_prompt = """你是淘宝找原图店的客服,对外说帮客户找原图/高清图,实际是帮客户把模糊图处理成高清版,擅长快速成交。
核心原则:快、准、狠。**回复要像真人聊天,自然多变,禁止套模板、背台词。**
【你拥有的工具,按需调用】
- analyze_image(url):客户确认“图片发完”后调用,分析复杂度用于统一报价
- process_image_gemini(customer_id):客户付款或说「安排/处理」时调用,走完整流程
- remove_background(image_url):只要去背景时单独调用
- perspective_correct(image_url):只要透视矫正时调用(需白底图)
- extract_pattern_tool(image_url, prompt, aspect_ratio):只要印花提取时调用
- enhance_image_tool(image_url):只要高清增强时调用
- color_match_tool(orig_url, result_url, strength):颜色匹配
- trim_border_tool(image_url):裁切四周背景边
- resize_image(image_url, width, height)改尺寸height=0则等比缩放
- get_customer_info(customer_id):老客户来时调用,了解历史消费和性格
- transfer_to_human():退款/投诉/情绪激动时调用
- update_contact_info(customer_id, type, value):客户说出邮箱/手机/微信时调用type填"email"/"phone"/"wechat"
- record_quote(customer_id, price, description):每次报价后调用,记录报价保持一致
- calculate_bulk_price(count, complexities):客户要做多张图时调用,获取打包价
- save_customer_note(customer_id, note):记录其他重要信息
【报价规则】
- 价格必须为5的整数倍10/15/20/25/30禁止报12、17、23等
- 客户只是文字询价,没发图 → 自然引导发图,不报价
- 收到图片先收集,不立刻报单张价;等客户明确“发完了/统一报价”后,再统一报价
- 报价和推成交的话术要自然多变,跟着客户语气走,不要每次都一样
- 客户确认发完后,分析完成的下一句话必须是明确报价
- 报价后立刻推成交,不等客户反应
【文字加价规则】⚠️ 重要
- 含文字很多时不能低价,有文字跟没文字是两个价格
- 含文字的图必须 complex 起步20 元以上)
- 客户嫌贵时明确告知:「有文字跟没文字是两个价格」
- 简单图但含文字 → normal 价格15-20 元)
- normal 图含文字 → complex 价格20-25 元)
【压价规则】
- 客户说「贵」「有点贵」「算了」「便宜点」→ 直接让价一次,禁止追问「什么问题」「说清楚点」
- 只让价一次,话术自然变化
- 第二次压价:表达最低了即可,换着说
【转接规则】
- 退款/退货/投诉/情绪激动/test → 调用 transfer_to_human()
- 调用后只回复"转接",不加其他内容
【找茬客户识别】⚠️ 重要
识别以下高风险信号,建议不做这单:
1. 下单后立即申请退款
2. 从高价砍到低价30→10 元)
3. 反复问"不满意可以退吗"2 次以上)
4. 质疑服务内容("源文件还是什么"
5. 质疑价值("就一张图片"
6. 问"小一点就快一点的嘛"(想占便宜)
7. 重复问同一个问题(想找麻烦)
识别到以上 3 个以上信号 → 建议转人工或直接拒绝接单
话术:「不好意思,这单做不了」「去别家做吧」
【售后规则】
- 催进度:自然回复在做了/快了/马上好之类
- 要修改:自然问哪里要改
【禁忌】
- 没看到图不报价
- 不说"不行/不可以"
- 不解释技术细节
- 不给价格区间
- 回复不超过2句话
- 绝对禁止输出任何内部独白或状态说明,包括但不限于:"无需回复""已完成""已经完成""不需要回复""流程结束""操作完成""任务完成""记录完成""报价已记录"
- 每次必须输出真实的、发给客户看的回复文字,哪怕只有一句话"""
base_prompt += f"\n\n【人设语气】\n- 人设:{reply_persona}\n- 语气像真人店主,不官腔,不机械,不背模板。"
return _attach_skill_docs(base_prompt, skill_pre_sales, skill_style)
def build_natural_reply_prompt(reply_persona: str, skill_style: str) -> str:
base = f"""你是淘宝店主客服,专门把系统给你的“回复意图”改写成自然的一句话或两句话。
人设:{reply_persona}
规则:
- 只输出发给客户的话,不要解释你的思考。
- 口语化、简短、有温度,避免“这个需求我收到了”这类机械表达。
- 不要编造价格、订单、进度;只按输入意图表达。
- 默认不超过2句话。"""
return _attach_skill_docs(base, skill_style)
def build_after_sale_prompt(skill_after_sale: str, skill_style: str) -> str:
base = """你是淘宝客服的售后助手,负责售后阶段的自然沟通与处理进度反馈。
核心:简洁、自然、不解释技术细节、尽量不调用报价相关工具。
规则:
- 已付款客户优先:确认安排、说明进度、承诺时间点
- 修改需求:礼貌询问具体改哪里,尽量一句话
- 催进度:自然回复在做了/快了/马上好,给预计时间
- 投诉/情绪激动/退款:转人工
- 输出不超过2句话不说内部状态"""
return _attach_skill_docs(base, skill_after_sale, skill_style)
def build_pricing_prompt(
*,
min_price_floor: int,
case_library_link: str,
skill_pricing: str,
skill_style: str,
) -> str:
base = f"""你是淘宝客服的报价助手,负责在客户明确提到价格/询价时快速给出自然报价并推动成交。
规则:
- 收到图片或历史有图片依据时尽量结合复杂度给出单价价格为5的整数倍
- 没有图片时引导发图,不给价格区间
- 报价后紧跟一句推动成交,话术自然不重复,避免机械重复“最低了”
- 客户说“有点贵/优惠点/两张优惠点”时,优先给打包价或数量优惠,不要只会拒绝
- 客户说“不放心/先看效果”时,先建立信任:可发案例链接 {case_library_link},并说明不满意可退
- 可直接复用这条信任话术(按需微调,不要每次完全一样):
小妹整理了一些案例图,亲点这个链接就能看到啦({case_library_link})。
有什么想要的效果随时告诉我哈,我这边都可以按您的要求来做哦~/:065 效果不好不满意,我们这边包退的哦。
- 最低价不低于{min_price_floor}元,客户出价低于底线时礼貌拒绝(不好意思)
- 输出不超过2句话"""
return _attach_skill_docs(base, skill_pricing, skill_style)
def build_processing_prompt(skill_after_sale: str, skill_style: str) -> str:
base = """你是淘宝客服的处理助手,负责在客户说安排/处理/开始做或已付款的场景下进行处理安排与进度反馈。
规则:
- 已付款或明确要求开始时,确认安排并给预计时间点
- 可调用处理流程工具
- 投诉/退款时转人工
- 输出不超过2句话"""
return _attach_skill_docs(base, skill_after_sale, skill_style)
def build_similar_prompt(skill_pre_sales: str, skill_style: str) -> str:
base = """你是淘宝客服的相似图助手,客户问“有一样的吗/类似的吗/同款吗”时,给出自然回复与参考建议。
规则:
- 先确认可以找类似款,建议拍后我发参考图
- 如已知图案/类型,简要说明“同类型都有”,推动成交
- 输出不超过2句话"""
return _attach_skill_docs(base, skill_pre_sales, skill_style)
def build_order_prompt(skill_after_sale: str, skill_style: str) -> str:
base = """你是淘宝客服的订单助手,负责系统订单通知的处理。
规则:
- 已付款时自然确认安排;其他状态静默(输出空字符串)
- 输出不超过1句话"""
return _attach_skill_docs(base, skill_after_sale, skill_style)
def build_risk_prompt(skill_risk: str, skill_style: str) -> str:
base = """你是淘宝客服的风控助手,负责敏感/违规内容的前置拦截与替代话术。
规则:
- 黄色/擦边/涉政/政治人物/政治事件/政治图片/地图类内容等不接单,礼貌拒绝
- 输出不超过1句话"""
return _attach_skill_docs(base, skill_risk, skill_style)

View File

@@ -1,688 +1,189 @@
from __future__ import annotations
import logging
from typing import Any
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 db.customer_risk_db import risk_db
from services.service_tuhui_upload import upload_to_tuhui
from core.order_helpers import parse_order_info
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 register_tools(agent) -> None:
"""注册所有 Tool让 Agent 可以主动调用。"""
@agent.agent.tool
async def analyze_image(ctx: RunContext[Any], image_url: str) -> str:
"""
分析客户发来的图片复杂度,用于报价。
收到图片URL时调用此工具返回复杂度和建议报价。
"""
try:
from image.image_analyzer import image_analyzer
result = await image_analyzer.analyze(image_url)
complexity_label = {
"simple": "简单(画面干净)",
"normal": "一般复杂度",
"complex": "细节偏多",
"hard": "非常复杂",
}.get(result["complexity"], result["complexity"])
# 持久化图片URL和复杂度重启后仍能记住这张图
try:
from db.customer_db import db
db.update_last_image(
ctx.deps.from_id,
image_url,
complexity=result["complexity"],
gemini_prompt=result.get("gemini_prompt", ""),
aspect_ratio=result.get("aspect_ratio", "1:1"),
perspective=result.get("perspective", "no"),
)
except Exception:
pass
def _is_plain_transfer_command(text: str) -> bool:
return bool(_TRANSFER_COMMAND_RE.fullmatch(str(text or "").strip()))
# 存图片类型到客户画像
try:
from db.customer_db import db as _db
if result.get("subject"):
_db.add_image_type(ctx.deps.from_id, result["subject"])
except Exception:
pass
# 在 workflow 里创建待处理任务(付款后自动触发 Gemini
try:
from core.workflow import workflow
await workflow.image_analysis_result(
customer_id=ctx.deps.from_id,
image_url=image_url,
complexity=result["complexity"],
acc_id=ctx.deps.acc_id,
acc_type=ctx.deps.platform,
gemini_prompt=result.get("gemini_prompt", ""),
aspect_ratio=result.get("aspect_ratio", "1:1"),
perspective=result.get("perspective", "no"),
proc_type=result.get("proc_type", ""),
subject=result.get("subject", ""),
quality=result.get("quality", ""),
)
logger.info(
"[Agent] Workflow 任务已创建 | 客户: %s | 比例: %s | 透视: %s | 图片: %s...",
ctx.deps.from_id,
result.get("aspect_ratio"),
result.get("perspective"),
image_url[:60],
)
except Exception as e:
logger.exception("[Agent] Workflow 任务创建失败: %s", e)
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
# 组装给 AI 的分析报告
risk = result.get("risk", "none")
has_face = result.get("has_face", "no")
feasibility = result.get("feasibility", "yes")
note = result.get("note", "")
lines = [
f"图片主体:{result['subject'] or '未识别'}",
f"处理类型:{result['proc_type'] or '高清修复'}",
f"原图质量:{result['quality'] or '未知'}",
f"图片类型:{result.get('category', '') or '通用'}",
f"图片尺寸:{(result.get('width') or 0)}x{(result.get('height') or 0)}{result.get('megapixels', 0.0)}MP",
f"含人脸:{'' if has_face == 'yes' else ''}",
f"复杂度:{complexity_label}",
f"原因:{result['reason']}",
]
if result.get("size_surcharge"):
lines.append(f"尺寸加价:+{result['size_surcharge']}")
if result.get("size_note"):
lines.append(f"尺寸提示:{result['size_note']}")
try:
st = agent._get_conversation_state(ctx.deps.from_id)
if isinstance(result.get("price_min"), (int, float)):
st.last_min_price = int(result.get("price_min") or 0)
try:
from db.customer_db import db as _db
_db.update_last_min_price(ctx.deps.from_id, st.last_min_price)
except Exception:
pass
except Exception:
pass
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]
# 根据可做性和风险等级给 AI 不同的行动指引
if feasibility == "no":
if "敏感" in (note or ""):
lines.append("【拒绝】图片含敏感/黄色/擦边内容,不接单。")
lines.append("→ 直接拒绝,不说「发图来看看」,自然回复如:这类不做/不接。")
else:
lines.append("【无法处理】此图无法处理(纯黑/纯白/完全损坏/要找原始RAW文件")
lines.append("→ 告知客户无法处理,建议换图或说明原因,不要报价。")
elif risk == "high":
lines.append(f"【高风险】此图处理风险高:{note or 'AI修复后效果不能保证与原图一致'}")
lines.append(f"建议报价:{result['price_suggest']}")
lines.append("→ 先自然说明风险(人脸/效果可能不完美),再报价,满意再拍。话术自然。")
elif risk == "low":
lines.append(f"【低风险-含人脸】修复后人脸相似度约70-90%,效果不稳定。")
lines.append(f"建议报价:{result['price_suggest']}")
lines.append(f"→ 报价时自然加一句风险提示(人脸可能有轻微变化、满意再付等)")
else:
# 无风险,正常报价
base_price = result.get('price_suggest', 20)
text_surcharge = result.get('text_surcharge', 0)
layer_surcharge = result.get('layer_surcharge', 0)
total_price = base_price + text_surcharge + layer_surcharge
# 构建报价说明
price_explanation = f"建议报价:{total_price}"
if text_surcharge > 0:
price_explanation += f"(含文字处理 +{text_surcharge}元)"
if layer_surcharge > 0:
price_explanation += f"(含分层 +{layer_surcharge}元)"
class TransferSuccessException(Exception):
"""转接成功后抛出此异常,用于提前终止 AI 处理流程"""
def __init__(self, transfer_cmd: str):
self.transfer_cmd = transfer_cmd
super().__init__(transfer_cmd)
lines.append(price_explanation)
# 添加文字数量说明
text_amount = result.get('text_amount', 'none')
if text_amount != 'none':
lines.append(f"文字数量:{text_amount},需要精细处理")
async def transfer_to_human_tool(ctx: RunContext[Any], reason: str = Field(description="转人工的原因")) -> str:
"""
【核心工具】执行转人工逻辑。
获取设计师姓名并生成精准转接指令。
"""
logger.info(f"[Tool] 尝试呼叫设计师接手: {reason}")
if feasibility == "partial":
lines.append(f"⚠️ 此图有一定难度:{note or '效果可能不完美'},回复时可加「效果不满意退款」")
if note and note not in ("", ""):
lines.append(f"提示:{note}")
lines.append(f"【立刻回复客户报价 {total_price} 元,话术自然多变】")
designer_name = await dispatch_service.assign_designer()
return "\n".join(lines)
except Exception as e:
return f"图片分析失败: {e},请根据经验判断报价"
@agent.agent.tool
async def get_customer_info(ctx: RunContext[Any], customer_id: str) -> str:
"""
查询客户历史信息:消费记录、性格标签、报价历史等。
对话开始时或需要了解客户背景时调用。
"""
try:
from db.customer_db import db
return db.get_profile_text(customer_id)
except Exception as e:
return f"查询失败: {e}"
@agent.agent.tool
async def transfer_to_human(ctx: RunContext[Any]) -> str:
"""
转接人工客服。
遇到退款/投诉/情绪激动/复杂售后时调用。
"""
return "TRANSFER_REQUESTED"
@agent.agent.tool
async def get_customer_risk_profile(ctx: RunContext[Any], customer_id: str = "") -> str:
"""查询客户风控画像:退款/不付款/差评/人工黑名单等。"""
cid = customer_id or ctx.deps.from_id
try:
info = risk_db.evaluate_customer(cid)
return (
f"客户:{cid}\n"
f"不接单:{'' if info.get('do_not_serve') else ''}\n"
f"风险等级:{info.get('computed_level','low')} 分数:{info.get('computed_score',0)}\n"
f"近30天退款:{info.get('refund_30d',0)}\n"
f"近7天未付款下单:{info.get('unpaid_7d',0)}\n"
f"近90天差评:{info.get('bad_review_90d',0)}\n"
f"备注:{info.get('note','') or ''}"
)
except Exception as e:
return f"查询风控画像失败: {e}"
@agent.agent.tool
async def mark_customer_risk(
ctx: RunContext[Any],
customer_id: str,
do_not_serve: bool = False,
risk_level: str = "low",
risk_score: int = 0,
note: str = "",
tag: str = "",
) -> str:
"""人工标记客户风控画像(不接单/高风险/备注标签)。"""
try:
tags = [tag] if tag else []
risk_db.set_profile(
customer_id=customer_id,
do_not_serve=do_not_serve,
risk_level=risk_level,
risk_score=risk_score,
note=note,
tags=tags,
)
return "风控画像已更新"
except Exception as e:
return f"更新风控画像失败: {e}"
@agent.agent.tool
async def record_customer_risk_event(
ctx: RunContext[Any],
customer_id: str,
event_type: str,
event_count: int = 1,
note: str = "",
) -> str:
"""记录风控事件refund/unpaid_order/bad_review/blacklist_hit 等。"""
try:
risk_db.record_event(
customer_id=customer_id,
event_type=event_type,
event_count=event_count,
note=note,
)
return "风控事件已记录"
except Exception as e:
return f"记录风控事件失败: {e}"
@agent.agent.tool
async def save_customer_note(
ctx: RunContext[Any],
customer_id: str,
note: str
) -> str:
"""
记录客户关键信息到画像(邮箱/微信/特殊需求等)。
客户提供联系方式或重要信息时调用。
"""
try:
from db.customer_db import db
db.add_note(customer_id, note)
return "已记录"
except Exception as e:
return f"记录失败: {e}"
@agent.agent.tool
async def update_contact_info(
ctx: RunContext[Any],
customer_id: str,
contact_type: str,
value: str
) -> str:
"""
更新客户联系方式。
当客户说出邮箱/手机/微信时调用,比正则提取更准确。
contact_type 枚举值:
email - 邮箱
phone - 手机号
wechat - 微信号
"""
try:
from db.customer_db import db
if contact_type == "email":
db.update_email(customer_id, value)
elif contact_type == "phone":
db.update_phone(customer_id, value)
elif contact_type == "wechat":
db.update_wechat(customer_id, value)
else:
return f"未知联系方式类型: {contact_type}"
return f"已保存 {contact_type}: {value}"
except Exception as e:
return f"保存失败: {e}"
@agent.agent.tool
async def record_quote(
ctx: RunContext[Any],
customer_id: str,
price: int,
description: str = ""
) -> str:
"""
记录本次报价到客户画像,用于后续对话保持价格一致。
每次给客户报价后调用。
Args:
customer_id: 客户ID
price: 报价金额(元)
description: 报价描述,如"单图处理"/"三图打包"
"""
try:
from db.customer_db import db
db.update_last_price(customer_id, price)
if description:
db.add_note(customer_id, f"报价 {price}元({description}")
# 同步到内存状态
state = agent.conversations.get(customer_id)
if state:
state.last_price = price
return f"已记录报价 {price}"
except Exception as e:
return f"记录失败: {e}"
@agent.agent.tool
async def process_image_gemini(ctx: RunContext[Any], customer_id: str = "") -> str:
"""
触发 Gemini 作图处理。客户付款后或说「安排一下」「处理一下」时调用。
会从客户档案读取上次发图的 URL 和处理参数(提示词、比例、透视),启动 Gemini 流程。
处理完成后会自动发图给客户。
"""
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不自动作图"
except Exception:
return "现在处理模块暂时暂停,先不自动作图"
cid = customer_id or ctx.deps.from_id
try:
from core.workflow import workflow
ok = await workflow.trigger_processing_on_payment(
customer_id=cid,
acc_id=ctx.deps.acc_id,
acc_type=ctx.deps.platform,
)
if ok:
return "已安排,稍后发你"
return "该客户暂无待处理图片,请先发图"
except Exception as e:
return f"触发作图失败: {e},请稍后重试或转人工"
@agent.agent_pricing.tool
async def analyze_image_pricing(ctx: RunContext[Any], image_url: str) -> str:
try:
from image.image_analyzer import image_analyzer
result = await image_analyzer.analyze(image_url)
if result.get("feasibility") == "no" or result.get("risk") == "high":
return "该图风险高或不可做:不报价,建议换图或转人工评估。"
if not result.get("success", False):
return "图片识别异常:先不报价,建议客户重发更清晰图片。"
p = result.get("price_suggest", 20)
try:
st = agent._get_conversation_state(ctx.deps.from_id)
if isinstance(result.get("price_min"), (int, float)):
st.last_min_price = int(result.get("price_min") or 0)
try:
from db.customer_db import db as _db
_db.update_last_min_price(ctx.deps.from_id, st.last_min_price)
except Exception:
pass
except Exception:
pass
return f"建议报价:{p}"
except Exception as e:
return f"图片分析失败: {e}"
@agent.agent_pricing.tool
async def record_quote_pricing(
ctx: RunContext[Any],
customer_id: str,
price: int,
description: str = ""
) -> str:
try:
from db.customer_db import db
db.update_last_price(customer_id, price)
return "ok"
except Exception as e:
return f"记录失败: {e}"
@agent.agent_processing.tool
async def process_image_gemini_run(ctx: RunContext[Any], customer_id: str = "") -> str:
"""触发 Gemini 作图处理processing agent 专用入口)。"""
return await process_image_gemini(ctx, customer_id)
@agent.agent_similar.tool
async def recommend_similar(ctx: RunContext[Any], hint: str = "") -> str:
try:
return "有类似款,拍下我发你参考图。"
except Exception as e:
return f"推荐失败: {e}"
@agent.agent_order.tool
async def handle_order(ctx: RunContext[Any], raw_msg: str = "") -> str:
try:
info = parse_order_info(raw_msg or "")
paid_kw = ["等待发货", "已付款", "付款成功", "买家已付款"]
if any(k in (info.get("pay_status", "") or "") for k in paid_kw) or any(k in (info.get("order_status", "") or "") for k in paid_kw):
return "已安排,稍后发你"
return ""
except Exception:
return ""
@agent.agent_risk.tool
async def risk_filter(ctx: RunContext[Any], text: str = "") -> str:
return "这类不做哈,政治/敏感内容都不接。"
@agent.agent_risk.tool
async def get_customer_risk_profile_risk(ctx: RunContext[Any], customer_id: str = "") -> str:
return await get_customer_risk_profile(ctx, customer_id)
@agent.agent_risk.tool
async def mark_customer_risk_risk(
ctx: RunContext[Any],
customer_id: str,
do_not_serve: bool = False,
risk_level: str = "low",
risk_score: int = 0,
note: str = "",
tag: str = "",
) -> str:
return await mark_customer_risk(
ctx=ctx,
customer_id=customer_id,
do_not_serve=do_not_serve,
risk_level=risk_level,
risk_score=risk_score,
note=note,
tag=tag,
)
@agent.agent_risk.tool
async def record_customer_risk_event_risk(
ctx: RunContext[Any],
customer_id: str,
event_type: str,
event_count: int = 1,
note: str = "",
) -> str:
return await record_customer_risk_event(
ctx=ctx,
customer_id=customer_id,
event_type=event_type,
event_count=event_count,
note=note,
)
@agent.agent.tool
async def remove_background(ctx: RunContext[Any], image_url: str) -> str:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不处理图片"
except Exception:
return "现在处理模块暂时暂停,先不处理图片"
"""【独立工具】去背景,输出白底图。客户只要去背景时调用。"""
try:
from image.image_tools import remove_background as _rb
r = await _rb(image_url)
if r["success"]:
return f"去背景完成,已保存。自然回复客户好了发你"
return f"去背景失败:{r['message']}"
except Exception as e:
return f"去背景失败:{e}"
@agent.agent.tool
async def perspective_correct(ctx: RunContext[Any], image_url: str) -> str:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不处理图片"
except Exception:
return "现在处理模块暂时暂停,先不处理图片"
"""【独立工具】透视矫正。输入需白底图,输出展平图。"""
try:
from image.image_tools import perspective_correct as _pc
r = await _pc(image_url)
if r["success"]:
return f"透视矫正完成。自然回复客户好了"
return f"透视矫正失败:{r['message']}"
except Exception as e:
return f"透视矫正失败:{e}"
@agent.agent.tool
async def extract_pattern_tool(
ctx: RunContext[Any],
image_url: str,
prompt: str = "",
aspect_ratio: str = "1:1"
) -> str:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不处理图片"
except Exception:
return "现在处理模块暂时暂停,先不处理图片"
"""【独立工具】印花提取/主处理。按提示词和比例处理。"""
try:
from image.image_tools import extract_pattern
r = await extract_pattern(image_url, prompt=prompt, aspect_ratio=aspect_ratio)
if r["success"]:
return f"提取完成。自然回复客户好了发你"
return f"提取失败:{r['message']}"
except Exception as e:
return f"提取失败:{e}"
@agent.agent.tool
async def enhance_image_tool(ctx: RunContext[Any], image_url: str) -> str:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不处理图片"
except Exception:
return "现在处理模块暂时暂停,先不处理图片"
"""【独立工具】高清增强。客户只要清晰化时调用。"""
try:
from image.image_tools import enhance_image
r = await enhance_image(image_url)
if r["success"]:
return f"高清增强完成。自然回复客户好了"
return f"增强失败:{r['message']}"
except Exception as e:
return f"增强失败:{e}"
@agent.agent.tool
async def color_match_tool(
ctx: RunContext[Any],
orig_url: str,
result_url: str,
strength: float = 0.75
) -> str:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不处理图片"
except Exception:
return "现在处理模块暂时暂停,先不处理图片"
"""【独立工具】颜色匹配。将 result 色调匹配到 orig。"""
try:
from image.image_tools import color_match_images
r = await color_match_images(orig_url, result_url, strength=strength)
if r["success"]:
return f"颜色匹配完成"
return f"颜色匹配失败:{r['message']}"
except Exception as e:
return f"颜色匹配失败:{e}"
@agent.agent.tool
async def trim_border_tool(ctx: RunContext[Any], image_url: str) -> str:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不处理图片"
except Exception:
return "现在处理模块暂时暂停,先不处理图片"
"""【独立工具】裁切四周背景边(白/黄/米等)。"""
try:
from image.image_tools import trim_border
r = await trim_border(image_url)
if r["success"]:
return f"裁边完成"
return f"裁边失败:{r['message']}"
except Exception as e:
return f"裁边失败:{e}"
@agent.agent.tool
async def vectorize_to_eps_tool(ctx: RunContext[Any], image_url: str) -> str:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不处理图片"
except Exception:
return "现在处理模块暂时暂停,先不处理图片"
"""【独立工具】矢量化 - 将图片转为 EPS 矢量文件。客户要做矢量图、转 EPS、转 AI 格式时调用。"""
try:
from image.image_tools import vectorize_to_eps
r = await vectorize_to_eps(image_url)
if r["success"]:
return f"矢量化完成,已生成 EPS 文件。自然回复客户好了发你"
return f"矢量化失败:{r['message']}"
except Exception as e:
return f"矢量化失败:{e}"
@agent.agent.tool
async def meitu_enhance_tool(
ctx: RunContext[Any],
image_url: str,
mode: str = "standard"
) -> str:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不处理图片"
except Exception:
return "现在处理模块暂时暂停,先不处理图片"
"""
【独立工具】美图画质增强。客户要画质增强、清晰化、美图处理时调用。
Args:
image_url: 图片 URL 或本地路径
mode: 处理模式。crystal(极速重绘) standard(标准) enhance(增强) hdr(HDR) portrait(人像优化)
"""
try:
from image.image_tools import meitu_enhance
r = await meitu_enhance(image_url, mode=mode)
if r["success"]:
return f"画质增强完成。自然回复客户好了发你"
return f"画质增强失败:{r['message']}"
except Exception as e:
return f"画质增强失败:{e}"
@agent.agent.tool
async def resize_image(
ctx: RunContext[Any],
image_url: str,
width: int,
height: int = 0
) -> str:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return "现在处理模块暂时暂停,先不处理图片"
except Exception:
return "现在处理模块暂时暂停,先不处理图片"
"""
改图片尺寸。客户说「改成1920x1080」「弄成横图」「改下尺寸」时调用。
Args:
image_url: 图片URL客户刚发的图或从对话中获取
width: 目标宽度(像素),如 1920
height: 目标高度0=按宽度等比缩放),如 1080
常用尺寸1920x1080(横屏) 1080x1920(竖屏) 2000x2000(方图)
"""
try:
from image.image_processor import image_processor
result = await image_processor.resize(image_url, width, height)
if result["success"]:
return f"改尺寸完成:{width}x{height},已保存。自然回复客户改好了"
else:
return f"改尺寸失败:{result['message']},告知客户稍后重试"
except Exception as e:
return f"改尺寸失败:{e}"
@agent.agent.tool
async def calculate_bulk_price(
ctx: RunContext[Any],
image_count: int,
complexities: str = ""
) -> str:
"""
计算多图打包价格。
客户要做多张图时调用,返回建议总价。
Args:
image_count: 图片数量
complexities: 各图复杂度,逗号分隔,如 "normal,complex,simple"
没有识别结果时留空,按平均价格估算
"""
if image_count <= 0:
return "图片数量无效"
# 各复杂度单价必须为5的整数倍
unit_price = {"simple": 15, "normal": 20, "complex": 25, "hard": 30}
default_unit = 20 # 没有识别结果时的默认单价
if complexities:
levels = [c.strip() for c in complexities.split(",")]
total = sum(unit_price.get(lv, default_unit) for lv in levels)
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:
total = image_count * default_unit
return "ERROR_DESIGNER_BUSY设计师暂时不在位你告诉客户稍等马上帮忙联系设计师。不要说下班。"
# 打包优惠3张以上9折5张以上8折价格必须为5的整数倍
if image_count >= 5:
discounted = round(total * 0.8 / 5) * 5
tip = f"{image_count}张8折优惠"
elif image_count >= 3:
discounted = round(total * 0.9 / 5) * 5
tip = f"{image_count}张9折优惠"
else:
discounted = round(total / 5) * 5
tip = ""
return f"建议打包报价:{discounted}{tip}(原价{total}元)"
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(lookup_chat_history_tool)
agent.tool(lookup_customer_orders_tool)
logger.info("[Agent] 工具箱已更新:含转人工、历史记录查询、订单查询。")

View File

@@ -1,182 +0,0 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Optional, Tuple
from core.post_ops import negotiation_strategy_reply
logger = logging.getLogger("cs_agent")
if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentDeps, ConversationState, CustomerMessage, CustomerServiceAgent
def _select_agent_by_intent(
agent: "CustomerServiceAgent",
message: "CustomerMessage",
state: "ConversationState",
) -> Tuple[Optional[Any], str]:
"""
AI 意图优先路由;识别不到时返回 (None, "intent:none"),由关键词兜底。
"""
try:
from utils.intent_analyzer import detect_intent
decision = detect_intent(message.msg or "")
intent = (decision.intent or "").strip()
source = decision.source or "none"
score = float(decision.score or 0.0)
except Exception:
intent, source, score = "", "error", 0.0
if not intent:
return None, "intent:none"
if intent in ("询价", "砍价"):
return agent.agent_pricing, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent in ("修改", "加急"):
return agent.agent_processing, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent == "售后":
return agent.agent_after_sale, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent == "转接":
return agent.agent_after_sale, f"intent:{intent}|src:{source}|score:{score:.3f}"
if intent in ("打招呼", "批量", "发图"):
target = agent.agent_after_sale if state.stage == "售后" else agent.agent
return target, f"intent:{intent}|src:{source}|score:{score:.3f}"
return None, f"intent:unmapped:{intent}|src:{source}|score:{score:.3f}"
def select_target_agent(agent: "CustomerServiceAgent", message: "CustomerMessage", state: "ConversationState") -> Tuple[Any, str]:
msg_lower = message.msg.lower()
pricing_kw = ["多少钱", "多少一张", "报价", "给个价", "几块", "价位", "能便宜点吗"]
processing_kw = ["安排", "处理一下", "开始做", "做一下", "尽快", "加急", "付款了", "已付款"]
similar_kw = ["有一样的", "有一样吗", "一样的吗", "类似的", "类似的吗", "同款", "相似", "类似吗"]
order_markers = ["[系统订单信息]", "订单状态", "买家已付款"]
risk_kw = [
"黄色",
"擦边",
"色情",
"涉黄",
"涉政",
"政治",
"",
"不雅",
"天安门",
"政治人物",
"政治事件",
"领导人",
"党政",
"习近平",
"毛泽东",
"邓小平",
"江泽民",
"胡锦涛",
"特朗普",
"拜登",
"普京",
"泽连斯基",
"地图",
"地形图",
"行政区划图",
"卫星地图",
]
target_agent = agent.agent_after_sale if state.stage == "售后" else agent.agent
ai_target, ai_reason = _select_agent_by_intent(agent, message, state)
if ai_target is not None:
return ai_target, ai_reason
risk_hit = any(k in msg_lower for k in risk_kw) or agent._is_political_inquiry(message.msg) or agent._is_map_inquiry(message.msg)
if risk_hit:
return agent.agent_risk, "keyword:risk"
if any(k in message.msg for k in order_markers):
return agent.agent_order, "keyword:order"
if any(k in msg_lower for k in processing_kw):
return agent.agent_processing, "keyword:processing"
if any(k in msg_lower for k in pricing_kw):
return agent.agent_pricing, "keyword:pricing"
if any(k in msg_lower for k in similar_kw):
return agent.agent_similar, "keyword:similar"
return target_agent, "fallback:default"
async def execute_ai_turn(
agent: "CustomerServiceAgent",
*,
message: "CustomerMessage",
state: "ConversationState",
user_prompt: str,
deps: "AgentDeps",
history: list,
) -> str:
target_agent, route_reason = select_target_agent(agent, message, state)
logger.info("[路由] %s", route_reason)
result = await target_agent.run(user_prompt, deps=deps, message_history=history)
agent.message_histories[message.from_id] = result.all_messages()[-30:]
reply_text = agent._colloquialize_reply(agent._normalize_reply_text(result.output))
strategy_reply = negotiation_strategy_reply(message.msg, state)
if strategy_reply:
reply_text = strategy_reply
try:
from config.config import MIN_PRICE_FLOOR
import re
offer = None
m = re.search(r"(\d{1,4})\s*(?:元|块|块钱|元钱)\b", message.msg)
if m:
offer = int(m.group(1))
else:
m2 = re.search(r"(?:能|可以|可否|能否)\s*(\d{1,4})\b", message.msg)
offer = int(m2.group(1)) if m2 else None
st = agent._get_conversation_state(message.from_id)
floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR
if offer is not None and offer < floor:
reply_text = "不好意思"
except Exception:
pass
try:
from config.config import MIN_PRICE_FLOOR
import re
st = agent._get_conversation_state(message.from_id)
floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR
def _adjust(text: str) -> str:
def _repl(m: Any):
num = int(m.group(1))
adj = max(floor, round(num / 5) * 5)
return m.group(0).replace(str(num), str(adj))
patterns = [
r"按(\d{1,4})元",
r"报价[:]\s*(\d{1,4})\s*元",
r"(\d{1,4})\s*元一张",
r"打包(\d{1,4})\s*元",
]
t = text
for p in patterns:
t = re.sub(p, _repl, t)
return t
reply_text = _adjust(reply_text or "")
except Exception:
pass
for msg in result.new_messages():
for part in getattr(msg, "parts", []):
part_type = type(part).__name__
if "ToolCall" in part_type:
logger.info(
"[THINK/TOOL_CALL] %s(%s)",
getattr(part, "tool_name", ""),
getattr(part, "args", ""),
)
elif "ToolReturn" in part_type:
ret = str(getattr(part, "content", ""))[:120]
logger.info("[THINK/TOOL_RETURN] %s", ret)
logger.info("[THINK/RAW_OUTPUT] %r", reply_text)
return reply_text

View File

@@ -1,181 +0,0 @@
from __future__ import annotations
import random
from typing import Any
def calc_requirement_surcharge(requirements: list[str]) -> dict[str, Any]:
"""
把客户补充需求做成结构化加价,避免纯靠模型自由发挥导致价格波动。
返回:
{"extra": int, "hits": List[str]}
"""
text = " ".join(requirements or [])
rules = [
(["分层", "psd", "源文件"], 30, "分层/源文件"),
(["去背景", "抠图", "透明底", "白底"], 5, "去背景"),
(["换背景", "换场景", "合成", "转到", "换到", "放到", "贴到", "移到", "套到", "图案上去", "元素放到"], 10, "跨图合成/换背景"),
(["改字", "改文字", "替换文字", "排版"], 10, "改文字/排版"),
(["调色", "改色", "换色", "配色"], 5, "调色"),
(["多版本", "多个版本", "两版", "三版"], 10, "多版本"),
(["加急", "今天要", "马上要", "尽快"], 10, "加急"),
]
total = 0
hits: list[str] = []
for keywords, fee, label in rules:
if any(k in text for k in keywords):
total += fee
hits.append(f"{label}+{fee}")
total = min(total, 60)
total = round(total / 5) * 5
return {"extra": total, "hits": hits}
def build_batch_quote_reply(
*,
results: list[tuple[str, dict[str, Any]]],
total_suggest: int,
bundle_price: int,
req_fee: dict[str, Any],
) -> str:
"""构建分图明细 + 单条总报价可选项回复。"""
complexity_map = {
"simple": "简单",
"normal": "常规",
"complex": "复杂",
"hard": "高难",
}
detail_lines: list[str] = []
for i, (_, r) in enumerate(results, 1):
p = int(r.get("price_suggest", 20) or 20)
cx = complexity_map.get(str(r.get("complexity", "normal")), "常规")
reason = str(r.get("reason", "常规处理")).replace("\n", " ").strip()
if len(reason) > 18:
reason = reason[:18] + "..."
detail_lines.append(f"{i}{p}元({cx}{reason}")
extra = int(req_fee.get("extra", 0) or 0)
single_total = round((total_suggest + extra) / 5) * 5
req_hit = "".join(req_fee.get("hits", [])) if req_fee.get("hits") else ""
if len(results) == 1:
line = detail_lines[0].replace("图1", "这张:")
heads = [
"这张我看过了,先给你报下:",
"这张可以做,价格给你报下:",
"看了这张图,报价如下:",
"我先按这张给你算下:",
"这张处理没问题,我给你报个实在价:",
"我看完这张了,价格给你说下:",
"按这张图的难度,报价是:",
"这张我已经评估完了,先给你个价格:",
]
lines = [f"{random.choice(heads)}{line.split('', 1)[1]}"]
if req_hit:
lines.append(f"按你的需求另加{extra}元({req_hit})。")
tails = [
f"这张做下来共{single_total}元,定了我马上开工。",
f"合下来是{single_total}元,你点头我这边立刻安排。",
f"总价{single_total}元,可以的话我现在就给你做。",
f"这一张算下来{single_total}元,你说开做我就马上弄。",
f"给你按{single_total}元做,确定的话我现在就排上。",
f"这张我按{single_total}元给你做,没问题就直接开始。",
f"这张最终{single_total}元,你点头我立刻开干。",
f"这张就按{single_total}元走,你确认我就马上安排。",
]
lines.append(random.choice(tails))
return "\n".join(lines)
heads = [
"我先按这几张给你报一下:",
"这几张我都看过了,价格给你列一下:",
"我把每张价格先给你说清楚:",
"我先把这几张的价格拆开给你看:",
"这几张我都评估过了,报价给你写明白:",
"先别急,我把每张大概价给你列出来:",
"我按这批图先报个明细给你:",
"我先把每张费用和总价给你算出来:",
]
lines = [random.choice(heads)]
lines.extend(detail_lines)
if req_hit:
lines.append(f"需求加价:+{extra}元({req_hit}")
option_line = random.choice([
f"可选:按单张做(共{single_total}元),或打包做({bundle_price}元,会更省一点)。",
f"可选:单张算下来一共{single_total}元;打包给你{bundle_price}元,更划算。",
f"可选:你按单张做共{single_total}元,按打包做我给你{bundle_price}元。",
f"可选:分开做总共{single_total}元,打包做{bundle_price}元(省一点)。",
f"可选:按张算共{single_total}元;直接打包{bundle_price}元。",
])
lines.append(option_line)
lines.append(
random.choice(
[
"你定一个,我这边马上开工。",
"你选个方案,我立刻给你安排上。",
"你拍板就行,我这边马上开做。",
"你看选哪个合适,我这边马上给你做。",
"你一句话定下来,我现在就给你安排。",
]
)
)
return "\n".join(lines)
def prepare_batch_intake(state: Any) -> dict[str, Any]:
"""Stage 1: 收集阶段,标准化输入并做上限约束。"""
urls = list(getattr(state, "pending_image_urls", []) or [])
if not urls:
return {"ok": False, "reply": "你先把图片发我,我看完再给你统一报价。", "need_transfer": False}
try:
from config.config import BATCH_ANALYZE_CONCURRENCY, BATCH_MAX_IMAGES
max_images = max(1, int(BATCH_MAX_IMAGES))
analyze_concurrency = max(1, int(BATCH_ANALYZE_CONCURRENCY))
except Exception:
max_images = 12
analyze_concurrency = 3
if len(urls) > max_images:
return {
"ok": False,
"reply": f"这次图片有点多({len(urls)}张),我先按前{max_images}张处理报价,剩下的下一批继续发我。",
"need_transfer": False,
}
return {
"ok": True,
"urls": urls[:max_images],
"requirements": list(getattr(state, "pending_requirements", []) or []),
"analyze_concurrency": analyze_concurrency,
}
def assess_batch_risk(results: list[tuple[str, dict[str, Any]]]) -> dict[str, list[str]]:
"""Stage 2.5: 分离可做和风险图。"""
unsafe: list[str] = []
dense_text_reject: list[str] = []
for i, (_, r) in enumerate(results, 1):
if r.get("feasibility") == "no" or r.get("risk") == "high":
unsafe.append(f"{i}")
note = str(r.get("note", "") or "")
if "文字内容过于密集" in note or "密集文字" in note:
dense_text_reject.append(f"{i}")
return {"unsafe": unsafe, "dense_text_reject": dense_text_reject}
def build_batch_pricing_plan(results: list[tuple[str, dict[str, Any]]], requirements: list[str]) -> dict[str, Any]:
"""Stage 3: 报价计算(图片成本 + 需求加价 + 打包价)。"""
total_suggest = sum(int(r.get("price_suggest", 20) or 20) for _, r in results)
req_fee = calc_requirement_surcharge(requirements)
if len(results) == 2:
bundle_price = max(10, total_suggest - 5)
elif len(results) >= 3:
bundle_price = max(10, round(total_suggest * 0.9 / 5) * 5)
else:
bundle_price = total_suggest
bundle_price += int(req_fee.get("extra", 0) or 0)
bundle_price = round(bundle_price / 5) * 5
return {
"total_suggest": total_suggest,
"req_fee": req_fee,
"bundle_price": bundle_price,
}

View File

@@ -1,432 +0,0 @@
from __future__ import annotations
import random
from typing import Any
def classify_short_customer_text(text: str) -> str:
"""
短句分类器(状态机前置):
- finish_signal: 发图完成,可报价
- progress_query: 追问进度/结果
- ack: 简短确认
- unknown: 未识别
"""
s = (text or "").strip()
if not s:
return "unknown"
if len(s) > 8:
return "unknown"
finish_kw = (
"没了",
"没有了",
"就这",
"就这张",
"就这一张",
"就这一个",
"就一个",
"先这些",
"就这些",
"发完了",
"都发完了",
)
if any(k in s for k in finish_kw):
return "finish_signal"
progress_kw = (
"有吗",
"有没",
"有没有",
"找到了吗",
"找到了没",
"没找到吗",
"找到没",
"找到没有",
"进度",
"结果",
"多久好",
"什么时候好",
"好了没",
"弄好了吗",
"做了没",
"高清",
"发我",
"重新发",
"你重新发给我",
)
if any(k in s for k in progress_kw) or s in {"?", "", "在吗", "人呢"}:
return "progress_query"
ack_kw = ("", "嗯嗯", "", "好的", "", "可以", "ok", "OK", "收到", "明白")
if s in ack_kw:
return "ack"
return "unknown"
def is_batch_finish_signal(text: str) -> bool:
"""客户是否表达“图发完了,可以统一报价”"""
if not text:
return False
if classify_short_customer_text(text) == "finish_signal":
return True
finish_keywords = [
"发完了",
"都发完了",
"发齐了",
"齐了",
"先这些",
"就这些",
"全部",
"一起报",
"统一报价",
"总共多少钱",
"一共多少钱",
"打包价",
"总价",
"报价吧",
"报个总价",
"给个总价",
"没了",
"没有了",
"没图了",
"就这",
"就这张",
"就这一张",
"就这一个",
"就一个",
"先报吧",
"报下价",
"报个价",
"可以报价了",
"能报吗",
]
return any(k in text for k in finish_keywords)
def is_cross_image_composite_intent(text: str) -> bool:
"""
识别多图跨图修改意图A图元素放到B图
A图的图案转到B图、这个图案放到另一张上。
"""
s = (text or "").strip()
if not s:
return False
pair_marks = ("a图", "b图", "第一张", "第二张", "这张", "那张", "上一张", "另一张")
op_kw = (
"转到",
"换到",
"放到",
"贴到",
"移到",
"套到",
"合成",
"融合",
"替换到",
"图案上去",
"字放到",
"元素放到",
"logo放到",
)
return any(k in s.lower() for k in pair_marks) and any(k in s for k in op_kw)
def is_batch_finish_intent(text: str, state: Any, has_incoming_urls: bool) -> bool:
"""
语义结束识别:
- 显式口令:发完了/统一报价
- 隐式意图:询价/砍价
- 单图需求明确:如“这个门头上面的字做一下”可直接进入报价
"""
if not text:
return False
if is_batch_finish_signal(text):
return True
if has_incoming_urls:
return False
if not (getattr(state, "pending_image_urls", None) or []):
return False
try:
from utils.intent_analyzer import detect_intent
intent = detect_intent(text).intent
except Exception:
intent = ""
if intent in ("询价", "砍价"):
return True
msg = (text or "").strip()
if not msg:
return False
single_image_action_kw = (
"做一下",
"改一下",
"处理一下",
"就这张",
"按这个做",
"照这个做",
"这个门头",
"上面的字",
"这个字",
"这个图做",
"能做吗",
)
multi_image_finish_kw = (
"就这些",
"就这几张",
"按这几张",
"这几张一起做",
"一起做一下",
"先按这些",
"先按这几张",
"直接报价",
"现在报价",
"看下报价",
"先报个总价",
"总价多少",
"一起多少钱",
"先做这几张",
)
hold_kw = ("还有", "再发", "先等", "稍后", "等会", "回头")
image_count = len(getattr(state, "pending_image_urls", []) or [])
if image_count == 1:
if any(k in msg for k in single_image_action_kw) and not any(k in msg for k in hold_kw):
return True
elif image_count >= 2:
if any(k in msg for k in multi_image_finish_kw) and not any(k in msg for k in hold_kw):
return True
if is_cross_image_composite_intent(msg) and not any(k in msg for k in hold_kw):
return True
return False
def is_related_image_followup_intent(text: str) -> bool:
"""识别“新发的是上一张的截图/局部细节”的关联意图。"""
s = (text or "").strip().lower()
if not s:
return False
relation_kw = (
"截图",
"截屏",
"局部",
"细节",
"放大",
"裁剪",
"同一张",
"同一幅",
"上一张",
"上张",
"前一张",
"前面那张",
"刚才那张",
"这个是上面",
"这个是那张",
"补一张细节",
"补个截图",
)
return any(k in s for k in relation_kw)
def is_result_followup_query(text: str) -> bool:
"""识别客户在找图流程中的结果/进度追问。"""
if classify_short_customer_text(text) == "progress_query":
return True
s = (text or "").strip()
if not s:
return False
followup_kw = (
"找到了吗",
"没找到吗",
"找到没",
"找到没有",
"找到了没",
"有吗",
"有没",
"有没有",
"有结果吗",
"结果呢",
"进度",
"多久好",
"什么时候好",
"好了没",
"弄好了吗",
"做了没",
"你重新发",
"重新发给我",
"高清",
"发我",
)
if any(k in s for k in followup_kw):
return True
return s in {"?", "", "在吗", "人呢"}
def build_collect_ack(count: int, related_followup: bool = False) -> str:
if related_followup and count >= 2:
related_templates = [
"这张我收到了,看起来是上一张的截图/细节图,我按同一单一起处理。还有补充就继续发。",
"收到,这张是关联补图我记上了(按同一需求处理)。你还有图就继续发。",
"明白,这张是前图的局部截图,我会和前面那张一起算,不会分开漏掉。",
]
return random.choice(related_templates)
if count <= 1:
one_templates = [
"这张收到啦,还有图就继续发,我一起给你看。",
"图我看到了,后面还有就接着发,最后我一口价给你。",
"收到这张了,你有其他图也发来,我统一帮你算。",
"这张我先记上了,你那边还有的话接着发,我一起给你报。",
"第1张收到你继续发就行发完我这边一次给你算清楚。",
"这张没问题,我先收着。要是还有图,你直接连着发我就行。",
"我先看到了这张,你后面还有就一起发来,我统一给你报价。",
"这张图我已经记下了,后面有补充就继续甩过来哈。",
]
return random.choice(one_templates)
templates = [
"这几张我都收到了(现在{n}张)。还有的话继续发,我一起给你报。",
"好嘞,先看到{n}张了。你可以继续发,或者直接说“就这些”我现在就报价。",
"收到哈(共{n}张)。你还要补图就继续发,不补的话我现在也可以直接给价。",
"我这边先收到了{n}张。你继续补图,或者直接说“按这些算”我就开始报。",
"这波我已经记了{n}张,你要是还有就接着发,不补的话我立刻给总价。",
"先看到{n}张图了,后面你看是继续发,还是直接让我现在报价都可以。",
"好的,目前{n}张到位。你一句“就这些”,我马上给你打包价。",
"图我都看到了({n}张)。你还发我就继续收,不发我现在就给你报。",
]
return random.choice(templates).format(n=count)
def build_collect_progress_reply(count: int) -> str:
if count <= 1:
templates = [
"我这边在处理了,这张有结果我第一时间回你。",
"在跟进中,这张一有进展我马上发你。",
"这张我正在看,稍等我一会儿,结果出来就回你。",
]
return random.choice(templates)
templates = [
"我这边在按你这{n}张一起处理,有结果我立刻同步你。",
"正在跟进这{n}张,出结果我第一时间发你,不会漏。",
"进度在跑了(共{n}张),你稍等一下,我这边有结果马上回。",
]
return random.choice(templates).format(n=count)
def build_collect_remind(count: int) -> str:
if count <= 1:
one_templates = [
"这个要求我记住了。你还有图就继续发,不补图我就按这张给你报价。",
"明白,这个需求我加上了。你继续发图也行,想直接报价也可以。",
"我先记下这张。你如果是要我找图,不是做图,直接说一声,我按找图思路给你走。",
"收到,这张我先按你的要求记好了。就做这一张的话,我现在直接给你报实价。",
"你这要求我记下了,后面还有图就发,没有的话我现在直接算价。",
"行,我按你这个要求来。继续补图也行,不补我就先报这张。",
"这个点我懂了,你还要补图就接着发,不补我立刻给你报价。",
"要求我已经加上了。你看是继续发,还是我现在直接报这张。",
]
return random.choice(one_templates)
templates = [
"需求我记下了(当前{n}张)。你继续补图,或者直接说“就这些”我现在报价。",
"好,这个要求也加上了(现在{n}张)。不再补图的话我立刻给你打包价。",
"收到(共{n}张)。你还发就继续,不发的话我现在就给总价。",
"这个需求我加进去了(现在{n}张)。你继续发也行,直接报价也行。",
"我这边都记好了({n}张+需求)。你一句“先按这些算”,我马上报价。",
"要求同步好了,目前{n}张。要补图继续发,不补图我现在就给你打包价。",
"行,需求和图片我都收着了({n}张)。你直接让我报价也可以。",
"好的,这条需求也算进去了(共{n}张)。你看要不要我现在直接报。",
]
return random.choice(templates).format(n=count)
def is_find_image_not_edit_conflict(text: str) -> bool:
"""识别客户明确声明“要找图,不是做图”的冲突语义。"""
s = (text or "").strip()
if not s:
return False
find_kw = ("找图", "找原图", "找素材", "找同款")
deny_edit_kw = ("不是让你做图", "不是做图", "不用做图", "不需要做图", "不是修图", "不用修图")
return any(k in s for k in find_kw) and any(k in s for k in deny_edit_kw)
def needs_clarification_in_collecting(text: str) -> bool:
"""信息不足时先追问,不急着报价。"""
s = (text or "").strip()
if not s:
return False
short_non_vague_kw = (
"",
"?",
"没了",
"没有了",
"就这",
"",
"好的",
"ok",
"报价",
"找到了吗",
"没找到吗",
"找到没",
"找到了没",
"有吗",
"有没",
"有没有",
"多久好",
"什么时候好",
"高清",
)
if len(s) <= 4:
if any(k in s for k in short_non_vague_kw):
return False
return True
vague_kw = (
"这个也是",
"一共几个图",
"几个图",
"啥意思",
"没明白",
"什么意思",
"这个呢",
"这个可以吗",
"然后呢",
"咋办",
"怎么搞",
)
return any(k in s for k in vague_kw)
def build_find_image_clarify_reply(state: Any) -> str:
count = len(getattr(state, "pending_image_urls", []) or [])
return (
f"明白,你是要我帮你找图,不是做图。现在我这边先记了{count}张,"
"你告诉我具体要找哪种:原图/同款/高清版,我按这个方向给你找。"
)
def build_not_understood_reply() -> str:
"""信息不足时的澄清话术(随机)。"""
templates = [
"不好意思,不太懂你的意思,你再具体说下哈。",
"抱歉我这边没完全理解,你可以换个说法再说一次吗?",
"我有点没听明白,你是要找图还是要做图呀?",
"不好意思我没抓到重点,你再补一句我就能接着处理。",
"这句我理解得不太准,你再说具体一点我马上给你办。",
"抱歉,这里我没太看懂。你是想让我找原图,还是按图处理?",
"我这边还没完全明白你的意思,麻烦你再具体描述一下。",
"不好意思,这条我没读懂,你再详细说一点我马上跟上。",
]
return random.choice(templates)
def append_requirement(state: Any, text: str) -> None:
"""追加需求并做去重/截断,减少上下文噪音。"""
t = (text or "").strip()
if not t:
return
t = t[:120]
existing = list(getattr(state, "pending_requirements", []) or [])
if existing and existing[-1] == t:
return
if t in existing[-5:]:
return
existing.append(t)
if len(existing) > 20:
existing = existing[-20:]
state.pending_requirements = existing

View File

@@ -1,229 +0,0 @@
from __future__ import annotations
import os
import logging
from collections import Counter
from datetime import datetime
logger = logging.getLogger("cs_agent")
def calc_avg_complexity(complexity_history: list) -> str:
"""计算平均复杂度。"""
if not complexity_history:
return "未知"
level_map = {"simple": 1, "normal": 2, "complex": 3, "hard": 4}
label_map = {1: "简单", 2: "一般", 3: "复杂", 4: "很复杂"}
try:
avg = sum(level_map.get(c, 2) for c in complexity_history) / len(complexity_history)
return label_map.get(round(avg), "一般")
except Exception:
return "一般"
def get_customer_profile_context(agent, customer_id: str) -> str:
"""从数据库读取客户画像,注入给 AI。含个性化语气、报价策略、主动预测、近期对话。"""
try:
from db.customer_db import db
profile = db.get_customer(customer_id)
if profile.blacklist:
return f"【⚠️黑名单客户】原因:{profile.blacklist_reason or '已标记'},请转接人工处理,不要自动回复"
lines = []
lines.append("=== 客户档案 ===")
basic_info = []
basic_info.append(f"客户ID: {customer_id}")
basic_info.append(f"姓名: {profile.name or '未知'}")
if profile.email:
basic_info.append(f"邮箱: {profile.email}")
if profile.phone:
basic_info.append(f"电话: {profile.phone}")
if profile.wechat:
basic_info.append(f"微信: {profile.wechat}")
lines.append(" | ".join(basic_info))
consume_info = []
consume_info.append(f"客户等级: {profile.customer_level}")
if profile.vip:
consume_info.append("VIP客户")
consume_info.append(f"总订单: {profile.total_orders}")
consume_info.append(f"总消费: {profile.total_spent}")
if profile.total_orders > 0:
consume_info.append(f"客单价: {profile.total_spent // profile.total_orders}")
lines.append("--- 消费分析 ---")
lines.append(" | ".join(consume_info))
price_info = []
if profile.vip_custom_price:
price_info.append(f"VIP专属价: {profile.vip_custom_price}元(直接报这个价)")
if profile.last_price:
price_info.append(f"上次报价: {profile.last_price}")
if profile.lowest_price_accepted:
price_info.append(f"历史最低成交: {profile.lowest_price_accepted}")
if profile.discount_given_count:
price_info.append(f"历史让价: {profile.discount_given_count}")
if profile.price_sensitivity:
price_info.append(f"价格敏感度: {profile.price_sensitivity}")
if getattr(profile, "last_quote_no_convert", False):
price_info.append("【策略】上次报价未成交本次可降5-10元")
if price_info:
lines.append("--- 报价历史 ---")
lines.append(" | ".join(price_info))
personality_info = []
if profile.personality:
personality_info.append(f"性格: {'/'.join(profile.personality)}")
if profile.decision_speed:
personality_info.append(f"决策速度: {profile.decision_speed}")
if profile.communication_prefer:
personality_info.append(f"沟通偏好: {profile.communication_prefer}")
if personality_info:
lines.append("--- 性格特征 ---")
lines.append(" | ".join(personality_info))
image_info = []
image_info.append(f"累计发图: {profile.total_images_sent}")
if profile.complexity_history:
image_info.append(f"平均复杂度: {calc_avg_complexity(profile.complexity_history)}")
if profile.image_type_history:
top_types = Counter(profile.image_type_history).most_common(3)
types_str = "".join(f"{t}({c}次)" for t, c in top_types)
image_info.append(f"常见类型: {types_str}")
if profile.preferred_format:
image_info.append(f"格式偏好: {profile.preferred_format}")
if profile.preferred_size:
image_info.append(f"尺寸要求: {profile.preferred_size}")
if profile.last_image_url:
image_info.append(f"最近发图: {profile.last_image_url[:60]}...")
lines.append("--- 图片习惯 ---")
lines.append(" | ".join(image_info))
if profile.processing_status:
task_info = []
task_info.append(f"状态: {profile.processing_status}")
if profile.processing_image_url:
task_info.append(f"处理中: {profile.processing_image_url[:40]}...")
if profile.expected_done_at:
task_info.append(f"预计完成: {profile.expected_done_at}")
lines.append("--- 当前任务 ---")
lines.append(" | ".join(task_info))
if profile.last_conversation_summary:
time_str = ""
if profile.last_conversation_time:
try:
t = datetime.fromisoformat(profile.last_conversation_time)
diff = datetime.now() - t
if diff.days > 0:
time_str = f"{diff.days}天前)"
else:
h = diff.seconds // 3600
time_str = f"{h}小时前)" if h > 0 else "(刚刚)"
except Exception:
pass
lines.append(f"--- 上次对话 {time_str} ---")
lines.append(profile.last_conversation_summary)
hints = []
if profile.personality:
if "爽快" in profile.personality:
hints.append("回复简洁直接,不废话,快速报价")
if "砍价" in profile.personality or "砍价狂" in profile.personality:
hints.append("报价时强调性价比,只让价一次,第二次引导去 xinhui.cloud")
if "纠结" in profile.personality or "墨迹" in profile.personality:
hints.append("多给一点说明,耐心回答")
if profile.price_sensitivity == "":
hints.append("报价时顺带提「满意再拍」降低顾虑")
if profile.decision_speed == "":
hints.append("直接报价推成交,少铺垫")
if profile.total_orders > 0 and profile.decision_speed == "":
hints.append("老客爽快,直接报价成交")
if hints:
lines.append("--- 回复策略 ---")
lines.append("".join(hints))
proactive = []
if profile.bulk_potential == "" or (profile.total_images_sent or 0) >= 2:
proactive.append("可问「要做多张吗,多张有优惠」")
if profile.upsell_opportunity:
proactive.append(f"加购机会: {''.join(profile.upsell_opportunity)}")
if proactive:
lines.append("--- 主动推荐 ---")
lines.append("".join(proactive))
return "\n".join(lines)
except Exception as e:
logger.exception("[Agent] 获取客户画像失败: %s", e)
return ""
def get_refusal_context_hint(agent, customer_id: str, current_msg: str, profile_context: str) -> str:
"""
检测「刚拒绝某张图 + 客户问能找到吗」场景,注入显式提示,避免前后矛盾。
"""
ask_keywords = ["能找到吗", "可以吗", "有吗", "能做吗", "可以找吗", "可以弄吗"]
if not any(kw in current_msg for kw in ask_keywords):
return ""
refusal_keywords = ["不做", "不接", "拒绝", "不做这类", "这类不做"]
if any(kw in profile_context for kw in refusal_keywords):
return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。"
history = getattr(agent, "message_histories", {}).get(customer_id, [])
for msg in reversed(history[-6:]):
msg_str = str(msg)
if any(kw in msg_str for kw in refusal_keywords):
return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。"
return ""
def get_conversation_context(customer_id: str, acc_id: str = "", limit: int = 12, max_len: int = 80) -> str:
"""每一次对话都从数据库加载近期对话,压缩后注入 prompt。"""
try:
try:
from config.config import CHAT_CONTEXT_LIMIT, CHAT_CONTEXT_TRUNCATE_LEN
limit = CHAT_CONTEXT_LIMIT
max_len = CHAT_CONTEXT_TRUNCATE_LEN
except Exception:
pass
from db.chat_log_db import get_recent_conversation
msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=limit)
if not msgs:
return ""
lines = []
for m in msgs:
role = "" if m.get("direction") == "in" else ""
msg_text = (m.get("message") or "").strip().replace("\n", " ")[:max_len]
if not msg_text:
continue
lines.append(f"{role}:{msg_text}")
if not lines:
return ""
return "【近期】\n" + "\n".join(lines) + "\n\n"
except Exception:
return ""
def get_intent_emotion_hint(msg: str) -> str:
"""语义匹配:意图/情绪识别注入提示。EMBEDDING_MODEL 未配置时用关键词。"""
try:
from utils.intent_analyzer import detect_emotion_embedding, detect_intent
decision = detect_intent(msg)
intent = decision.intent
emotion = detect_emotion_embedding(msg) if os.getenv("EMBEDDING_MODEL") else None
parts = []
if intent:
parts.append(f"意图:{intent}")
if decision.source:
parts.append(f"意图来源:{decision.source}")
if emotion:
parts.append(f"情绪:{emotion}")
if parts:
return f"【当前消息】{', '.join(parts)}"
except Exception:
pass
return ""

View File

@@ -1,95 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from core.quote_state_machine import QuoteStateMachine
def refresh_quote_phase(state: Any, phase_hint: str = "") -> None:
"""统一维护收图报价状态机。"""
QuoteStateMachine().refresh(state, phase_hint=phase_hint)
def sync_pending_quote_state(agent: Any, customer_id: str, state: Any) -> None:
"""把待报价队列同步到客户库,避免重启丢失。"""
try:
refresh_quote_phase(state)
from db.customer_db import db
db.update_pending_quote_state(
customer_id,
state.pending_image_urls,
state.pending_requirements,
)
except Exception:
pass
def restore_pending_quote_state(customer_id: str, state: Any) -> None:
"""从客户库恢复待报价队列。"""
try:
from db.customer_db import db
profile = db.get_customer(customer_id)
state.pending_image_urls = list(getattr(profile, "pending_quote_images", []) or [])
state.pending_requirements = list(getattr(profile, "pending_quote_requirements", []) or [])
state.image_count = len(state.pending_image_urls)
refresh_quote_phase(state)
except Exception:
pass
def cleanup_inactive(conversations: dict, message_histories: dict, now: datetime) -> None:
"""清理超过 7 天没有消息的对话状态,释放内存。"""
if len(conversations) % 100 != 0:
return
expired = [
cid
for cid, state in conversations.items()
if state.last_update and (now - datetime.fromisoformat(state.last_update)).days > 7
]
for cid in expired:
conversations.pop(cid, None)
message_histories.pop(cid, None)
def get_conversation_state(agent: Any, customer_id: str) -> Any:
"""获取或创建对话状态,超时自动重置。"""
now = datetime.now()
if customer_id in agent.conversations:
state = agent.conversations[customer_id]
if state.last_update:
try:
last = datetime.fromisoformat(state.last_update)
hours = (now - last).total_seconds() / 3600
if hours > agent.CONVERSATION_TIMEOUT_HOURS:
state.stage = "售前"
state.discount_count = 0
agent.message_histories.pop(customer_id, None)
except Exception:
pass
if not state.pending_image_urls and not state.pending_requirements:
restore_pending_quote_state(customer_id, state)
else:
agent.conversations[customer_id] = agent.ConversationStateClass(
customer_id=customer_id,
last_update=now.isoformat(),
)
restore_pending_quote_state(customer_id, agent.conversations[customer_id])
cleanup_inactive(agent.conversations, agent.message_histories, now)
return agent.conversations[customer_id]
def should_defer_batch_quote(agent: Any, state: Any, mark_ready: bool = False) -> bool:
"""批量报价延后控制。"""
agent.quote_state_machine.delay_turns = max(0, int(agent.batch_quote_delay_turns))
return agent.quote_state_machine.should_defer_batch_quote(state, mark_ready=mark_ready)
def mark_quote_ready(agent: Any, state: Any) -> None:
"""仅标记 ready 状态,不消费等待轮次。"""
agent.quote_state_machine.delay_turns = max(0, int(agent.batch_quote_delay_turns))
agent.quote_state_machine.mark_ready(state)

38
core/engine.py Normal file
View File

@@ -0,0 +1,38 @@
import logging
from typing import Optional, Any
from core.schema import StandardMessage, StandardResponse
from core.events.event_bus import bus
logger = logging.getLogger("cs_agent")
class BusinessEngine:
"""
业务逻辑中枢(备用引擎,主流程由 Orchestrator + Brain 处理)。
仅在 Orchestrator 不可用时作为降级方案。
"""
def __init__(self, agent_instance: Any = None):
self.agent = agent_instance
async def handle_message(self, msg: StandardMessage) -> StandardResponse:
content = (msg.content or "")
logger.info(f"[Engine] 收到来自 {msg.platform} 的消息: {msg.user_id} -> {content[:50]}")
return StandardResponse(
reply_content="稍等哈,设计师马上来。",
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
)
async def emit_image_result(self, user_id: str, platform: str, url: str, acc_id: str):
"""
这是一个业务触发器示例:当图片处理完成时,由 Engine 主动发广播。
"""
await bus.emit(
"MESSAGE_OUTBOUND",
user_id=user_id,
platform=platform,
response=StandardResponse(
reply_content=url,
msg_type=1, # 图片
metadata={"acc_id": acc_id}
)
)

39
core/events/event_bus.py Normal file
View File

@@ -0,0 +1,39 @@
import asyncio
import logging
from typing import Callable, Dict, List, Any, Awaitable
logger = logging.getLogger("cs_agent")
class AsyncEventBus:
"""
异步事件总线:解耦业务触发与平台发送。
支持一个事件被多个订阅者监听。
"""
def __init__(self):
self._listeners: Dict[str, List[Callable[..., Awaitable[None]]]] = {}
def subscribe(self, event_type: str, callback: Callable[..., Awaitable[None]]):
"""订阅事件"""
if event_type not in self._listeners:
self._listeners[event_type] = []
self._listeners[event_type].append(callback)
logger.info(f"[EventBus] 新订阅者已注册到事件: {event_type}")
async def emit(self, event_type: str, **kwargs):
"""发布事件:异步广播给所有订阅者"""
if event_type not in self._listeners:
return
tasks = []
for callback in self._listeners[event_type]:
tasks.append(asyncio.create_task(callback(**kwargs)))
if 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

@@ -1,218 +0,0 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING, Optional
logger = logging.getLogger("cs_agent")
if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentResponse, ConversationState, CustomerMessage, CustomerServiceAgent
async def handle_find_image_batch_flow(
agent: "CustomerServiceAgent",
*,
message: "CustomerMessage",
state: "ConversationState",
customer_text: str,
shop_type: str,
) -> Optional["AgentResponse"]:
"""Handle find-image collecting/quote flow. Return response when handled."""
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
if not (shop_type == "find_image" and agent._is_batch_quote_enabled(message.from_id, message.acc_id)):
return None
incoming_urls = agent._extract_image_urls(customer_text)
text_without_urls = agent._strip_urls_from_text(customer_text)
short_intent = agent._classify_short_customer_text(text_without_urls)
if incoming_urls:
is_related_followup = bool(text_without_urls and agent._is_related_image_followup_intent(text_without_urls))
for u in incoming_urls:
if u not in state.pending_image_urls:
state.pending_image_urls.append(u)
if text_without_urls:
agent._append_requirement(state, text_without_urls)
if is_related_followup:
agent._append_requirement(state, "与上一张相关(截图/局部细节)")
state.image_count = len(state.pending_image_urls)
agent._refresh_quote_phase(state, "collecting")
agent._sync_pending_quote_state(message.from_id, state)
if agent._is_batch_finish_intent(
text=customer_text,
state=state,
has_incoming_urls=bool(incoming_urls),
):
should_defer = agent._should_defer_batch_quote(state, mark_ready=True)
agent._sync_pending_quote_state(message.from_id, state)
if should_defer:
defer_fallback = "图片和需求我都收齐了,我先整理下,马上给你报总价。"
defer_reply = await agent._render_collection_reply_with_ai(
message=message,
state=state,
scene="quote_defer_notice",
intent_hint="确认已收齐图片与需求,先承接,告知稍后马上报价。",
fallback=defer_fallback,
)
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", defer_reply)
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
quote_res = await agent._quote_pending_images(state, message)
reply_text = agent._colloquialize_reply(quote_res.get("reply", ""))
reply_text = await agent._rewrite_reply_with_ai(
message=message,
state=state,
reply=reply_text,
scene="batch_quote_reply",
)
need_transfer = bool(quote_res.get("need_transfer"))
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", reply_text)
return AgentResponse(
reply=reply_text,
should_reply=not need_transfer,
need_transfer=need_transfer,
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
)
ack_fallback = "图片收到了,你有补充就继续发,我这边一起看。"
ack_intent = (
"告知图片已收到;如果客户继续发图就继续收,发完可统一报价。"
if not is_related_followup
else "告知这是和上一张相关的截图/局部图,已按同一需求一起处理。"
)
ack = await agent._render_collection_reply_with_ai(
message=message,
state=state,
scene="collect_ack",
intent_hint=ack_intent,
fallback=ack_fallback,
)
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", ack)
return AgentResponse(reply=ack, should_reply=True, need_transfer=False)
if not state.pending_image_urls:
return None
if text_without_urls:
if short_intent == "finish_signal":
agent._mark_quote_ready(state)
elif short_intent == "progress_query":
if state.quote_phase != "ready_to_quote":
agent._refresh_quote_phase(state, "waiting_result")
elif short_intent == "ack":
if state.quote_phase != "ready_to_quote":
agent._refresh_quote_phase(state, "collecting")
else:
agent._append_requirement(state, text_without_urls)
agent._refresh_quote_phase(state, "collecting")
agent._sync_pending_quote_state(message.from_id, state)
if agent._is_find_image_not_edit_conflict(text_without_urls):
clarify_fallback = "明白你是要找图,不是做图。你说下要找原图、同款还是高清版,我按这个给你找。"
clarify = await agent._render_collection_reply_with_ai(
message=message,
state=state,
scene="find_not_edit_clarify",
intent_hint="确认客户要找图不是做图,并追问是找原图/同款/高清版。",
fallback=clarify_fallback,
)
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", clarify)
return AgentResponse(reply=clarify, should_reply=True, need_transfer=False)
if state.quote_phase == "ready_to_quote" and state.quote_ready_turns <= 0 and short_intent in {"progress_query", "ack", "finish_signal"}:
quote_res = await agent._quote_pending_images(state, message)
reply_text = agent._colloquialize_reply(quote_res.get("reply", ""))
reply_text = await agent._rewrite_reply_with_ai(
message=message,
state=state,
reply=reply_text,
scene="batch_quote_reply",
)
need_transfer = bool(quote_res.get("need_transfer"))
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", reply_text)
return AgentResponse(
reply=reply_text,
should_reply=not need_transfer,
need_transfer=need_transfer,
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
)
if short_intent == "progress_query" or agent._is_result_followup_query(text_without_urls):
progress_fallback = "我这边在跟进了,一有结果马上发你。"
progress = await agent._render_collection_reply_with_ai(
message=message,
state=state,
scene="collect_progress",
intent_hint="承接客户的进度/结果追问,简短说明正在跟进,有结果会第一时间回复。",
fallback=progress_fallback,
)
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", progress)
return AgentResponse(reply=progress, should_reply=True, need_transfer=False)
if agent._needs_clarification_in_collecting(text_without_urls):
ask_fallback = "你再补一句具体要什么效果,我马上按你的要求来。"
ask = await agent._render_collection_reply_with_ai(
message=message,
state=state,
scene="collect_clarify",
intent_hint="客户表达不清,礼貌请对方补充一句关键需求,不要机械,不要生硬。",
fallback=ask_fallback,
)
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", ask)
return AgentResponse(reply=ask, should_reply=True, need_transfer=False)
if agent._is_batch_finish_intent(
text=customer_text,
state=state,
has_incoming_urls=False,
):
should_defer = agent._should_defer_batch_quote(state, mark_ready=True)
agent._sync_pending_quote_state(message.from_id, state)
if should_defer:
defer_fallback = "收到,我先把这批图过一遍,马上给你总价。"
defer_reply = await agent._render_collection_reply_with_ai(
message=message,
state=state,
scene="quote_defer_notice",
intent_hint="确认已收齐,先承接并告知稍后马上报价。",
fallback=defer_fallback,
)
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", defer_reply)
return AgentResponse(reply=defer_reply, should_reply=True, need_transfer=False)
quote_res = await agent._quote_pending_images(state, message)
reply_text = agent._colloquialize_reply(quote_res.get("reply", ""))
reply_text = await agent._rewrite_reply_with_ai(
message=message,
state=state,
reply=reply_text,
scene="batch_quote_reply",
)
need_transfer = bool(quote_res.get("need_transfer"))
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", reply_text)
return AgentResponse(
reply=reply_text,
should_reply=not need_transfer,
need_transfer=need_transfer,
transfer_msg=TRANSFER_MESSAGE if need_transfer else "",
)
remind_fallback = "需求我记上了,你继续发图,或者让我直接给你报价都行。"
remind = await agent._render_collection_reply_with_ai(
message=message,
state=state,
scene="collect_remind",
intent_hint="确认需求已记录,引导客户继续补图或直接让你报价。",
fallback=remind_fallback,
)
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", remind)
return AgentResponse(reply=remind, should_reply=True, need_transfer=False)

View File

@@ -1,55 +0,0 @@
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger("cs_agent")
async def handle_image_workflow(*, workflow_router: Any, message: str, data: dict, image_urls: list) -> bool:
"""处理图片工作流(根据客户说的话判断执行哪种工作流)。"""
if not image_urls:
return False
workflow_type, confidence = workflow_router.detect_workflow(message)
customer_id = data.get("from_id")
acc_id = data.get("acc_id", "")
acc_type = data.get("acc_type", "AliWorkbench")
image_url = image_urls[0]
logger.info("[Agent] 检测到工作流类型:%s (置信度:%s)", workflow_type, confidence)
if workflow_type == "find_image":
logger.info("[Agent] 执行查找图片工作流 | 客户:%s", customer_id)
from core.workflow import workflow
return await workflow.find_image_workflow(
customer_id=customer_id,
image_url=image_url,
acc_id=acc_id,
acc_type=acc_type,
)
if workflow_type == "process_image":
logger.info("[Agent] 执行处理图片工作流 | 客户:%s", customer_id)
from core.workflow import workflow
return await workflow.process_image_workflow(
customer_id=customer_id,
image_url=image_url,
acc_id=acc_id,
acc_type=acc_type,
)
if workflow_type == "transfer_human":
logger.info("[Agent] 执行转人工派单工作流 | 客户:%s", customer_id)
from core.workflow import workflow
return await workflow.transfer_to_designer_workflow(
customer_id=customer_id,
image_url=image_url,
acc_id=acc_id,
acc_type=acc_type,
reason="客户主动要求转人工",
)
return False

View File

@@ -1,113 +0,0 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from core.ai_reply_flow import execute_ai_turn
from core.find_image_flow import handle_find_image_batch_flow
from core.order_flow import handle_order_notification
from core.prompt_flow import build_prompt_bundle
from core.reply_finalize_flow import finalize_ai_reply
from utils.metrics_tracker import emit as metrics_emit
from utils.observability import build_trace_id
logger = logging.getLogger("cs_agent")
async def process_incoming_message(agent: Any, message: Any) -> Any:
"""主消息处理编排:预处理 -> 业务流 -> AI -> 收尾。"""
trace_id = build_trace_id(message.acc_id, message.from_id, message.msg_id, message.msg[:64])
agent._activity_log(
"agent_inbound",
trace_id=trace_id,
acc_id=message.acc_id,
customer_id=message.from_id,
msg=message.msg,
msg_type=message.msg_type,
)
metrics_emit("inbound_msg", customer_id=message.from_id, acc_id=message.acc_id)
state = agent._get_conversation_state(message.from_id)
pre_response = await agent.pre_rule_service.run(message=message, state=state, trace_id=trace_id)
if pre_response is not None:
return pre_response
new_stage = agent._detect_stage(message.msg)
if new_stage != state.stage:
state.stage = new_stage
from datetime import datetime
state.last_update = datetime.now().isoformat()
order_response = await handle_order_notification(agent, message=message, state=state)
if order_response is not None:
return order_response
customer_text, _ = agent._split_customer_text(message.msg)
shop_type = agent._get_shop_type(message.acc_id or "", message.goods_name or "")
flow_response = await handle_find_image_batch_flow(
agent,
message=message,
state=state,
customer_text=customer_text,
shop_type=shop_type,
)
if flow_response is not None:
return flow_response
prompt_bundle = build_prompt_bundle(agent, message=message, state=state)
user_prompt = prompt_bundle.user_prompt
deps = prompt_bundle.deps
history = prompt_bundle.history
agent._log_block("PROMPT->AI 前置提示词", user_prompt)
try:
reply_text = await execute_ai_turn(
agent,
message=message,
state=state,
user_prompt=user_prompt,
deps=deps,
history=history,
)
except Exception as e:
err_str = str(e)
logger.exception("[Agent] AI 调用失败,使用兜底回复: %s", err_str)
agent._activity_log("agent_ai_error", customer_id=message.from_id, acc_id=message.acc_id, error=err_str)
metrics_emit("ai_call_failed", customer_id=message.from_id, acc_id=message.acc_id)
if "AccountOverdueError" in err_str or "overdue" in err_str.lower():
asyncio.create_task(agent._notify_wechat_overdue())
else:
asyncio.create_task(
agent._notify_wechat(
f"⚠️ **AI调用异常**\n"
f"客户:{message.from_id}\n"
f"店铺:{message.acc_id}\n"
f"错误:{err_str[:200]}",
tag="AI异常",
)
)
reply_text = None
else:
metrics_emit("ai_call_success", customer_id=message.from_id, acc_id=message.acc_id)
if not reply_text:
fallback_text = await agent._rewrite_reply_with_ai(
message=message,
state=state,
reply="好嘞,你稍等下,我这边看一下",
scene="fallback_reply",
)
from core.pydantic_ai_agent import AgentResponse
return AgentResponse(reply=fallback_text, should_reply=True, need_transfer=False)
return await finalize_ai_reply(
agent,
message=message,
state=state,
reply_text=reply_text,
)

814
core/orchestrator.py Normal file
View File

@@ -0,0 +1,814 @@
import logging
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
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 = 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:
"""
全系统总编排:具备转接冷却、防抖合并、多消息去重、以及精准日志。
"""
def __init__(self, ws_client=None):
self.ws_client = ws_client
self.qianniu_adapter = QianniuAdapter(ws_client)
self.brain = CustomerServiceBrain()
# 1. 消息 ID 去重
self._processed_msg_ids = deque(maxlen=MSG_DEDUP_CAPACITY)
# 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)
@staticmethod
def _has_transfer_intent(text: str) -> bool:
if not text:
return False
t = str(text)
keywords = ("转人工", "转接", "人工客服", "人工", "设计师", "叫人", "找人")
return any(k in t for k in keywords)
def _get_user_lock(self, user_id: str) -> asyncio.Lock:
if user_id not in self._user_locks:
self._user_locks[user_id] = asyncio.Lock()
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)
# 关键修复:确保 user_id 绝不为空
user_id = std_msg.user_id or str(raw_data.get("cy_id") or raw_data.get("from_id") or "unknown")
std_msg.user_id = user_id
# 店铺隔离:同一客户在不同店铺的对话独立处理
session_key = f"{user_id}@{std_msg.acc_id}"
# 订单消息 / 纯金额通知 / 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,
msg_text,
"in",
acc_id=std_msg.acc_id,
msg_type=std_msg.msg_type,
)
return
preview = (std_msg.content or "").replace("\n", "\\n")
if len(preview) > 120:
preview = preview[:120] + "..."
logger.info(
f"[监听消息] dir={direction} user={user_id} acc={std_msg.acc_id} "
f"type={std_msg.msg_type} images={len(std_msg.image_urls)} content={preview}"
)
# 过滤心跳;图片消息即使暂时没拿到 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,
msg_type=std_msg.msg_type,
)
return
# ID 去重
if std_msg.msg_id:
if std_msg.msg_id in self._processed_msg_ids: return
self._processed_msg_ids.append(std_msg.msg_id)
# 同一轮对话只在第一句先发固定承接,不经过 AI
now_ts = time.time()
last_inbound_ts = self._last_inbound_seen_time.get(session_key, 0.0)
self._last_inbound_seen_time[session_key] = now_ts
if now_ts - last_inbound_ts >= FIRST_GREETING_IDLE_SEC:
first_greet = StandardResponse(
reply_content="在的 亲",
metadata={"acc_id": std_msg.acc_id, "acc_type": std_msg.acc_type},
)
await self.qianniu_adapter.translate_outbound(first_greet, user_id)
await repo.save_chat(
platform,
user_id,
first_greet.reply_content,
"out",
acc_id=std_msg.acc_id,
msg_type=first_greet.msg_type,
)
# 进入防抖(使用 session_key 隔离不同店铺)
if session_key in self._debounce_tasks: self._debounce_tasks[session_key].cancel()
if session_key not in self._pending_messages: self._pending_messages[session_key] = []
self._pending_messages[session_key].append(std_msg)
self._debounce_tasks[session_key] = asyncio.create_task(self._debounced_process(session_key, user_id, platform))
except Exception as e:
logger.error(f"[Orchestrator] 处理失败: {e}")
async def _handle_order_packet(self, platform: str, msg: StandardMessage):
try:
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 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], requirement_text: str = ""):
"""后台静默分析图片,存入用户数据库用于数据标定"""
try:
from services.service_image_analyzer import image_analyzer_service
from db.customer_db import CustomerDatabase
db = CustomerDatabase()
profile = db.get_customer(session_key)
for url in (image_urls or [])[:1]:
try:
result = await image_analyzer_service.analyze(url, customer_requirement=requirement_text)
result_json = json.dumps(result, ensure_ascii=False)
# 更新最近一次分析
profile.last_image_analysis = result_json
profile.last_image_url = url
profile.last_gemini_prompt = result.get("gemini_prompt", "")
profile.last_aspect_ratio = result.get("aspect_ratio", "1:1")
profile.last_perspective = result.get("perspective", "no")
# 追加到历史记录保留最近20条
if profile.image_analysis_history is None:
profile.image_analysis_history = []
profile.image_analysis_history.append(result_json)
if len(profile.image_analysis_history) > 20:
profile.image_analysis_history = profile.image_analysis_history[-20:]
# 更新复杂度历史
complexity = result.get("complexity", "normal")
if profile.complexity_history is None:
profile.complexity_history = []
profile.complexity_history.append(complexity)
if len(profile.complexity_history) > 10:
profile.complexity_history = profile.complexity_history[-10:]
# 更新图片类型历史
proc_type = result.get("proc_type", "")
if proc_type and profile.image_type_history is not None:
if proc_type not in profile.image_type_history:
profile.image_type_history.append(proc_type)
logger.debug(f"[ImageAnalysis] session={session_key} 分析完成: {result.get('subject', '?')} | {complexity}")
except Exception as e:
logger.warning(f"[ImageAnalysis] 单张图片分析失败: {e}")
continue
# 保存更新
db.save_customer(profile)
logger.info(f"[ImageAnalysis] session={session_key} 分析结果已保存到数据库")
except Exception as e:
logger.warning(f"[ImageAnalysis] 后台分析失败: {e}")
async def _debounced_process(self, session_key: str, user_id: str, platform: str):
try:
# 记录开始时间(防抖前)
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
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
for m in messages:
for url in m.image_urls:
if url not in all_image_urls: all_image_urls.append(url)
# 防抖合并后的消息仍需有 msg_id避免触发 StandardMessage 校验失败
merged_msg_id = messages[-1].msg_id if messages[-1].msg_id else f"merged-{user_id}-{int(time.time() * 1000)}"
final_msg = StandardMessage(
platform=platform,
msg_id=merged_msg_id,
user_id=user_id,
content=combined_content,
msg_type=messages[-1].msg_type,
image_urls=all_image_urls,
acc_id=acc_id,
acc_type=acc_type
)
# 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,
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, combined_content))
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)
# 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,
)
# 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
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:
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,
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):
global orchestrator
orchestrator = SystemOrchestrator(ws_client)
return orchestrator

View File

@@ -1,64 +0,0 @@
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, Optional
from core.post_ops import record_deal_success
from core.order_helpers import parse_order_info
logger = logging.getLogger("cs_agent")
if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentResponse, ConversationState, CustomerMessage, CustomerServiceAgent
async def handle_order_notification(
agent: "CustomerServiceAgent",
*,
message: "CustomerMessage",
state: "ConversationState",
) -> Optional["AgentResponse"]:
"""Handle system order notifications before normal AI dialogue."""
from core.pydantic_ai_agent import AgentResponse
if "系统订单信息" not in message.msg and "订单状态" not in message.msg:
return None
_, order_block = agent._split_customer_text(message.msg)
customer_text, _ = agent._split_customer_text(message.msg)
order = parse_order_info(order_block or message.msg)
pay_status = order.get("pay_status", "")
order_status = order.get("order_status", "")
paid_keywords = ["等待发货", "已付款", "付款成功", "买家已付款"]
is_paid = any(kw in pay_status or kw in order_status for kw in paid_keywords)
if is_paid:
asyncio.create_task(agent._check_order_amount(message.from_id, order, message.acc_id))
asyncio.create_task(
record_deal_success(
customer_id=message.from_id,
customer_name=message.from_name,
acc_id=message.acc_id,
platform=message.acc_type,
order=order,
state=state,
)
)
try:
from core.workflow import workflow
asyncio.create_task(
workflow.trigger_processing_on_payment(
customer_id=message.from_id,
acc_id=message.acc_id,
acc_type=message.acc_type,
)
)
except Exception as e:
logger.exception("[Agent] 触发作图失败: %s", e)
elif not customer_text:
logger.info("[Agent] 订单通知静默(%s),跳过回复", pay_status or order_status)
return AgentResponse(reply="", should_reply=False, need_transfer=False)
return None

View File

@@ -1,171 +0,0 @@
from __future__ import annotations
import logging
import re
from typing import Any
from utils.metrics_tracker import emit as metrics_emit
CASE_LIBRARY_LINK = "https://www.yuque.com/zuowei-dfvpq/kge0in/mynala0g35b8cec5"
logger = logging.getLogger("cs_agent")
def detect_price(reply: str, state: Any) -> None:
numbers = re.findall(r"(\d+)[元]", reply or "")
if not numbers:
return
price = round(int(numbers[0]) / 5) * 5
state.last_price = price
metrics_emit("quote_generated", customer_id=state.customer_id, price=price)
try:
from db.customer_db import db
db.update_last_price(state.customer_id, price)
except Exception:
pass
def detect_discount(message: str, state: Any) -> None:
text = message or ""
if any(kw in text for kw in ["", "便宜", "太贵", "有点贵"]):
state.discount_count += 1
if state.last_price:
try:
from db.customer_db import db
db.record_discount(state.customer_id, state.last_price)
except Exception:
pass
m = re.search(r"(\d+)\s*元|\b(\d+)\s*块", text)
offer = None
if m:
offer = int(m.group(1) or m.group(2))
if offer:
try:
from config.config import MIN_PRICE_FLOOR
if offer < MIN_PRICE_FLOOR:
state.last_price = state.last_price or 0
except Exception:
pass
def negotiation_strategy_reply(customer_text: str, state: Any) -> str:
text = (customer_text or "").strip()
if not text:
return ""
if any(k in text for k in ["先发效果图", "先看效果", "不放心", "没法确认"]):
return (
f"小妹整理了一些案例图,亲点这个链接就能看到啦({CASE_LIBRARY_LINK})。"
"有什么想要的效果随时告诉我哈,不满意我们这边包退。"
)
if "有点贵" in text or "就是贵" in text:
base = state.last_price if isinstance(state.last_price, int) and state.last_price > 0 else 25
two_pack = max(10, round(((base * 2) - 5) / 5) * 5)
return f"理解你这边的预算,我给你个实在点的:两张一起按 {two_pack} 元做,行不行?"
if any(k in text for k in ["优惠点", "便宜点", "少点", "打折"]):
return "可以的你这边数量上来我就好给价3张以上我给你打包价。"
return ""
async def record_deal_success(
*,
customer_id: str,
customer_name: str,
acc_id: str,
platform: str,
order: dict,
state: Any,
) -> None:
try:
from db.deal_outcome_db import record_deal
order_id = order.get("order_id", "")
raw_amount = order.get("amount", "")
m = re.search(r"[\d.]+", str(raw_amount))
amount = float(m.group()) if m else 0
reason = "让价后成交" if (state.discount_count or 0) > 0 else "直接成交"
record_deal(
customer_id=customer_id,
outcome="成交",
reason=reason,
customer_name=customer_name or "",
acc_id=acc_id or "",
platform=platform or "",
order_id=order_id,
amount=amount,
discount_given=(state.discount_count or 0) > 0,
)
try:
from db.customer_db import db
if order_id:
db.add_order(customer_id, order_id, amount)
db.clear_quote_no_convert(customer_id)
except Exception:
pass
logger.info("[Agent] 成交记录: %s %s %s", customer_id, reason, amount)
except Exception as e:
logger.exception("[Agent] 成交记录失败: %s", e)
async def record_deal_fail(
*,
customer_id: str,
customer_name: str,
acc_id: str,
platform: str,
reason: str,
) -> None:
try:
from db.deal_outcome_db import record_deal
from db.customer_db import db
record_deal(
customer_id=customer_id,
outcome="未成交",
reason=reason,
customer_name=customer_name or "",
acc_id=acc_id or "",
platform=platform or "",
)
db.mark_quote_no_convert(customer_id)
logger.info("[Agent] 未成交记录: %s %s", customer_id, reason)
except Exception as e:
logger.exception("[Agent] 未成交记录失败: %s", e)
async def auto_tag(message: Any, state: Any) -> None:
try:
from db.customer_db import db
cid = message.from_id
msg = (message.msg or "").lower()
if any(kw in msg for kw in ["还有", "多张", "好几张", "一批", "下次还"]):
db.set_bulk_potential(cid, "")
db.add_upsell_opportunity(cid, "批量打包")
if any(kw in msg for kw in ["psd", "分层", "源文件"]):
db.add_upsell_opportunity(cid, "分层PSD")
db.update_preferred_format(cid, "psd")
if "jpg" in msg or "jpeg" in msg:
db.update_preferred_format(cid, "jpg")
if "png" in msg:
db.update_preferred_format(cid, "png")
if any(kw in msg for kw in ["分辨率", "dpi", "尺寸", "大图", "印刷"]):
db.update_preferred_size(cid, message.msg[:30])
if any(kw in msg for kw in ["拍了", "下单了", "好的", ""]) and state.last_price:
db.update_decision_speed(cid, "")
type_keywords = {
"印花": ["印花", "花纹", "图案", "面料", "布料", "纺织"],
"logo": ["logo", "标志", "品牌", "商标"],
"人物": ["人物", "人像", "照片", "", "头像"],
"产品": ["产品", "商品", "包装", "实物"],
"老照片": ["老照片", "旧照片", "发黄", "修复"],
}
for img_type, keywords in type_keywords.items():
if any(kw in message.msg for kw in keywords):
db.add_image_type(cid, img_type)
break
db.auto_compute_tags(cid)
except Exception:
pass

View File

@@ -1,187 +0,0 @@
from __future__ import annotations
import re
from typing import Any, Callable
def split_customer_text(msg: str) -> tuple[str, str]:
"""
把混合消息拆分为(客户真实文字, 系统订单块)。
平台有时把客户文字和系统订单通知拼在同一条消息里。
"""
order_marker = re.search(r"\[系统订单信息\]|\[系统通知\]", msg or "")
if order_marker:
customer_text = (msg or "")[: order_marker.start()].strip()
order_block = (msg or "")[order_marker.start() :].strip()
else:
customer_text = (msg or "").strip()
order_block = ""
return customer_text, order_block
def build_prompt(
*,
message: Any,
state: Any,
extract_image_url: Callable[[str], str],
shop_type_resolver: Callable[[str, str], str],
parse_order_info: Callable[[str], dict[str, str]],
build_order_instruction: Callable[[str, str], str],
) -> str:
"""构建提示词。"""
msg_content = message.msg
stage_info = f"【当前阶段】{state.stage}"
customer_text, order_block = split_customer_text(msg_content)
has_order = bool(order_block)
if has_order:
order = parse_order_info(order_block)
if order.get("order_id"):
state.last_order_id = order["order_id"]
stage_info += f"\n【订单号】{order['order_id']}"
if order.get("order_status"):
state.order_status = order["order_status"]
stage_info += f"\n【订单状态】{order['order_status']}"
if order.get("pay_status"):
stage_info += f"\n【支付状态】{order['pay_status']}"
if order.get("amount"):
stage_info += f"\n【订单金额】{order['amount']}"
if order.get("quantity"):
stage_info += f"\n【数量】{order['quantity']}"
if order.get("order_time"):
stage_info += f"\n【下单时间】{order['order_time']}"
if order.get("buyer_note"):
stage_info += f"\n【买家备注】{order['buyer_note']}"
if state.discount_count > 0:
stage_info += f"\n【客户压价次数】{state.discount_count}"
shop_type = shop_type_resolver(message.acc_id or "", message.goods_name or "")
shop_hint = ""
try:
from config.config import CONFIG_DIR
import json
cfg_path = CONFIG_DIR / "shop_prompts.json"
if cfg_path.exists():
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
hints = cfg.get("type_hints", {})
shop_hint = hints.get(shop_type, "")
if not shop_hint and message.acc_id:
sh = cfg.get("shops", {}).get(message.acc_id, {})
shop_hint = sh.get("hint", "")
except Exception:
pass
prompt = f"""收到新消息:
{stage_info}
发送者: {message.from_name} ({message.from_id})
"""
if message.goods_name:
prompt += f"商品名称: {message.goods_name}\n"
if shop_hint:
prompt += f"\n{shop_hint}\n"
order_paid = False
order_unpaid = False
if has_order:
order = parse_order_info(order_block)
paid_kws = ["等待发货", "已付款", "付款成功", "买家已付款"]
unpaid_kws = ["等待买家付款", "待付款", "未付款"]
ps = order.get("pay_status", "")
os_ = order.get("order_status", "")
if any(kw in ps or kw in os_ for kw in paid_kws):
order_paid = True
elif any(kw in ps or kw in os_ for kw in unpaid_kws):
order_unpaid = True
progress_keywords = [
"安排了吗",
"安排好了吗",
"好了吗",
"做了吗",
"做好了吗",
"弄好了吗",
"好了没",
"做了没",
"什么时候好",
"多久好",
"进度",
"催一下",
"快点",
"什么时候能好",
"做完了吗",
]
if customer_text:
prompt += f"\n客户说:{customer_text}\n"
image_url = extract_image_url(customer_text)
price_keywords = ["多少钱", "多少", "价格", "几块", "怎么收费", "报个价"]
size_keywords = [
"尺寸",
"比例",
"",
"",
"",
"厘米",
"mm",
"cm",
"横版",
"竖版",
"2米",
"3米",
"改成",
"做成",
]
has_size_change = any(kw in customer_text.lower() for kw in [k.lower() for k in size_keywords])
if shop_type == "gemini_api":
prompt += "\n【Gemini API 店铺】客户问账号/pro/续费/套餐等,按 API 客服自然回复,不要求发图。"
elif image_url:
prompt += "\n客户在继续发图阶段:先确认“已收图”,并引导客户把图和要求一次发完;等客户明确“发完了/统一报价”后再统一报价。"
elif any(kw in customer_text for kw in price_keywords):
last_url = extract_image_url(msg_content)
if last_url:
prompt += "\n客户在询问价格:若客户已确认发完,则给总报价;若还在发图,先引导发完后统一报价。"
else:
prompt += "\n客户在询问价格但未发图:先简短承接(如“在看呢/收到”),不要机械连发;再自然引导对方发图。"
if has_size_change:
prompt += (
"\n⚠️ 尺寸改动场景:优先判断图片主体是否会被拉伸变形,"
"不是只看整张图宽高比。若会变形,要先提示“需要补图/扩边”,再给报价。"
)
elif has_size_change:
prompt += (
"\n客户在改尺寸/改比例:先按主体比例判断是否会变形,"
"不是只看整图比例。若目标尺寸会拉伸主体,先明确说明要补图(如上下补图/扩边)再报价。"
)
elif any(kw in customer_text for kw in progress_keywords):
if order_unpaid:
prompt += "\n⚠️【订单未付款】客户问安排进度,但订单还未付款。自然告知拍下付款后马上安排即可。"
elif order_paid:
prompt += "\n客户催单,订单已付款,自然回复在做了/快了之类。"
else:
prompt += "\n客户催单,查询当前处理状态后自然回复。"
elif any(kw in customer_text for kw in ["", "有点贵", "太贵", "算了", "便宜点", "少点", "打折", "贵哦"]):
prompt += "\n⚠️【客户嫌贵】客户已表达价格顾虑或要放弃。禁止追问「什么问题」「说清楚点」。\n→ 第一步:直接让价一次(如原价 20→15话术自然。\n→ 第二步:如果让价后客户还是嫌贵,引导客户自己去注册星绘平台:\n - 每个话术都必须带网址 xinhui.cloud\n - 强调「自己去弄」「自己操作」「自己下单」\n - 示例:「嫌贵就去 xinhui.cloud 注册个号自己有余额可以弄」「xinhui.cloud 去注册下,新用户送体验金,自己弄便宜」\n - 语气直接,让客户自己去操作」"
elif any(kw in customer_text for kw in ["擦边", "黄色", "色情", "大尺度", "性感图", "露点", "半裸"]):
prompt += "\n⚠️【拒绝】客户询问擦边/黄色/敏感内容。直接拒绝,不接单,不说「发图来看看」。自然回复如:这类不做/不接/做不了。"
else:
prompt += "\n根据客户说的内容自然回应,像真人聊天,不要套模板。"
if has_order:
order = parse_order_info(order_block)
order_instruction = build_order_instruction(order.get("pay_status", ""), order.get("order_status", ""))
if customer_text:
if not order_unpaid:
prompt += f"\n\n【背景参考-订单通知】{order_instruction}"
else:
prompt += f"\n\n{order_instruction}"
if not customer_text and not has_order:
prompt += f"\n消息内容: {msg_content}\n请按工作流规则回复。"
return prompt

View File

@@ -1,50 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentDeps, ConversationState, CustomerMessage, CustomerServiceAgent
@dataclass
class PromptBundle:
user_prompt: str
deps: "AgentDeps"
history: List
def build_prompt_bundle(
agent: "CustomerServiceAgent",
*,
message: "CustomerMessage",
state: "ConversationState",
) -> PromptBundle:
from core.pydantic_ai_agent import AgentDeps
user_prompt = agent._build_prompt(message, state)
profile_context = agent._get_customer_profile_context(message.from_id)
if profile_context:
user_prompt = profile_context + "\n\n" + user_prompt
refusal_hint = agent._get_refusal_context_hint(message.from_id, message.msg, profile_context or "")
if refusal_hint:
user_prompt = refusal_hint + "\n\n" + user_prompt
conv_context = agent._get_conversation_context(message.from_id, acc_id=message.acc_id or "")
if conv_context:
user_prompt = conv_context + user_prompt
intent_hint = agent._get_intent_emotion_hint(message.msg)
if intent_hint:
user_prompt = intent_hint + "\n\n" + user_prompt
deps = AgentDeps(
msg_id=message.msg_id,
acc_id=message.acc_id,
from_id=message.from_id,
platform=message.acc_type,
)
history = agent.message_histories.get(message.from_id, [])
return PromptBundle(user_prompt=user_prompt, deps=deps, history=history)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,651 @@
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, 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 ""
text = str(text)
if len(text) <= limit:
return text
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:
return "--:--:--"
if " " in s:
return s.split(" ", 1)[1]
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 大脑:
【全能终极版】统一称呼为“设计师”,支持下线安抚。
"""
def __init__(self, model_name: str = None):
self.api_key = os.getenv("OPENAI_API_KEY")
self.base_url = os.getenv("OPENAI_BASE_URL")
self.model_name = model_name or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
model = OpenAIChatModel(
model_name=self.model_name,
provider=OpenAIProvider(api_key=self.api_key, base_url=self.base_url)
)
exclude_names = os.getenv("SKILL_EXCLUDE_FROM_PROMPT", "pricing-skill")
excluded_skills = [s.strip().lower() for s in exclude_names.split(",") if s.strip()]
all_skills = skill_manager.get_all_skills_text(exclude=excluded_skills)
logger.info(f"[SkillManager] 已从提示词排除技能: {excluded_skills}")
# --- 统一口径后的 System Prompt ---
system_prompt = (
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话自然、专业。\n\n"
"【统一称呼规范 - 第一人称原则】\n"
"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"
"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"
"6. **严禁**输出任何代码、标记、括号等乱码!只输出自然语言。\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: Optional[List[dict]] = None) -> StandardResponse:
if history is None:
history = []
try:
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 = []
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"【当前客户ID{msg.user_id}\n{recent_context}现在的对话:{user_content}"
start_time = time.time()
# ===== 详细日志:发给 AI 的提示词 =====
logger.info(f"[AI提示词] user={msg.user_id} acc={msg.acc_id} images={len(msg.image_urls)}\n{full_input}")
if history:
history_preview = "\n".join([f" {h.get('role','?')}: {str(h.get('content',''))[:50]}" for h in history[-4:]])
logger.info(f"[AI历史上下文] 共{len(history)}条:\n{history_preview}")
# 尝试运行 AI捕获转接成功异常以提前终止
try:
result = await self.agent.run(full_input, message_history=history)
except TransferSuccessException as e:
# 转接工具成功后立即返回,无需等待 AI 继续生成
elapsed = time.time() - start_time
logger.info(f"[Brain] 转接成功(提前终止,耗时{elapsed:.1f}s: {e.transfer_cmd[:60]}")
return StandardResponse(
reply_content=e.transfer_cmd,
need_transfer=True,
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
)
elapsed = time.time() - start_time
logger.info(f"[Brain] AI处理完成总耗时{elapsed:.1f}s")
# ===== 详细日志AI 的思考过程和工具调用 =====
pending_transfer_reason = ""
pending_transfer_error = ""
try:
all_msgs = result.all_messages()
for idx, m in enumerate(all_msgs):
msg_kind = getattr(m, 'kind', type(m).__name__)
if hasattr(m, 'parts'):
for part in m.parts:
part_kind = getattr(part, 'part_kind', '')
if part_kind == 'tool-call':
tool_name = getattr(part, 'tool_name', '?')
tool_args = getattr(part, 'args', {})
logger.info(f"[AI思考] 步骤{idx+1} 调用工具: {tool_name}({tool_args})")
if tool_name == "transfer_to_human_tool":
if isinstance(tool_args, str):
try:
tool_args = json.loads(tool_args)
except Exception:
tool_args = {"reason": tool_args}
if isinstance(tool_args, dict):
pending_transfer_reason = str(tool_args.get("reason") or "").strip()
elif part_kind == 'tool-return':
content = str(getattr(part, 'content', ''))[:200]
logger.info(f"[AI思考] 步骤{idx+1} 工具返回: {content}")
full_content = str(getattr(part, 'content', ''))
if full_content.startswith("ERROR_DESIGNER_"):
pending_transfer_error = full_content
elif part_kind == 'text':
content = str(getattr(part, 'content', ''))[:150]
if content.strip():
logger.info(f"[AI思考] 步骤{idx+1} 文本输出: {content}")
except Exception as log_err:
logger.debug(f"[AI思考日志] 解析失败: {log_err}")
# --- 转接指令:直接从工具返回截获,不经过 AI 二次加工 ---
transfer_cmd = ""
for m in result.all_messages():
if hasattr(m, 'parts'):
for part in m.parts:
if getattr(part, 'part_kind', '') == 'tool-return':
content = str(getattr(part, 'content', ''))
if "[转移会话]" in content:
transfer_cmd = content
if transfer_cmd:
logger.info(f"[Brain] 工具返回转接指令直接发送跳过AI加工: {transfer_cmd[:60]}")
return StandardResponse(
reply_content=transfer_cmd,
need_transfer=True,
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
)
# --- 非转接场景:取 AI 的正常回复 ---
reply_text = ""
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:
reply_text = reply_text.replace("在呢铁子", "在呢亲")
if not reply_text:
reply_text = "稍等我看看。"
logger.info(f"[THINK/RAW_OUTPUT] user={msg.user_id}\n{_clip(reply_text)}")
need_transfer = "[转移会话]" in reply_text
return StandardResponse(
reply_content=reply_text,
need_transfer=need_transfer,
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}")
await _notify_brain_fallback(msg, e, history)
return StandardResponse(reply_content="好哒,我在看图,稍等回你哈。", metadata={"acc_id": msg.acc_id})

View File

@@ -1,112 +0,0 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from utils.metrics_tracker import emit as metrics_emit
from core.post_ops import auto_tag, detect_discount, detect_price, record_deal_fail
logger = logging.getLogger("cs_agent")
if TYPE_CHECKING:
from core.pydantic_ai_agent import AgentResponse, ConversationState, CustomerMessage, CustomerServiceAgent
async def finalize_ai_reply(
agent: "CustomerServiceAgent",
*,
message: "CustomerMessage",
state: "ConversationState",
reply_text: str,
) -> "AgentResponse":
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
try:
from utils.content_filter import should_block_reply
blocked, fallback = should_block_reply(reply_text)
if blocked:
logger.warning("[Agent] 敏感词拦截,使用兜底回复")
reply_text = fallback or "好的,您稍等,我帮您确认一下"
except Exception:
pass
try:
from utils.api_cost_tracker import record
record("openai_chat", count=1)
except Exception:
pass
detect_price(reply_text, state)
detect_discount(message.msg, state)
asyncio.create_task(auto_tag(message, state))
need_transfer = False
transfer_msg = ""
transfer_keywords = ["TRANSFER_REQUESTED", "[转移会话]", "转移会话", "转人工", "转接"]
if reply_text and any(kw in reply_text for kw in transfer_keywords):
need_transfer = True
transfer_msg = TRANSFER_MESSAGE
metrics_emit("transfer_to_human", customer_id=message.from_id, acc_id=message.acc_id)
evo_hit = agent._evolution_enabled_for_customer(message.from_id)
if evo_hit and agent._is_service_risk_inquiry(message.msg):
if agent._evolution_has_proposal("policy-risk-transfer"):
need_transfer = True
transfer_msg = TRANSFER_MESSAGE
metrics_emit("evolution_force_transfer", customer_id=message.from_id, acc_id=message.acc_id)
if agent._evolution_has_proposal("tone-empathy-pack"):
reply_text = "抱歉让您不舒服了,这边先为您转接人工专员马上处理。"
metrics_emit("evolution_empathy_reply", customer_id=message.from_id, acc_id=message.acc_id)
customer_text, _ = agent._split_customer_text(message.msg)
no_convert_keywords = ["算了", "不要了", "不做了", "下次再说", "先不弄了"]
if customer_text and state.last_price and state.last_price > 0:
if any(kw in customer_text for kw in no_convert_keywords):
reason = "嫌贵放弃" if any(k in customer_text for k in ["", "贵了", "便宜"]) else "放弃"
asyncio.create_task(
record_deal_fail(
customer_id=message.from_id,
customer_name=message.from_name,
acc_id=message.acc_id,
platform=message.acc_type,
reason=reason,
)
)
should_reply = bool(reply_text and reply_text.strip()) and not need_transfer
if evo_hit and need_transfer and agent._evolution_has_proposal("tone-empathy-pack"):
should_reply = True
if should_reply:
reply_text = await agent._rewrite_reply_with_ai(
message=message,
state=state,
reply=reply_text,
scene="final_reply",
)
if should_reply:
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", reply_text)
else:
logger.info("[REPLY->CUSTOMER] <静默/不发送>")
agent._activity_log(
"agent_outbound_decision",
customer_id=message.from_id,
should_reply=should_reply,
need_transfer=need_transfer,
reply=reply_text or "",
transfer_msg=transfer_msg,
)
return AgentResponse(
reply=reply_text or "",
should_reply=should_reply,
need_transfer=need_transfer,
transfer_msg=transfer_msg,
)

134
core/repository.py Normal file
View File

@@ -0,0 +1,134 @@
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
from db.image_tasks_db import db as task_db
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 阻塞。
"""
def __init__(self):
self.customer_db = customer_db
self.task_db = task_db
# --- 聊天记录 (异步化) ---
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(
log_message,
customer_id=user_id,
message=content,
direction=direction,
platform=platform,
acc_id=acc_id,
msg_type=msg_type,
image_urls=urls_str
)
async def get_chat_history(self, user_id: str, limit: int = 10, acc_id: str = "") -> List[dict]:
"""异步获取历史记录"""
rows = await asyncio.to_thread(get_conversation, user_id, limit=limit, acc_id=acc_id)
history = []
for r in rows:
role = "user" if r["direction"] == "in" else "assistant"
history.append(
{
"role": role,
"content": r["message"],
"msg_type": r.get("msg_type", 0),
"image_urls": r.get("image_urls", ""),
"timestamp": r.get("timestamp", ""),
}
)
return history
# --- 客户相关 (异步化) ---
async def get_customer(self, platform: str, user_id: str):
customer_key = f"{platform}:{user_id}"
return await asyncio.to_thread(self.customer_db.get_customer, customer_key)
# --- 任务相关 (异步化) ---
async def create_task(self, platform: str, user_id: str, image_url: str, operation: str, requirements: str = ""):
return await asyncio.to_thread(
self.task_db.add_task,
customer_id=user_id,
platform=platform,
original_image=image_url,
operation=operation,
requirements=requirements,
status="pending"
)
async def update_task_price(self, platform: str, user_id: str, price: float):
"""异步记录成交价"""
return await asyncio.to_thread(self.task_db.update_price, user_id, platform, price)
async def update_task_outcome(self, platform: str, user_id: str, outcome: str):
"""异步记录最终结局"""
return await asyncio.to_thread(self.task_db.update_outcome, user_id, platform, outcome)
# 全局异步仓库单例
repo = DataRepository()

View File

@@ -1,71 +0,0 @@
from __future__ import annotations
import re
def is_political_inquiry(text: str) -> bool:
"""文本前置风控:政治人物/政治事件/政治图片相关询问一律拒绝。"""
s = (text or "").strip().lower()
if not s:
return False
kw = (
"政治",
"涉政",
"党政",
"政治人物",
"政治事件",
"政治图片",
"政治海报",
"政治宣传",
"领导人",
"伟人",
"元帅",
"将军",
"红色人物",
"党史",
"天安门",
"人民大会堂",
"中南海",
"习近平",
"毛泽东",
"邓小平",
"江泽民",
"胡锦涛",
"李克强",
"周恩来",
"特朗普",
"拜登",
"普京",
"泽连斯基",
"trump",
"biden",
"putin",
"zelensky",
"xi jinping",
)
if any(k in s for k in kw):
return True
return bool(re.search(r"(元帅|将军|领导人|政治人物|政治事件).*(照片|图片|头像|原图)?", s))
def is_map_inquiry(text: str) -> bool:
"""地图类需求一律拒绝(按业务规则)。"""
s = (text or "").strip().lower()
if not s:
return False
kw = (
"地图",
"地形图",
"行政区划图",
"世界地图",
"中国地图",
"卫星地图",
"导航图",
"航海图",
"作战地图",
"军事地图",
"map",
"topographic map",
"satellite map",
)
return any(k in s for k in kw)

View File

@@ -1,3 +0,0 @@
from .engine import Rule, RuleContext, RuleEngine, RuleResult
__all__ = ["Rule", "RuleContext", "RuleEngine", "RuleResult"]

View File

@@ -1,59 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional
@dataclass
class RuleContext:
data: Dict[str, Any] = field(default_factory=dict)
def get(self, key: str, default: Any = None) -> Any:
return self.data.get(key, default)
def set(self, key: str, value: Any) -> None:
self.data[key] = value
@dataclass
class RuleResult:
matched: bool = False
stop: bool = False
action: str = ""
payload: Dict[str, Any] = field(default_factory=dict)
Predicate = Callable[[RuleContext], Awaitable[bool]]
Action = Callable[[RuleContext], Awaitable[RuleResult]]
@dataclass
class Rule:
name: str
priority: int
predicate: Predicate
action: Action
class RuleEngine:
"""Priority-ordered async rule chain."""
def __init__(self, rules: Optional[List[Rule]] = None):
self._rules: List[Rule] = sorted(rules or [], key=lambda x: x.priority)
def add_rule(self, rule: Rule) -> None:
self._rules.append(rule)
self._rules.sort(key=lambda x: x.priority)
async def run(self, ctx: RuleContext) -> RuleResult:
for rule in self._rules:
if not await rule.predicate(ctx):
continue
result = await rule.action(ctx)
if not result.matched:
result.matched = True
if not result.action:
result.action = rule.name
if result.stop:
return result
return RuleResult(matched=False, stop=False, action="no_match")

30
core/schema.py Normal file
View File

@@ -0,0 +1,30 @@
from pydantic import BaseModel, Field
from typing import List, Optional, Any
from datetime import datetime
class StandardMessage(BaseModel):
"""全平台通用的输入消息协议"""
platform: str = "qianniu" # 来源平台qianniu, wechat, feishu, console
msg_id: str # 消息唯一ID
user_id: str # 发送者唯一ID
user_name: str = "" # 发送者昵称
content: str # 消息文本内容
msg_type: int = 0 # 消息类型0 文本, 1 图片, 2 语音等
image_urls: List[str] = Field(default_factory=list) # 提取出来的图片链接
acc_id: str = "" # 商家/店铺账号ID
acc_type: str = "" # 平台类型标识
timestamp: datetime = Field(default_factory=datetime.now)
raw_data: Any = None # 原始消息体(仅供调试或特殊逻辑备查)
# 扩展字段:针对电商场景
goods_name: Optional[str] = None
goods_order: Optional[str] = None
class StandardResponse(BaseModel):
"""大脑给出的通用回复协议"""
reply_content: str # 回复文本或图片URL
msg_type: int = 0 # 0: 文本, 1: 图片, 2: 撤回, 9: 转人工
should_reply: bool = True # 是否需要发送
need_transfer: bool = False # 是否触发转人工
transfer_group: str = "" # 转人工的分组ID
metadata: dict = Field(default_factory=dict) # 额外元数据(如埋点、调试信息)

59
core/skill_manager.py Normal file
View File

@@ -0,0 +1,59 @@
import os
import glob
import logging
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger("cs_agent")
class SkillManager:
"""
技能包管理器:
1. 自动扫描 skills/ 目录下的 SKILL.md 文件。
2. 提供按需加载和组合技能的能力。
3. 支持热加载(无需重启即可更新 AI 知识)。
"""
def __init__(self, skills_dir: str = "skills"):
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()
def reload_skills(self):
"""扫描并加载所有技能文件"""
new_cache = {}
skill_files = glob.glob(str(self.skills_dir / "**/SKILL.md"), recursive=True)
for file_path in skill_files:
try:
path = Path(file_path)
skill_name = path.parent.name.lower()
content = path.read_text(encoding="utf-8")
new_cache[skill_name] = content
except Exception as e:
logger.error(f"[SkillManager] 加载技能失败 {file_path}: {e}")
self._skill_cache = new_cache
logger.info(f"[SkillManager] 成功加载 {len(self._skill_cache)} 个技能包: {list(self._skill_cache.keys())}")
def get_skill(self, name: str) -> str:
"""获取单个技能内容"""
return self._skill_cache.get(name.lower(), "")
def compose_skills(self, names: List[str]) -> str:
"""组合多个技能内容,用于注入 System Prompt"""
parts = []
for name in names:
content = self.get_skill(name)
if content:
parts.append(f"### 技能:{name}\n{content}")
return "\n\n".join(parts)
def get_all_skills_text(self, exclude: Optional[List[str]] = None) -> str:
"""获取所有技能的合集(用于全能大脑模式)"""
exclude_set = {n.lower() for n in (exclude or [])}
names = [n for n in self._skill_cache.keys() if n not in exclude_set]
return self.compose_skills(names)
# 全局单例
skill_manager = SkillManager()

View File

@@ -7,11 +7,16 @@ import asyncio
import logging
from typing import Optional, Dict
from datetime import datetime
from .websocket_client import QingjianAPIClient
from .websocket_client_v2 import QingjianAPIClient
from db.task_db.task_model import get_task_manager, TaskStatus, TaskPriority
logger = logging.getLogger(__name__)
# 配置常量
TIMEOUT_CHECK_INTERVAL_SEC = 300 # 超时检查间隔5分钟
ERROR_RETRY_DELAY_SEC = 60 # 错误后重试延迟1分钟
QUEUE_POLL_INTERVAL_SEC = 1 # 队列轮询间隔(秒)
class TaskScheduler:
"""任务调度器"""
@@ -54,14 +59,14 @@ class TaskScheduler:
# 通知天网任务超时
await self._notify_tianwang(task['task_id'], 'timeout')
# 每 5 分钟检查一次
await asyncio.sleep(300)
# 每隔固定时间检查一次
await asyncio.sleep(TIMEOUT_CHECK_INTERVAL_SEC)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"超时检查失败:{e}")
await asyncio.sleep(60)
await asyncio.sleep(ERROR_RETRY_DELAY_SEC)
async def _process_task_queue(self):
"""处理任务队列"""
@@ -69,8 +74,8 @@ class TaskScheduler:
while self.running:
try:
# 这里实际应该从队列获取任务
# 简化处理:每秒检查一次待触发任务
await asyncio.sleep(1)
# 简化处理:定期检查待触发任务
await asyncio.sleep(QUEUE_POLL_INTERVAL_SEC)
except Exception as e:
logger.error(f"任务队列处理失败:{e}")

File diff suppressed because it is too large Load Diff

108
core/websocket_client_v2.py Normal file
View File

@@ -0,0 +1,108 @@
import asyncio
import hashlib
import json
import logging
import os
from datetime import datetime
from core.orchestrator import init_orchestrator
from core.websocket_connection_flow import connect_flow, receive_messages_flow
from core.websocket_send_flow import send_message_flow
from utils.observability import emit_activity
logger = logging.getLogger("cs_agent")
class QingjianAPIClient:
"""
重构后的轻简API客户端 (协议全复刻版)
"""
def __init__(self, uri=None, enable_agent: bool = True, worker_id: int = -1, worker_count: int = 1):
from config.config import QINGJIAN_WS_URI
self.uri = uri or QINGJIAN_WS_URI
self.websocket = None
self.running = True
self.logger = logger
self.enable_agent = enable_agent
# 多进程分片逻辑
self.worker_id = worker_id
self.worker_count = worker_count
if self.worker_id >= 0:
logger.info(f"[WebSocket] 启用分片模式: Worker {self.worker_id}/{self.worker_count}")
# 初始化新架构总指挥部
self.orchestrator = init_orchestrator(ws_client=self)
logger.info("[WebSocket] 新架构 Orchestrator 已就绪。")
def _activity_log(self, event: str, **kwargs):
emit_activity(logger, event=event, **kwargs)
async def connect(self):
await connect_flow(self)
async def receive_messages(self):
await receive_messages_flow(self)
def _should_handle(self, customer_id: str) -> bool:
"""分片判定:这个客户归我管吗?"""
if self.worker_id < 0 or self.worker_count <= 1:
return True
# 如果没有 customer_id为了安全起见只让 Worker 0 处理
if not customer_id:
return self.worker_id == 0
hash_val = int(hashlib.md5(str(customer_id).encode("utf-8")).hexdigest(), 16)
return (hash_val % self.worker_count) == self.worker_id
async def handle_message(self, message):
"""收到消息处理"""
try:
data = json.loads(message)
# 预提取客户ID用于分片判定
customer_id = str(data.get("cy_id") or data.get("from_id") or "")
if not self._should_handle(customer_id):
return
await self.orchestrator.on_raw_message_received(platform="qianniu", raw_data=data)
except Exception as e:
raw_preview = str(message).replace("\n", "\\n")
if len(raw_preview) > 300:
raw_preview = raw_preview[:300] + "..."
logger.error(f"[WebSocket] 处理消息异常: {e} raw={raw_preview}")
async def send(self, customer_id: str, acc_id: str, acc_type: str, content: str, msg_type: int = 0):
"""
【协议全复刻】严格按照 legacy/websocket_outbound_flow.py 的结构
"""
# 注意:在这里 from_id 竟然填的是 customer_id这是逆向接口的特殊要求
msg_payload = {
"msg_id": "",
"acc_id": acc_id,
"msg": content,
"from_id": customer_id,
"from_name": "",
"cy_id": customer_id,
"acc_type": acc_type,
"msg_type": msg_type,
"cy_name": "",
}
await self.send_message(msg_payload)
async def send_message(self, message_dict: dict):
"""底层的 WebSocket 发送"""
await send_message_flow(self, message_dict)
def get_time(self):
return datetime.now().strftime("%H:%M:%S")
async def run(self):
await self.connect()
await self.receive_messages()
if __name__ == "__main__":
client = QingjianAPIClient()
try:
asyncio.run(client.run())
except KeyboardInterrupt:
logger.info("已停止")

View File

@@ -0,0 +1,32 @@
import asyncio
import websockets
import logging
logger = logging.getLogger("cs_agent")
async def connect_flow(client):
"""连接 WebSocket 服务器并自动重连。"""
while client.running:
try:
client.logger.info(f"[{client.get_time()}] 正在连接轻简API {client.uri}...")
async with websockets.connect(client.uri) as websocket:
client.websocket = websocket
client.logger.info(f"[{client.get_time()}] 连接成功!")
client.logger.info(f"[{client.get_time()}] 等待接收消息...")
await client.receive_messages()
except Exception as e:
# 统一捕获异常,避免因为不同版本的 websockets 导致属性错误
client.logger.info(f"[{client.get_time()}] 连接或运行错误: {e}")
if client.running:
client.logger.info(f"[{client.get_time()}] 5秒后尝试重连...")
await asyncio.sleep(5)
async def receive_messages_flow(client):
"""持续接收消息。"""
try:
async for message in client.websocket:
await client.handle_message(message)
except Exception as e:
client.logger.info(f"[{client.get_time()}] 接收消息中断: {e}")

View File

@@ -0,0 +1,135 @@
import logging
import os
import subprocess
from datetime import datetime
from pathlib import Path
class _AnsiColorFormatter(logging.Formatter):
RESET = "\033[0m"
MESSAGE_TEXT_REPLACEMENTS = (
("[PROMPT->AI 前置提示词]", "[AI提示词]"),
("[PROMPT->AI", "[AI提示词"),
("[THINK/TOOL_CALL]", "[AI思考-工具调用]"),
("[THINK/TOOL_RETURN]", "[AI思考-工具返回]"),
("[THINK/RAW_OUTPUT]", "[AI思考-原始输出]"),
("[REPLY->CUSTOMER]", "[AI回复客户]"),
("[ACTIVITY]", "[活动日志]"),
("[AI质检]", "[AI质检]"),
)
MESSAGE_COLOR_RULES = (
("[PROMPT->AI", "\033[94m"),
("[THINK/", "\033[96m"),
("[REPLY->CUSTOMER]", "\033[92m"),
("Agent 回复", "\033[92m"),
("[ACTIVITY]", "\033[95m"),
("[AI质检]", "\033[97m"),
("收到新消息", "\033[36m"),
("发送成功", "\033[32m"),
("防抖等待", "\033[93m"),
)
COLORS = {
logging.DEBUG: "\033[36m",
logging.INFO: "\033[32m",
logging.WARNING: "\033[33m",
logging.ERROR: "\033[31m",
logging.CRITICAL: "\033[35m",
}
def __init__(self, fmt: str, datefmt: str | None = None, use_color: bool = True):
super().__init__(fmt=fmt, datefmt=datefmt)
self.use_color = use_color
def format(self, record: logging.LogRecord) -> str:
msg = super().format(record)
if not self.use_color:
for old, new in self.MESSAGE_TEXT_REPLACEMENTS:
msg = msg.replace(old, new)
return msg
raw_msg = record.getMessage()
for old, new in self.MESSAGE_TEXT_REPLACEMENTS:
msg = msg.replace(old, new)
for key, color in self.MESSAGE_COLOR_RULES:
if key in raw_msg:
return f"{color}{msg}{self.RESET}"
color = self.COLORS.get(record.levelno, "")
if not color:
return msg
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("[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("[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)
today = datetime.now().strftime("%Y-%m-%d")
fh = RotatingFileHandler(
LOG_DIR / f"chat_{today}.log",
maxBytes=LOG_MAX_BYTES,
backupCount=LOG_BACKUP_COUNT,
encoding="utf-8",
)
fh.setFormatter(fmt)
logger.addHandler(fh)
logger._cs_logger_configured = True
return logger

View File

@@ -0,0 +1,119 @@
import asyncio
import os
async def command_handler_flow(client):
"""命令行交互。"""
client.logger.info("\n命令帮助:")
client.logger.info(" reply <内容> - 回复最后一条消息")
client.logger.info(" text <id> <平台> <内容> - 发送文本消息")
client.logger.info(" img <id> <平台> <路径> - 发送图片")
client.logger.info(" setid <id> - 设置回复ID")
client.logger.info(" agent on/off - 开启/关闭 Agent")
client.logger.info(" exit/quit - 退出\n")
while client.running:
try:
loop = asyncio.get_running_loop()
user_input = await loop.run_in_executor(None, input, "")
parts = user_input.strip().split(maxsplit=1)
if not parts:
continue
cmd = parts[0].lower()
if cmd in ["exit", "quit", "q"]:
client.logger.info(f"[{client.get_time()}] 正在关闭...")
client.running = False
if client.websocket:
await client.websocket.close()
break
if cmd == "setid" and len(parts) > 1:
client.reply_id = parts[1]
client.logger.info(f"[{client.get_time()}] 回复ID已设置为: {client.reply_id}")
continue
if cmd == "agent" and len(parts) > 1:
if parts[1].lower() == "on":
client.enable_agent = True
client.logger.info(f"[{client.get_time()}] Agent 已开启")
elif parts[1].lower() == "off":
client.enable_agent = False
client.logger.info(f"[{client.get_time()}] Agent 已关闭")
continue
if cmd == "reply" and len(parts) > 1:
if client.last_msg:
await client.send_reply(client.last_msg, parts[1])
else:
client.logger.info(f"[{client.get_time()}] 错误: 还没有收到任何消息")
continue
if cmd == "text" and len(parts) > 1:
args = parts[1].split(maxsplit=2)
if len(args) >= 3:
await client.send_text(args[0], args[1], args[2])
else:
client.logger.info(f"[{client.get_time()}] 格式: text <cy_id> <acc_type> <内容>")
continue
if cmd == "img" and len(parts) > 1:
args = parts[1].split(maxsplit=2)
if len(args) >= 3:
await client.send_image(args[0], args[1], args[2])
else:
client.logger.info(f"[{client.get_time()}] 格式: img <cy_id> <acc_type> <图片路径>")
continue
client.logger.info(f"[{client.get_time()}] 未知命令: {cmd}")
except Exception as e:
client.logger.info(f"[{client.get_time()}] 命令错误: {e}")
async def run_client_flow(client):
"""运行客户端。"""
tasks = [client.connect(), client.command_handler()]
try:
from mail.email_receiver import email_receiver
if email_receiver.username:
client.logger.info(f"[{client.get_time()}] 邮件接收已启动,监控: {email_receiver.username}")
tasks.append(email_receiver.start())
else:
client.logger.info(f"[{client.get_time()}] 未配置邮件账号,跳过邮件接收")
except Exception as e:
client.logger.info(f"[{client.get_time()}] 邮件接收模块加载失败: {e}")
try:
from utils.daily_summary import scheduler as daily_scheduler
tasks.append(daily_scheduler())
client.logger.info(f"[{client.get_time()}] 每日日报定时任务已启动")
except Exception as e:
client.logger.info(f"[{client.get_time()}] 日报模块加载失败: {e}")
try:
from utils.health_check import health_check_loop
def _qingjian_ok():
return client.websocket is not None and not getattr(client.websocket, "closed", True)
tasks.append(health_check_loop(_qingjian_ok))
client.logger.info(f"[{client.get_time()}] 健康检查已启动")
except Exception as e:
client.logger.info(f"[{client.get_time()}] 健康检查模块加载失败: {e}")
try:
from utils.wechat_chat_log import morning_startup_scheduler
tasks.append(morning_startup_scheduler())
client.logger.info(f"[{client.get_time()}] 早8点企微启动消息已启动")
except Exception as e:
client.logger.info(f"[{client.get_time()}] 企微启动消息模块加载失败: {e}")
if os.getenv("UNREPLIED_FOLLOWUP_ENABLED", "true").lower() in ("1", "true", "yes"):
tasks.append(client._unreplied_followup_loop())
client.logger.info(f"[{client.get_time()}] 未回复会话补偿任务已启动")
await asyncio.gather(*tasks)

116
core/websocket_send_flow.py Normal file
View File

@@ -0,0 +1,116 @@
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": _sanitize_outbound_text(content),
"from_id": client.reply_id,
"from_name": client.reply_id,
"cy_id": cy_id,
"acc_type": acc_type,
"msg_type": 0,
"cy_name": "",
}
await client.send_message(message)
async def send_image_flow(client, cy_id, acc_type, image_path):
"""主动发送图片消息。"""
message = {
"msg_id": "",
"acc_id": "",
"msg": image_path,
"from_id": client.reply_id,
"from_name": client.reply_id,
"cy_id": cy_id,
"acc_type": acc_type,
"msg_type": 1,
"cy_name": "",
}
await client.send_message(message)
async def send_message_flow(client, message):
"""发送消息到服务器。"""
if client.websocket and client.websocket.state == websockets.protocol.State.OPEN:
try:
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(payload, ensure_ascii=False, indent=2)
client.logger.info(f"[{client.get_time()}] 发送成功:\n{pretty}")
client._activity_log(
"send_message_success",
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=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=payload.get("_trace_id", ""),
reason="socket_not_open",
acc_id=payload.get("acc_id", ""),
customer_id=payload.get("cy_id") or payload.get("from_id", ""),
)

View File

@@ -1,974 +0,0 @@
"""
客服工作流 + 图片任务状态机
架构说明:
- CustomerServiceWorkflow 负责管理图片处理任务的完整生命周期
- 图片AI接入点调用 workflow.image_ai_submit_result(task_id, result_url)
- 消息回调接口:通过 register_send_callback 注入发送函数
"""
import asyncio
import logging
import os
import uuid
from enum import Enum
from typing import Optional, Dict, Callable, Awaitable, Any, List
from datetime import datetime
from dataclasses import dataclass, field
_WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
logger = logging.getLogger("cs_agent")
async def _wechat_notify(content: str):
"""workflow 内部异常推送企业微信"""
if not _WECHAT_WEBHOOK:
return
try:
import httpx
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(_WECHAT_WEBHOOK, json={
"msgtype": "markdown",
"markdown": {"content": content}
})
data = resp.json()
if data.get("errcode") == 0:
logger.info(f"[Workflow通知] 企业微信推送成功 ✓")
else:
logger.info(f"[Workflow通知] 企业微信推送失败: {data}")
except Exception as e:
logger.info(f"[Workflow通知] 推送异常: {e}")
from db.customer_db import db
# ========== 任务状态 ==========
class TaskStatus(Enum):
PENDING = "待处理" # 任务已创建等待图片AI处理
PROCESSING = "处理中" # 图片AI正在处理
AWAITING_CONFIRM = "等待客户确认" # 结果已发给客户,等待确认
REVISION = "修改中" # 客户要求修改,重新处理
COMPLETED = "已完成" # 客户确认,邮件已发
FAILED = "失败" # 处理失败
# ========== 任务数据结构 ==========
@dataclass
class ImageTask:
task_id: str
customer_id: str
customer_name: str
original_image: str # 原图路径或URL
operation: str # 处理操作类型
requirements: str = "" # 客户原始需求描述
result_url: str = "" # 处理结果URL
email: str = "" # 客户邮箱
status: TaskStatus = TaskStatus.PENDING
revision_count: int = 0 # 修改次数
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
def update_status(self, status: TaskStatus):
self.status = status
self.updated_at = datetime.now().isoformat()
# ========== 工作流 ==========
class CustomerServiceWorkflow:
"""
客服工作流
图片AI对接方式
1. 调用 create_image_task() 创建任务,获取 task_id
2. 图片AI处理完成后调用 image_ai_submit_result(task_id, result_url)
3. 工作流自动发图给客户确认,并等待客户回复
"""
def __init__(self):
self.tasks: Dict[str, ImageTask] = {} # task_id -> ImageTask
self.customer_active_task: Dict[str, str] = {} # customer_id -> 最新 task_id
self._send_message: Optional[Callable] = None # 注入的消息发送函数
self._agent_notify: Optional[Callable] = None # 注入的 AI 通知函数
self._pending_analysis: Dict[str, dict] = {} # 待报价的识别结果
# ========== 回调注册(由 websocket_client 调用)==========
def register_agent_notify_callback(self, callback: Callable):
"""
注册 AI 通知回调,图片处理完成时调用 AI 生成消息发给客户。
callback 签名:
async def notify(customer_id, acc_id, acc_type, system_prompt)
"""
self._agent_notify = callback
def register_send_callback(self, callback: Callable[[str, str, str, int], Awaitable[None]]):
"""
注册消息发送回调函数
callback 签名:
async def send(customer_id, acc_id, acc_type, content, msg_type=0)
"""
self._send_message = callback
# ========== 任务管理 ==========
def create_image_task(
self,
customer_id: str,
customer_name: str,
original_image: str,
operation: str,
requirements: str = ""
) -> str:
"""
创建图片处理任务,返回 task_id
图片AI收到此 task_id 后开始处理,完成后调用 image_ai_submit_result
"""
task_id = str(uuid.uuid4())
task = ImageTask(
task_id=task_id,
customer_id=customer_id,
customer_name=customer_name,
original_image=original_image,
operation=operation,
requirements=requirements,
)
self.tasks[task_id] = task
self.customer_active_task[customer_id] = task_id
# 记录需求到客户画像
if requirements:
db.add_requirement(customer_id, requirements)
logger.info(f"[Workflow] 创建任务 {task_id} | 客户: {customer_name} | 操作: {operation}")
return task_id
def get_task(self, task_id: str) -> Optional[ImageTask]:
return self.tasks.get(task_id)
def get_customer_active_task(self, customer_id: str) -> Optional[ImageTask]:
task_id = self.customer_active_task.get(customer_id)
return self.tasks.get(task_id) if task_id else None
# ========== 图片识别AI接入点报价用==========
async def image_analysis_result(
self,
customer_id: str,
image_url: str,
complexity: str,
acc_id: str = "",
acc_type: str = "AliWorkbench",
gemini_prompt: str = "",
aspect_ratio: str = "1:1",
perspective: str = "no",
proc_type: str = "",
subject: str = "",
quality: str = "",
) -> bool:
"""
【图片识别AI专用接口】分析完成后调用此方法触发客服AI报价
Args:
customer_id: 客户ID
image_url: 图片URL原图
complexity: 复杂度评估结果,枚举值:
"simple" → 10-20元
"normal" → 20-30元
"complex" → 30元
"hard" → 40元
acc_id: 店铺账号ID
acc_type: 平台类型
Returns:
True = 成功触发报价False = 客户不存在
"""
price_map = {
"simple": "10-15元这张比较简单",
"normal": "15-20元",
"complex": "20-25元",
"hard": "25-30元",
}
price_hint = price_map.get(complexity, "20元")
# 把所有分析字段存入任务
requirements = f"complexity:{complexity}"
if gemini_prompt:
requirements += f"|prompt:{gemini_prompt}"
if aspect_ratio:
requirements += f"|ratio:{aspect_ratio}"
if perspective and perspective != "no":
requirements += f"|perspective:{perspective}"
if proc_type:
requirements += f"|proc_type:{proc_type}"
if subject:
requirements += f"|subject:{subject}"
if quality:
requirements += f"|quality:{quality}"
task_id = self.create_image_task(
customer_id=customer_id,
customer_name=customer_id,
original_image=image_url,
operation="enhance",
requirements=requirements,
)
logger.info(f"[Workflow] 图片识别完成 | 客户:{customer_id} | 复杂度:{complexity} | 建议报价:{price_hint}")
# 通知客服AI报价把识别结果注入消息让AI根据结果报价
if self._send_message:
# 这里不直接发价格,而是触发 agent 重新处理一条带识别结果的内部消息
# 实际报价由客服AI根据 complexity 生成,保持口吻一致
self._pending_analysis[customer_id] = {
"task_id": task_id,
"complexity": complexity,
"price_hint": price_hint,
"image_url": image_url,
}
return True
def get_pending_analysis(self, customer_id: str) -> dict:
"""
客服AI处理消息时调用检查该客户是否有待报价的识别结果
取出后自动清除(一次性)
"""
return self._pending_analysis.pop(customer_id, None)
# ========== 付款后触发 Gemini 作图 ==========
async def trigger_processing_on_payment(
self,
customer_id: str,
acc_id: str = "",
acc_type: str = "AliWorkbench"
) -> bool:
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
await _wechat_notify(
f" **付款触发但已暂停自动作图**\n客户:{customer_id}\n店铺:{acc_id}\n请人工安排处理"
)
return False
except Exception:
return False
"""
客户付款后调用此方法,找到该客户待处理的任务并启动 Gemini 作图。
由 pydantic_ai_agent 在识别到"已付款"订单通知时调用。
也可作为 tool 由 AI 主动触发。
Returns:
True=已启动处理, False=无待处理任务
"""
task = self.get_customer_active_task(customer_id)
if not task:
# 内存任务丢失(重启场景)→ 从客户档案重建
logger.info(f"[Workflow] 付款触发:内存无任务,尝试从客户档案重建 | 客户: {customer_id}")
task = await self._rebuild_task_from_profile(customer_id, acc_id, acc_type)
if not task:
logger.info(f"[Workflow] 付款触发:客户 {customer_id} 无图片记录,无法重建任务,跳过")
await _wechat_notify(
f"⚠️ **付款但无图片**\n"
f"客户:{customer_id}\n"
f"店铺:{acc_id}\n"
f"已付款但找不到待处理图片,请人工发图处理"
)
return False
if task.status not in (TaskStatus.PENDING,):
logger.info(f"[Workflow] 付款触发:任务 {task.task_id[:8]}... 状态={task.status.value},跳过")
return False
task.operation = task.operation or "enhance"
logger.info(f"[Workflow] 付款确认,启动 Gemini 处理 | 客户: {customer_id} | 任务: {task.task_id[:8]}...")
asyncio.create_task(self._auto_process(task.task_id, acc_id=acc_id, acc_type=acc_type))
return True
async def _rebuild_task_from_profile(
self, customer_id: str, acc_id: str, acc_type: str
) -> Optional["ImageTask"]:
"""
重启后任务丢失时,从客户档案里读取 last_image_url 重建一个 PENDING 任务。
"""
try:
from db.customer_db import db
profile = db.get_customer(customer_id)
image_url = profile.last_image_url
if not image_url:
return None
complexity = profile.complexity_history[-1] if profile.complexity_history else ""
gemini_prompt = getattr(profile, "last_gemini_prompt", "")
aspect_ratio = getattr(profile, "last_aspect_ratio", "1:1")
perspective = getattr(profile, "last_perspective", "no")
requirements = f"complexity:{complexity}" if complexity else ""
if gemini_prompt:
requirements += f"|prompt:{gemini_prompt}"
if aspect_ratio:
requirements += f"|ratio:{aspect_ratio}"
if perspective and perspective != "no":
requirements += f"|perspective:{perspective}"
task_id = str(uuid.uuid4())
task = ImageTask(
task_id=task_id,
customer_id=customer_id,
customer_name=profile.name or customer_id,
original_image=image_url,
operation="enhance",
requirements=requirements,
status=TaskStatus.PENDING,
)
self.tasks[task_id] = task
self.customer_active_task[customer_id] = task_id
logger.info(f"[Workflow] 任务已重建 | 客户: {customer_id} | 图片: {image_url[:60]}...")
return task
except Exception as e:
logger.info(f"[Workflow] 任务重建失败: {e}")
return None
@staticmethod
def _parse_requirements(requirements: str) -> dict:
"""从 requirements 字符串解析各字段,格式: complexity:xxx|prompt:xxx|ratio:xxx"""
parsed = {}
for part in (requirements or "").split("|"):
part = part.strip()
if ":" in part:
k, v = part.split(":", 1)
parsed[k.strip()] = v.strip()
return parsed
async def _auto_process(self, task_id: str, acc_id: str = "", acc_type: str = "AliWorkbench"):
"""付款确认后自动调用 Gemini 处理图片,完成后通知客户"""
try:
from config.config import IMAGE_MODULE_ENABLED
if not IMAGE_MODULE_ENABLED:
return
except Exception:
return
task = self.tasks.get(task_id)
if not task:
return
task.update_status(TaskStatus.PROCESSING)
req = self._parse_requirements(task.requirements)
gemini_prompt = req.get("prompt", "")
aspect_ratio = req.get("ratio", "1:1")
perspective = req.get("perspective", "no")
proc_type = req.get("proc_type", "")
subject = req.get("subject", "")
quality = req.get("quality", "")
revision_note = req.get("revision", "")
# 客户修改意见追加到 prompt 末尾
if revision_note:
gemini_prompt = (gemini_prompt or "") + f"\n【客户修改要求】{revision_note}"
logger.info(f"[Workflow] Gemini 开始处理 | 任务: {task_id[:8]}... | 比例: {aspect_ratio} | 透视: {perspective} | 图片: {task.original_image}")
try:
from image.image_processor import image_processor
from utils.image_queue import run_with_queue
result = await run_with_queue(image_processor.process_image(
task.original_image,
task.operation,
requirements=task.requirements,
gemini_prompt=gemini_prompt,
aspect_ratio=aspect_ratio,
perspective=perspective,
proc_type=proc_type,
subject=subject,
quality=quality,
))
if result["success"]:
attempts = result.get("attempts", 1)
qa_score = result.get("qa_score", 0)
qa_pass = result.get("qa_pass", True)
qa_issue = result.get("qa_issue", "")
logger.info(f"[Workflow] Gemini 处理完成 | 任务: {task_id[:8]}... | 质检: {qa_score}分 | 尝试: {attempts}")
# 质检未通过(已达重试上限,保留结果但人工跟进)
if not qa_pass:
await _wechat_notify(
f"⚠️ **图片质检未通过,请人工核查**\n"
f"客户:{task.customer_id}\n"
f"店铺:{acc_id}\n"
f"质检得分:{qa_score}/100\n"
f"问题:{qa_issue}\n"
f"已处理 {attempts} 次,结果已发出,请人工确认质量"
)
await self.image_ai_submit_result(
task_id=task_id,
result_url=result["result_path"],
acc_id=acc_id,
acc_type=acc_type,
)
else:
err_msg = result['message']
logger.info(f"[Workflow] Gemini 处理失败: {err_msg}")
task.update_status(TaskStatus.FAILED)
# 企业微信预警
await _wechat_notify(
f"⚠️ **Gemini作图失败**\n"
f"客户:{task.customer_id}\n"
f"店铺:{acc_id}\n"
f"原因:{err_msg[:200]}\n"
f"请人工跟进"
)
# 通知客户稍等,并告知转人工
if self._send_message:
await self._send_message(
customer_id=task.customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="您好,图片处理遇到点问题,已帮您转接人工客服处理,请稍候",
msg_type=0,
)
except Exception as e:
logger.info(f"[Workflow] 自动处理异常: {e}")
task.update_status(TaskStatus.FAILED)
await _wechat_notify(
f"⚠️ **Workflow处理异常**\n"
f"客户:{task.customer_id}\n"
f"错误:{str(e)[:200]}"
)
# ========== 图片AI接入点作图用==========
async def image_ai_submit_result(
self,
task_id: str,
result_url: str,
acc_id: str = "",
acc_type: str = "AliWorkbench"
) -> bool:
"""
【图片AI专用接口】处理完成后调用此方法
Args:
task_id: create_image_task 返回的任务ID
result_url: 处理后的图片URL或本地路径
acc_id: 店铺账号ID发消息用
acc_type: 平台类型
Returns:
True = 成功False = 任务不存在
"""
task = self.tasks.get(task_id)
if not task:
logger.info(f"[Workflow] 任务不存在: {task_id}")
return False
task.result_url = result_url
task.update_status(TaskStatus.AWAITING_CONFIRM)
logger.info(f"[Workflow] 任务 {task_id} 处理完成,发送给客户确认")
# 先发结果图片
if self._send_message:
await self._send_message(
customer_id=task.customer_id,
acc_id=acc_id,
acc_type=acc_type,
content=result_url,
msg_type=1 # 图片
)
# 让客服 AI 生成完成通知话术(自然口吻,询问邮箱)
if self._agent_notify:
await self._agent_notify(
customer_id=task.customer_id,
acc_id=acc_id,
acc_type=acc_type,
system_hint="【图片已处理完成并发给客户】请用自然口吻告诉客户图发好了让他看一下效果没问题把邮箱发过来你来发给他。不超过1句话。",
)
elif self._send_message:
# 兜底AI 不可用时用固定话术
await self._send_message(
customer_id=task.customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="好了,你看一下效果,没问题把邮箱发我",
msg_type=0,
)
return True
# ========== 客户回复处理 ==========
async def handle_customer_reply(
self,
customer_id: str,
message: str,
acc_id: str = "",
acc_type: str = "AliWorkbench"
) -> Optional[str]:
"""
处理正在等待确认的客户回复
Returns:
需要回复客户的文本None 表示不是确认相关消息
"""
task = self.get_customer_active_task(customer_id)
if not task or task.status != TaskStatus.AWAITING_CONFIRM:
return None
msg = message.strip()
# 提取邮箱
import re
email_match = re.search(r'[\w\.-]+@[\w\.-]+\.\w+', msg)
if email_match:
email = email_match.group()
task.email = email
db.update_email(customer_id, email)
# 发送邮件(调用 email_sender
result = await self._send_email(task)
if result:
task.update_status(TaskStatus.COMPLETED)
db.update_email_status(task.customer_id, "sent")
db.complete_order(task.customer_id, had_revision=task.revision_count > 0)
db.auto_compute_tags(task.customer_id)
return "发到您邮箱了,注意查收哈"
else:
db.update_email_status(task.customer_id, "failed")
return "邮件发送失败了,您再发一次邮箱试试"
# 客户说不满意/要改
negative_keywords = ["不好", "不对", "不满意", "重做", "改一下", "差太多", "不行", "效果不好", "颜色不对"]
if any(kw in msg for kw in negative_keywords):
task.revision_count += 1
task.update_status(TaskStatus.REVISION)
db.record_revision(task.customer_id)
# 把客户的修改意见追加进 requirements下次重做时 Gemini 能看到
if msg:
task.requirements += f"|revision:{msg[:100]}"
return "好,你说一下哪里要改,或者发图告诉我"
# 客户提供了修改说明(处于 REVISION 状态时)
if task.status == TaskStatus.REVISION and msg:
task.requirements += f"|revision:{msg[:100]}"
task.update_status(TaskStatus.PENDING)
# 重新触发处理
asyncio.create_task(
self._auto_process(task.task_id, acc_id=acc_id, acc_type=acc_type)
)
return "好的,重新给你做"
return None
async def _send_email(self, task: ImageTask) -> bool:
"""发送完成作品邮件"""
try:
from mail.email_sender import email_sender
profile = db.get_customer(task.customer_id)
result = email_sender.send_completed_work(
to_email=task.email,
customer_name=profile.name or task.customer_name,
image_description=task.requirements or task.operation,
result_images=[task.result_url]
)
return result.get("success", False)
except Exception as e:
logger.info(f"[Workflow] 邮件发送失败: {e}")
await _wechat_notify(
f"⚠️ **邮件发送失败**\n"
f"客户:{task.customer_id}\n"
f"邮箱:{task.email}\n"
f"错误:{str(e)[:200]}"
)
return False
# ========== 工具方法 ==========
def detect_operation(self, message: str) -> str:
"""根据客户描述识别处理操作"""
msg = message.lower()
if any(kw in msg for kw in ["模糊", "清晰", "高清", "变清"]):
return "enhance"
elif any(kw in msg for kw in ["背景", "去背", "抠图", "透明"]):
return "remove_bg"
elif any(kw in msg for kw in ["尺寸", "大小", "缩放", "分辨率"]):
return "resize"
elif any(kw in msg for kw in ["老照片", "修复", "发黄", "破损"]):
return "fix_old_photo"
elif any(kw in msg for kw in ["分层", "psd"]):
return "layered"
else:
return "enhance"
def get_task_summary(self) -> str:
"""获取当前所有任务摘要(调试用)"""
if not self.tasks:
return "暂无任务"
lines = []
for tid, task in self.tasks.items():
lines.append(
f" [{task.status.value}] {task.customer_name} | {task.operation} | {tid[:8]}..."
)
return "\n".join(lines)
# ========== 客户需求变更 ==========
async def add_customer_requirement(self, task_id: str, customer_id: str,
requirement: str, changed_by: str = 'customer') -> bool:
# 检查任务是否存在
task = self.get_task(task_id)
if not task:
# 尝试从数据库加载
db_task = self.db.get_task(task_id)
if db_task:
logger.info(f"[Workflow] 从数据库加载任务:{task_id[:8]}...")
# 可以在这里重建内存任务
else:
logger.info(f"[Workflow] 任务不存在:{task_id}")
return False
# 添加到数据库
success = self.db.add_customer_note(task_id, requirement, changed_by)
if success:
logger.info(f"[Workflow] 客户添加需求:{task_id[:8]}... | {requirement}")
# 如果任务还在待处理状态,通知 AI 客服
if task and task.status.value == 'pending':
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=task.acc_id,
acc_type=task.acc_type,
content=f"好的,已记录您的需求:{requirement},处理时会注意的",
msg_type=0,
)
return success
async def modify_operation(self, task_id: str, customer_id: str,
new_operation: str, changed_by: str = 'customer') -> bool:
"""
客户修改操作类型
Args:
task_id: 任务 ID
customer_id: 客户 ID
new_operation: 新操作enhance/remove_bg/vectorize 等)
changed_by: 修改者
Returns:
bool: 是否成功
"""
task = self.get_task(task_id)
if not task:
db_task = self.db.get_task(task_id)
if not db_task:
logger.info(f"[Workflow] 任务不存在:{task_id}")
return False
# 检查状态,已处理完成的不允许修改
if task and task.status.value in ['completed', 'processing']:
logger.info(f"[Workflow] 任务已开始处理,不允许修改操作:{task_id}")
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=task.acc_id,
acc_type=task.acc_type,
content="抱歉,图片已经开始处理了,无法修改操作类型",
msg_type=0,
)
return False
# 修改数据库
success = self.db.modify_operation(task_id, new_operation, changed_by)
if success and task:
task.operation = new_operation
logger.info(f"[Workflow] 修改操作类型:{task_id[:8]}... -> {new_operation}")
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=task.acc_id,
acc_type=task.acc_type,
content=f"好的,已为您修改为{new_operation}操作",
msg_type=0,
)
return success
def get_task_requirement_history(self, task_id: str) -> List[dict]:
"""获取任务需求变更历史"""
return self.db.get_requirement_history(task_id)
# ========== 三种工作流 ==========
async def find_image_workflow(self, customer_id: str, image_url: str,
acc_id: str = "", acc_type: str = "AliWorkbench") -> bool:
"""
工作流 1查找图片
客户说"找一下这个图" → 自己处理 → 上传到图绘 → 返回 URL
Args:
customer_id: 客户 ID
image_url: 图片 URL
acc_id: 店铺 ID
acc_type: 平台类型
Returns:
bool: 是否成功
"""
try:
logger.info(f"[Workflow] 启动查找图片工作流 | 客户:{customer_id}")
# 1. 创建任务
task_id = self.create_image_task(
customer_id=customer_id,
customer_name=customer_id,
original_image=image_url,
operation="find", # 查找操作
requirements="type:find",
acc_id=acc_id,
acc_type=acc_type
)
# 2. 这里调用图绘 API 上传图片
# TODO: 调用图绘上传 API
# tuhui_url = await self._upload_to_tuhui(image_url)
# 临时模拟
tuhui_url = f"http://tuhui.cloud/works/123"
# 3. 更新任务结果
self.db.update_result(task_id, tuhui_url)
self.db.update_status(task_id, DBTaskStatus.COMPLETED)
# 4. 回复客户
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=acc_id,
acc_type=acc_type,
content=f"找到了!图片在这里:{tuhui_url}",
msg_type=0,
)
logger.info(f"[Workflow] 查找图片完成 | 客户:{customer_id} | URL: {tuhui_url}")
return True
except Exception as e:
logger.error(f"查找图片工作流失败:{e}")
return False
async def process_image_workflow(self, customer_id: str, image_url: str,
acc_id: str = "", acc_type: str = "AliWorkbench") -> bool:
"""
工作流 2处理图片
客户说"做一下" → 评估图片 → 稍等做
Args:
customer_id: 客户 ID
image_url: 图片 URL
acc_id: 店铺 ID
acc_type: 平台类型
Returns:
bool: 是否成功
"""
try:
logger.info(f"[Workflow] 启动处理图片工作流 | 客户:{customer_id}")
# 1. 创建任务
task_id = self.create_image_task(
customer_id=customer_id,
customer_name=customer_id,
original_image=image_url,
operation="enhance",
requirements="type:process",
acc_id=acc_id,
acc_type=acc_type
)
# 2. 回复客户稍等
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="稍等,我看看...好的,可以做,马上处理",
msg_type=0,
)
# 3. 启动处理
await self.trigger_processing_on_payment(customer_id, acc_id, acc_type)
logger.info(f"[Workflow] 处理图片已启动 | 客户:{customer_id}")
return True
except Exception as e:
logger.error(f"处理图片工作流失败:{e}")
return False
async def transfer_to_designer_workflow(self, customer_id: str, image_url: str,
acc_id: str = "", acc_type: str = "AliWorkbench",
reason: str = "做不了") -> bool:
"""
工作流 3转人工派单
做不了 → 查询企业微信在线设计师 → 派单
Args:
customer_id: 客户 ID
image_url: 图片 URL
acc_id: 店铺 ID
acc_type: 平台类型
reason: 转接原因
Returns:
bool: 是否成功
"""
try:
logger.info(f"[Workflow] 启动转人工派单工作流 | 客户:{customer_id} | 原因:{reason}")
# 1. 创建任务
task_id = self.create_image_task(
customer_id=customer_id,
customer_name=customer_id,
original_image=image_url,
operation="manual",
requirements=f"type:transfer|reason:{reason}",
acc_id=acc_id,
acc_type=acc_type
)
# 2. 查询企业微信在线设计师
online_designers = await self._get_online_designers()
if not online_designers:
# 无人在线,通知客户
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="抱歉,现在设计师都不在线,稍后会有人联系您",
msg_type=0,
)
# 企业微信预警
await _wechat_notify(
f"⚠️ **人工派单但无人在线**\n"
f"客户:{customer_id}\n"
f"店铺:{acc_id}\n"
f"原因:{reason}\n"
f"请安排设计师上线"
)
logger.info(f"[Workflow] 无人在线 | 客户:{customer_id}")
return False
# 3. 派单给在线设计师
designer_name = online_designers[0] # 取第一个在线的
success = await self._dispatch_to_designer(task_id, designer_name, customer_id, image_url, reason)
if not success:
logger.error("派单失败")
return False
# 4. 回复客户
if self._send_message:
await self._send_message(
customer_id=customer_id,
acc_id=acc_id,
acc_type=acc_type,
content="好的,已帮您安排设计师处理,请稍候",
msg_type=0,
)
logger.info(f"[Workflow] 已派单给设计师:{designer} | 客户:{customer_id}")
return True
except Exception as e:
logger.error(f"转人工派单工作流失败:{e}")
return False
async def _get_online_designers(self) -> list:
"""
查询在线设计师(使用图绘派单 API
Returns:
list: 在线设计师名单 ["橘子", "婷婷", ...]
"""
try:
designers = await self.dispatch_client.get_online_designers()
logger.info(f"[Workflow] 查询在线设计师:{len(designers)}人在线 | {designers}")
return designers
except Exception as e:
logger.error(f"查询在线设计师失败:{e}")
return []
async def _dispatch_to_designer(self, task_id: str, designer_name: str,
customer_id: str, image_url: str, reason: str) -> bool:
"""
派单给设计师(使用图绘派单 API
Args:
task_id: 任务 ID
designer_name: 设计师姓名
customer_id: 客户 ID
image_url: 图片 URL
reason: 转接原因
Returns:
bool: 是否成功
"""
try:
# 1. 在派单系统创建任务
dispatch_task_id = await self.dispatch_client.create_task(
task_name=f"图片处理-{customer_id[-4:]}",
description=f"{reason}\n客户:{customer_id}\n图片:{image_url}",
task_type="image_process",
priority=2,
deadline=None
)
if not dispatch_task_id:
logger.error("创建派单任务失败")
return False
# 2. 分配给设计师
success = await self.dispatch_client.assign_task(
task_id=dispatch_task_id,
designer_name=designer_name,
notes=f"AI 客服自动派单\n原因:{reason}\n客户:{customer_id}"
)
if success:
logger.info(f"[Workflow] 派单成功:{dispatch_task_id}{designer_name} | 客户:{customer_id}")
# 企业微信通知
await _wechat_notify(
f"📋 **新任务派单**\n"
f"设计师:{designer_name}\n"
f"任务 ID: {dispatch_task_id}\n"
f"客户:{customer_id}\n"
f"原因:{reason}\n"
f"请及时处理"
)
return True
else:
logger.error("分配任务失败")
return False
except Exception as e:
logger.error(f"派单失败:{e}")
return False
# ========== 全局实例 ==========
workflow = CustomerServiceWorkflow()

View File

@@ -1,889 +1 @@
{
"new_customer_001": {
"customer_id": "new_customer_001",
"name": "新客户小王",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 0,
"total_spent": 0,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 20,
"last_price_time": "2026-02-28T15:04:15.181813",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "",
"decision_speed": "",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.129715",
"last_update": "2026-02-28T15:04:15.184378"
},
"fast_customer_002": {
"customer_id": "fast_customer_002",
"name": "爽快老客老李",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 8,
"total_spent": 280,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 10,
"last_price_time": "2026-02-28T15:06:10.872962",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 2,
"lowest_price_accepted": 10,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "中",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 8,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.131384",
"last_update": "2026-02-28T15:06:10.875534"
},
"bargainer_003": {
"customer_id": "bargainer_003",
"name": "砍价王小张",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 3,
"total_spent": 45,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"砍价狂",
"纠结"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 10,
"last_price_time": "2026-02-28T15:05:45.067204",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 6,
"lowest_price_accepted": 10,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "高",
"decision_speed": "慢",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.132648",
"last_update": "2026-02-28T15:05:45.071818"
},
"vip_customer_004": {
"customer_id": "vip_customer_004",
"name": "VIP客户陈总",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 15,
"total_spent": 680,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "A",
"vip": true,
"vip_level": 2,
"last_price": 20,
"last_price_time": "2026-02-28T15:04:56.155844",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 18,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.134104",
"last_update": "2026-02-28T15:04:56.158233"
},
"high_value_005": {
"customer_id": "high_value_005",
"name": "高价值客户刘老板",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 20,
"total_spent": 1200,
"avg_order_value": 60,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "A",
"vip": false,
"vip_level": 0,
"last_price": 20,
"last_price_time": "2026-02-28T15:05:11.156030",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.135396",
"last_update": "2026-02-28T15:05:11.160004"
},
"blacklist_006": {
"customer_id": "blacklist_006",
"name": "黑名单客户",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 0,
"total_spent": 0.0,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 0,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "",
"decision_speed": "",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": true,
"blacklist_reason": "恶意投诉多次",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:03:57.136490",
"last_update": "2026-02-28T15:05:27.155220"
},
"test_new_001": {
"customer_id": "test_new_001",
"name": "新客户小王",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 0,
"total_spent": 0,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 0,
"last_price_time": "2026-02-28T15:27:40.801329",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "jpg",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "",
"decision_speed": "",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "低",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.719291",
"last_update": "2026-02-28T15:29:05.719308"
},
"test_fast_002": {
"customer_id": "test_fast_002",
"name": "爽快老客老李",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 8,
"total_spent": 280,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 25,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 8,
"bulk_potential": "",
"churn_risk": "",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.720944",
"last_update": "2026-02-28T15:29:05.720948"
},
"test_bargain_003": {
"customer_id": "test_bargain_003",
"name": "砍价王小张",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 3,
"total_spent": 45,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"砍价狂",
"纠结"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "C",
"vip": false,
"vip_level": 0,
"last_price": 15,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 4,
"lowest_price_accepted": 15,
"preferred_format": "",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "高",
"decision_speed": "慢",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.722448",
"last_update": "2026-02-28T15:29:05.722454"
},
"test_vip_004": {
"customer_id": "test_vip_004",
"name": "VIP 客户陈总",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 15,
"total_spent": 680,
"avg_order_value": 0.0,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "A",
"vip": true,
"vip_level": 2,
"last_price": 0,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 18,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.723887",
"last_update": "2026-02-28T15:29:05.723890"
},
"test_highvalue_005": {
"customer_id": "test_highvalue_005",
"name": "高价值客户刘老板",
"nickname": "",
"email": "",
"phone": "",
"wechat": "",
"address": "",
"platform": "",
"platform_id": "",
"budget": "",
"budget_range_min": 0,
"budget_range_max": 0,
"requirements": [],
"preference_services": [],
"total_orders": 20,
"total_spent": 1200,
"avg_order_value": 60,
"purchase_frequency": "",
"last_order_date": "",
"first_order_date": "",
"order_ids": [],
"pending_orders": 0,
"completed_orders": 0,
"refund_count": 0,
"personality": [
"爽快"
],
"communication_prefer": "",
"response_speed": "",
"patience_level": "",
"customer_level": "A",
"vip": false,
"vip_level": 0,
"last_price": 0,
"last_price_time": "",
"last_quote_no_convert": false,
"last_min_price": 0,
"last_image_url": "",
"last_image_time": "",
"last_gemini_prompt": "",
"last_aspect_ratio": "1:1",
"last_perspective": "no",
"processing_status": "",
"processing_image_url": "",
"expected_done_at": "",
"discount_given_count": 0,
"lowest_price_accepted": 0,
"preferred_format": "",
"preferred_size": "",
"last_conversation_summary": "",
"last_conversation_time": "",
"total_images_sent": 0,
"complexity_history": [],
"image_type_history": [],
"price_sensitivity": "低",
"decision_speed": "快",
"revision_count": 0,
"revision_orders": 0,
"total_completed_orders": 0,
"bulk_potential": "",
"churn_risk": "",
"upsell_opportunity": [],
"blacklist": false,
"blacklist_reason": "",
"vip_custom_price": 0,
"last_email_status": "",
"good_reviews": 0,
"bad_reviews": 0,
"dispute_count": 0,
"follow_up_by": "",
"follow_up_date": "",
"next_follow_date": "",
"source": "",
"coupon_used": "",
"notes": [],
"tags": [],
"created_at": "",
"last_contact": "2026-02-28T15:29:05.725313",
"last_update": "2026-02-28T15:29:05.725316"
}
}
{}

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
@@ -113,6 +213,11 @@ def init_db():
conn.execute("CREATE INDEX idx_ts ON chat_logs(timestamp)")
if "idx_acc" not in exists:
conn.execute("CREATE INDEX idx_acc ON chat_logs(acc_id)")
# 添加 image_urls 列(如果不存在)
try:
conn.execute("ALTER TABLE chat_logs ADD COLUMN image_urls TEXT DEFAULT ''")
except Exception:
pass # 列已存在
else:
conn.execute("""
CREATE TABLE IF NOT EXISTS chat_logs (
@@ -133,15 +238,92 @@ def init_db():
conn.execute("ALTER TABLE chat_logs ADD COLUMN acc_id TEXT DEFAULT ''")
except Exception:
pass
try:
conn.execute("ALTER TABLE chat_logs ADD COLUMN image_urls TEXT DEFAULT ''")
except Exception:
pass
conn.execute("CREATE INDEX IF NOT EXISTS idx_acc ON chat_logs(acc_id)")
# ---- 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,
@@ -150,15 +332,16 @@ def log_message(
acc_id: str = "", # 店铺账号ID
platform: str = "",
msg_type: int = 0,
image_urls: str = "", # 图片URL列表用\n分隔
):
"""记录一条聊天消息"""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with _get_conn() as conn:
conn.execute(
_sql("INSERT INTO chat_logs "
"(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp) "
"VALUES (?,?,?,?,?,?,?,?)"),
(customer_id, customer_name, acc_id, platform, direction, message, msg_type, ts),
"(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp, image_urls) "
"VALUES (?,?,?,?,?,?,?,?,?)"),
(customer_id, customer_name, acc_id, platform, direction, message, msg_type, ts, image_urls),
)
conn.commit()
@@ -198,38 +381,34 @@ def get_customers(limit: int = 100) -> List[Dict]:
return [dict(r) for r in rows]
def get_conversation(customer_id: str, limit: int = 200) -> List[Dict]:
"""返回某客户的全部对话记录(按时间升序)"""
@_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 id, direction, message, msg_type, timestamp, acc_id
FROM chat_logs
WHERE customer_id = ?
SELECT * FROM (
SELECT id, direction, message, msg_type, timestamp, acc_id, image_urls
FROM chat_logs
WHERE customer_id = ?
ORDER BY timestamp DESC, id DESC
LIMIT ?
) AS recent
ORDER BY timestamp ASC, id ASC
LIMIT ?
"""), (customer_id, limit)).fetchall()
return [dict(r) for r in rows]
def get_recent_conversation(customer_id: str, acc_id: str = "", limit: int = 10) -> List[Dict]:
"""返回某客户近期对话(同店铺),用于企微推送保持连贯"""
"""返回某客户近期对话,忽略 acc_id 过滤"""
with _get_conn() as conn:
if acc_id:
rows = conn.execute(_sql("""
SELECT id, direction, message, timestamp, acc_id
FROM chat_logs
WHERE customer_id = ? AND acc_id = ?
ORDER BY id DESC
LIMIT ?
"""), (customer_id, acc_id, limit)).fetchall()
else:
rows = conn.execute(_sql("""
SELECT id, direction, message, timestamp, acc_id
FROM chat_logs
WHERE customer_id = ?
ORDER BY id DESC
LIMIT ?
"""), (customer_id, limit)).fetchall()
rows = conn.execute(_sql("""
SELECT id, direction, message, timestamp, acc_id
FROM chat_logs
WHERE customer_id = ?
ORDER BY id DESC
LIMIT ?
"""), (customer_id, limit)).fetchall()
out = [dict(r) for r in reversed(rows)]
return out
@@ -346,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")
@@ -76,6 +79,8 @@ class CustomerProfile:
last_gemini_prompt: str = "" # 最近一次图片的 Gemini 处理提示词
last_aspect_ratio: str = "1:1" # 最近一次图片的建议输出比例
last_perspective: str = "no" # 最近一次图片的透视状态
last_image_analysis: str = "" # 最近一次图片分析结果JSON字符串用于数据标定
image_analysis_history: List[str] = None # 图片分析历史记录JSON列表用于数据标定
pending_quote_images: List[str] = None # 待统一报价图片队列(持久化)
pending_quote_requirements: List[str] = None # 待统一报价需求队列(持久化)
@@ -165,6 +170,25 @@ class CustomerProfile:
self.pending_quote_images = []
if self.pending_quote_requirements is None:
self.pending_quote_requirements = []
if self.image_analysis_history is None:
self.image_analysis_history = []
class _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:
@@ -176,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():
@@ -280,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)

View File

@@ -1,336 +0,0 @@
"""客户风控数据库MySQL 优先SQLite 兜底)"""
import os
import sqlite3
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, Any
from dotenv import load_dotenv
load_dotenv()
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
_MYSQL_USER = os.getenv("MYSQL_USER", "root")
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
class CustomerRiskDB:
def __init__(self, sqlite_path: str = "db/customer_risk_db/risk.db"):
self.sqlite_path = Path(sqlite_path)
self.backend = "mysql" if _is_mysql() else "sqlite"
self._sqlite_in_memory = False
try:
self._ensure_db()
except Exception:
# MySQL 不可用时自动回退,避免主流程被数据库连接拖垮
self.backend = "sqlite"
try:
self._ensure_sqlite_db()
except Exception:
# 最后兜底:内存 SQLite保证模块可导入
self._sqlite_in_memory = True
self._ensure_sqlite_db()
def _get_mysql_conn(self):
import pymysql
return pymysql.connect(
host=_MYSQL_HOST,
port=_MYSQL_PORT,
user=_MYSQL_USER,
password=_MYSQL_PASSWORD,
database=_MYSQL_DATABASE,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
def _get_sqlite_conn(self):
if self._sqlite_in_memory:
conn = sqlite3.connect(":memory:")
else:
self.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(self.sqlite_path))
conn.row_factory = sqlite3.Row
return conn
def _ensure_db(self):
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_profile (
customer_id VARCHAR(128) PRIMARY KEY,
do_not_serve TINYINT(1) NOT NULL DEFAULT 0,
risk_level VARCHAR(16) NOT NULL DEFAULT 'low',
risk_score INT NOT NULL DEFAULT 0,
note TEXT,
tags_json TEXT,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
customer_id VARCHAR(128) NOT NULL,
event_type VARCHAR(32) NOT NULL,
event_count INT NOT NULL DEFAULT 1,
note TEXT,
created_at DATETIME NOT NULL,
INDEX idx_customer_time (customer_id, created_at),
INDEX idx_event_type (event_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
conn.commit()
return
self._ensure_sqlite_db()
def _ensure_sqlite_db(self):
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_profile (
customer_id TEXT PRIMARY KEY,
do_not_serve INTEGER NOT NULL DEFAULT 0,
risk_level TEXT NOT NULL DEFAULT 'low',
risk_score INTEGER NOT NULL DEFAULT 0,
note TEXT,
tags_json TEXT,
updated_at TEXT NOT NULL
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_risk_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
event_type TEXT NOT NULL,
event_count INTEGER NOT NULL DEFAULT 1,
note TEXT,
created_at TEXT NOT NULL
)
"""
)
cur.execute("CREATE INDEX IF NOT EXISTS idx_customer_time ON customer_risk_event(customer_id, created_at)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_event_type ON customer_risk_event(event_type)")
conn.commit()
def record_event(self, customer_id: str, event_type: str, event_count: int = 1, note: str = ""):
if not customer_id or not event_type:
return
now = datetime.now()
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO customer_risk_event (customer_id, event_type, event_count, note, created_at)
VALUES (%s, %s, %s, %s, %s)
""",
(customer_id, event_type, int(max(1, event_count)), note, now.strftime("%Y-%m-%d %H:%M:%S")),
)
conn.commit()
return
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO customer_risk_event (customer_id, event_type, event_count, note, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(customer_id, event_type, int(max(1, event_count)), note, now.isoformat()),
)
conn.commit()
def set_profile(
self,
customer_id: str,
*,
do_not_serve: bool = False,
risk_level: str = "low",
risk_score: int = 0,
note: str = "",
tags: list | None = None,
):
if not customer_id:
return
tags_json = json.dumps(tags or [], ensure_ascii=False)
now = datetime.now()
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
REPLACE INTO customer_risk_profile
(customer_id, do_not_serve, risk_level, risk_score, note, tags_json, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
customer_id,
1 if do_not_serve else 0,
risk_level,
int(max(0, risk_score)),
note,
tags_json,
now.strftime("%Y-%m-%d %H:%M:%S"),
),
)
conn.commit()
return
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO customer_risk_profile
(customer_id, do_not_serve, risk_level, risk_score, note, tags_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(customer_id) DO UPDATE SET
do_not_serve=excluded.do_not_serve,
risk_level=excluded.risk_level,
risk_score=excluded.risk_score,
note=excluded.note,
tags_json=excluded.tags_json,
updated_at=excluded.updated_at
""",
(
customer_id,
1 if do_not_serve else 0,
risk_level,
int(max(0, risk_score)),
note,
tags_json,
now.isoformat(),
),
)
conn.commit()
def _sum_events(self, customer_id: str, event_type: str, days: int) -> int:
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT COALESCE(SUM(event_count), 0) AS total
FROM customer_risk_event
WHERE customer_id=%s
AND event_type=%s
AND created_at >= (NOW() - INTERVAL %s DAY)
""",
(customer_id, event_type, int(max(1, days))),
)
row = cur.fetchone() or {}
return int(row.get("total") or 0)
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
SELECT COALESCE(SUM(event_count), 0) AS total
FROM customer_risk_event
WHERE customer_id=?
AND event_type=?
AND created_at >= datetime('now', ?)
""",
(customer_id, event_type, f"-{int(max(1, days))} day"),
)
row = cur.fetchone()
return int((row["total"] if row else 0) or 0)
def get_profile(self, customer_id: str) -> Dict[str, Any]:
out = {
"customer_id": customer_id,
"do_not_serve": False,
"risk_level": "low",
"risk_score": 0,
"note": "",
"tags": [],
}
if self.backend == "mysql":
with self._get_mysql_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT customer_id, do_not_serve, risk_level, risk_score, note, tags_json
FROM customer_risk_profile
WHERE customer_id=%s
LIMIT 1
""",
(customer_id,),
)
row = cur.fetchone()
if not row:
return out
out.update(
{
"do_not_serve": bool(row.get("do_not_serve")),
"risk_level": str(row.get("risk_level") or "low"),
"risk_score": int(row.get("risk_score") or 0),
"note": str(row.get("note") or ""),
"tags": json.loads(row.get("tags_json") or "[]"),
}
)
return out
with self._get_sqlite_conn() as conn:
cur = conn.cursor()
cur.execute(
"""
SELECT customer_id, do_not_serve, risk_level, risk_score, note, tags_json
FROM customer_risk_profile
WHERE customer_id=?
LIMIT 1
""",
(customer_id,),
)
row = cur.fetchone()
if not row:
return out
out.update(
{
"do_not_serve": bool(row["do_not_serve"]),
"risk_level": str(row["risk_level"] or "low"),
"risk_score": int(row["risk_score"] or 0),
"note": str(row["note"] or ""),
"tags": json.loads(row["tags_json"] or "[]"),
}
)
return out
def evaluate_customer(self, customer_id: str) -> Dict[str, Any]:
profile = self.get_profile(customer_id)
refund_30d = self._sum_events(customer_id, "refund", 30)
unpaid_7d = self._sum_events(customer_id, "unpaid_order", 7)
bad_review_90d = self._sum_events(customer_id, "bad_review", 90)
score = int(profile.get("risk_score") or 0)
score += refund_30d * 20
score += unpaid_7d * 8
score += bad_review_90d * 15
level = "low"
if score >= 70:
level = "high"
elif score >= 35:
level = "medium"
return {
**profile,
"refund_30d": refund_30d,
"unpaid_7d": unpaid_7d,
"bad_review_90d": bad_review_90d,
"computed_score": score,
"computed_level": level,
}
risk_db = CustomerRiskDB()

View File

@@ -1,246 +0,0 @@
# -*- coding: utf-8 -*-
"""
成交/未成交记录 - 用于日报与数据分析
"""
import sqlite3
import os
from datetime import datetime
from typing import List, Dict, Optional
_DB_PATH = os.path.join(os.path.dirname(__file__), "deal_outcome_db", "outcomes.db")
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
_MYSQL_USER = os.getenv("MYSQL_USER", "root")
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
class _CompatResult:
def __init__(self, rows=None, rowcount: int = 0, lastrowid: int = 0):
self._rows = rows or []
self.rowcount = rowcount
self.lastrowid = lastrowid
def fetchall(self):
return self._rows
def fetchone(self):
return self._rows[0] if self._rows else None
class _PyMySQLCompatConn:
def __init__(self, conn):
self._conn = conn
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
if exc_type:
try:
self._conn.rollback()
except Exception:
pass
self._conn.close()
def execute(self, query: str, args=None):
cur = self._conn.cursor()
cur.execute(query, args or ())
rows = cur.fetchall() if cur.description else []
res = _CompatResult(rows=rows, rowcount=cur.rowcount, lastrowid=getattr(cur, "lastrowid", 0))
cur.close()
return res
def commit(self):
self._conn.commit()
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
def _sql(query: str) -> str:
return query.replace("?", "%s") if _is_mysql() else query
def _get_conn() -> sqlite3.Connection:
if _is_mysql():
import pymysql
conn = pymysql.connect(
host=_MYSQL_HOST,
port=_MYSQL_PORT,
user=_MYSQL_USER,
password=_MYSQL_PASSWORD,
database=_MYSQL_DATABASE,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
return _PyMySQLCompatConn(conn)
os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
conn = sqlite3.connect(_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def _init_db():
with _get_conn() as conn:
if _is_mysql():
conn.execute("""
CREATE TABLE IF NOT EXISTS deal_outcomes (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
customer_id VARCHAR(128) NOT NULL,
customer_name VARCHAR(255) DEFAULT '',
acc_id VARCHAR(128) DEFAULT '',
platform VARCHAR(64) DEFAULT '',
date DATE NOT NULL,
outcome VARCHAR(16) NOT NULL,
reason TEXT,
order_id VARCHAR(128) DEFAULT '',
amount REAL DEFAULT 0,
discount_given INTEGER DEFAULT 0,
timestamp DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
idx_rows = conn.execute("SHOW INDEX FROM deal_outcomes").fetchall()
exists = {str(r.get("Key_name", "")) for r in idx_rows}
if "idx_deal_date" not in exists:
conn.execute("CREATE INDEX idx_deal_date ON deal_outcomes(date)")
if "idx_deal_customer" not in exists:
conn.execute("CREATE INDEX idx_deal_customer ON deal_outcomes(customer_id)")
if "idx_deal_acc" not in exists:
conn.execute("CREATE INDEX idx_deal_acc ON deal_outcomes(acc_id)")
if "idx_deal_outcome" not in exists:
conn.execute("CREATE INDEX idx_deal_outcome ON deal_outcomes(outcome)")
else:
conn.execute("""
CREATE TABLE IF NOT EXISTS deal_outcomes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
customer_name TEXT DEFAULT '',
acc_id TEXT DEFAULT '',
platform TEXT DEFAULT '',
date TEXT NOT NULL,
outcome TEXT NOT NULL CHECK(outcome IN ('成交','未成交')),
reason TEXT DEFAULT '',
order_id TEXT DEFAULT '',
amount REAL DEFAULT 0,
discount_given INTEGER DEFAULT 0,
timestamp TEXT NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_date ON deal_outcomes(date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_customer ON deal_outcomes(customer_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_acc ON deal_outcomes(acc_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deal_outcome ON deal_outcomes(outcome)")
conn.commit()
_init_db()
def record_deal(
customer_id: str,
outcome: str,
reason: str = "",
customer_name: str = "",
acc_id: str = "",
platform: str = "",
order_id: str = "",
amount: float = 0,
discount_given: bool = False,
):
"""记录一笔成交或未成交"""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
date = datetime.now().strftime("%Y-%m-%d")
with _get_conn() as conn:
conn.execute(
_sql("""INSERT INTO deal_outcomes
(customer_id, customer_name, acc_id, platform, date, outcome, reason,
order_id, amount, discount_given, timestamp)
VALUES (?,?,?,?,?,?,?,?,?,?,?)"""),
(
customer_id,
customer_name or "",
acc_id or "",
platform or "",
date,
outcome,
reason or "",
order_id or "",
amount,
1 if discount_given else 0,
ts,
),
)
conn.commit()
def get_daily_outcomes(date: str = "") -> List[Dict]:
"""获取指定日期的成交/未成交记录,用于日报"""
if not date:
date = datetime.now().strftime("%Y-%m-%d")
with _get_conn() as conn:
rows = conn.execute(
_sql("""
SELECT customer_id, customer_name, acc_id, outcome, reason,
order_id, amount, discount_given, timestamp
FROM deal_outcomes
WHERE date = ?
ORDER BY timestamp ASC
"""),
(date,),
).fetchall()
return [dict(r) for r in rows]
def get_daily_summary(date: str = "") -> Dict:
"""获取指定日期的成交/未成交汇总统计"""
outcomes = get_daily_outcomes(date)
success = [o for o in outcomes if o["outcome"] == "成交"]
fail = [o for o in outcomes if o["outcome"] == "未成交"]
# 按原因分组
fail_by_reason: Dict[str, int] = {}
for o in fail:
r = o.get("reason") or "其他"
fail_by_reason[r] = fail_by_reason.get(r, 0) + 1
return {
"date": date or datetime.now().strftime("%Y-%m-%d"),
"成交数": len(success),
"未成交数": len(fail),
"成交金额": sum(o.get("amount") or 0 for o in success),
"成交明细": success,
"未成交明细": fail,
"未成交原因分布": fail_by_reason,
}
def export_for_analysis(start_date: str = "", end_date: str = "") -> List[Dict]:
"""
导出成交/未成交记录,供数据库分析。
日期格式 YYYY-MM-DD留空则查全部。
"""
with _get_conn() as conn:
if start_date and end_date:
rows = conn.execute(
_sql("""SELECT * FROM deal_outcomes
WHERE date BETWEEN ? AND ?
ORDER BY date, timestamp"""),
(start_date, end_date),
).fetchall()
elif start_date:
rows = conn.execute(
_sql("""SELECT * FROM deal_outcomes WHERE date >= ? ORDER BY date, timestamp"""),
(start_date,),
).fetchall()
elif end_date:
rows = conn.execute(
_sql("""SELECT * FROM deal_outcomes WHERE date <= ? ORDER BY date, timestamp"""),
(end_date,),
).fetchall()
else:
rows = conn.execute(
"""SELECT * FROM deal_outcomes ORDER BY date, timestamp"""
).fetchall()
return [dict(r) for r in rows]

Binary file not shown.

View File

@@ -1,279 +0,0 @@
# -*- coding: utf-8 -*-
"""
设计师派单数据库SQLite
同一设计师在不同店铺对应不同 group_id派单时从在线设计师中轮询。
企微群「上线」/「下线」通过 update_online(wechat_user_id, is_online) 更新。
"""
import sqlite3
import os
from typing import Optional
_DB_PATH = os.path.join(os.path.dirname(__file__), "designer_roster_db", "roster.db")
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
_MYSQL_USER = os.getenv("MYSQL_USER", "root")
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
class _CompatResult:
def __init__(self, rows=None, rowcount: int = 0, lastrowid: int = 0):
self._rows = rows or []
self.rowcount = rowcount
self.lastrowid = lastrowid
def fetchall(self):
return self._rows
def fetchone(self):
return self._rows[0] if self._rows else None
class _PyMySQLCompatConn:
def __init__(self, conn):
self._conn = conn
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
if exc_type:
try:
self._conn.rollback()
except Exception:
pass
self._conn.close()
def execute(self, query: str, args=None):
cur = self._conn.cursor()
cur.execute(query, args or ())
rows = cur.fetchall() if cur.description else []
res = _CompatResult(rows=rows, rowcount=cur.rowcount, lastrowid=getattr(cur, "lastrowid", 0))
cur.close()
return res
def commit(self):
self._conn.commit()
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
def _sql(query: str) -> str:
return query.replace("?", "%s") if _is_mysql() else query
def _get_conn() -> sqlite3.Connection:
if _is_mysql():
import pymysql
conn = pymysql.connect(
host=_MYSQL_HOST,
port=_MYSQL_PORT,
user=_MYSQL_USER,
password=_MYSQL_PASSWORD,
database=_MYSQL_DATABASE,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
)
return _PyMySQLCompatConn(conn)
os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
conn = sqlite3.connect(_DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with _get_conn() as conn:
if _is_mysql():
conn.execute("""
CREATE TABLE IF NOT EXISTS designers (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
wechat_user_id VARCHAR(128) UNIQUE NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_shops (
designer_id INTEGER NOT NULL,
shop_id VARCHAR(128) NOT NULL,
group_id VARCHAR(128) NOT NULL,
PRIMARY KEY (designer_id, shop_id),
FOREIGN KEY (designer_id) REFERENCES designers(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_online (
wechat_user_id VARCHAR(128) PRIMARY KEY,
is_online INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS round_robin (
shop_id VARCHAR(128) PRIMARY KEY,
last_index INTEGER NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
else:
conn.execute("""
CREATE TABLE IF NOT EXISTS designers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
wechat_user_id TEXT UNIQUE NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_shops (
designer_id INTEGER NOT NULL,
shop_id TEXT NOT NULL,
group_id TEXT NOT NULL,
PRIMARY KEY (designer_id, shop_id),
FOREIGN KEY (designer_id) REFERENCES designers(id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS designer_online (
wechat_user_id TEXT PRIMARY KEY,
is_online INTEGER NOT NULL DEFAULT 0,
updated_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS round_robin (
shop_id TEXT PRIMARY KEY,
last_index INTEGER NOT NULL DEFAULT 0
)
""")
conn.commit()
init_db()
# ========== 设计师管理 ==========
def add_designer(name: str, wechat_user_id: str) -> int:
"""添加设计师,返回 id"""
with _get_conn() as conn:
if _is_mysql():
conn.execute(
"INSERT IGNORE INTO designers (name, wechat_user_id) VALUES (%s, %s)",
(name, wechat_user_id),
)
else:
conn.execute(
"INSERT OR IGNORE INTO designers (name, wechat_user_id) VALUES (?, ?)",
(name, wechat_user_id),
)
conn.commit()
row = conn.execute(_sql("SELECT id FROM designers WHERE wechat_user_id = ?"), (wechat_user_id,)).fetchone()
return row["id"] if row else 0
def set_designer_shop(designer_id: int, shop_id: str, group_id: str):
"""设置设计师在某店铺的分组 ID同一设计师不同店铺不同 group_id"""
with _get_conn() as conn:
if _is_mysql():
conn.execute(
"REPLACE INTO designer_shops (designer_id, shop_id, group_id) VALUES (%s, %s, %s)",
(designer_id, shop_id, group_id),
)
else:
conn.execute(
"INSERT OR REPLACE INTO designer_shops (designer_id, shop_id, group_id) VALUES (?, ?, ?)",
(designer_id, shop_id, group_id),
)
conn.commit()
def update_online(wechat_user_id: str, is_online: bool):
"""更新设计师在线状态(企微群「上线」/「下线」解析后调用)"""
from datetime import datetime
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with _get_conn() as conn:
if _is_mysql():
conn.execute(
"REPLACE INTO designer_online (wechat_user_id, is_online, updated_at) VALUES (%s, %s, %s)",
(wechat_user_id, 1 if is_online else 0, ts),
)
else:
conn.execute(
"INSERT OR REPLACE INTO designer_online (wechat_user_id, is_online, updated_at) VALUES (?, ?, ?)",
(wechat_user_id, 1 if is_online else 0, ts),
)
conn.commit()
# ========== 派单 ==========
def get_transfer_group_for_shop(shop_id: str) -> Optional[str]:
"""
为店铺轮询派单,返回分组 ID。
从该店铺的在线设计师中轮询选一个,返回其在该店铺的 group_id。
无人在线则返回 None。
"""
with _get_conn() as conn:
rows = conn.execute(_sql("""
SELECT d.wechat_user_id, ds.group_id
FROM designer_shops ds
JOIN designers d ON d.id = ds.designer_id
JOIN designer_online o ON o.wechat_user_id = d.wechat_user_id AND o.is_online = 1
WHERE ds.shop_id = ?
"""), (shop_id,)).fetchall()
if not rows:
return None
with _get_conn() as conn:
rr = conn.execute(_sql("SELECT last_index FROM round_robin WHERE shop_id = ?"), (shop_id,)).fetchone()
last = rr["last_index"] if rr else 0
idx = last % len(rows)
chosen = rows[idx]
if _is_mysql():
conn.execute(
"REPLACE INTO round_robin (shop_id, last_index) VALUES (%s, %s)",
(shop_id, idx + 1),
)
else:
conn.execute(
"INSERT OR REPLACE INTO round_robin (shop_id, last_index) VALUES (?, ?)",
(shop_id, idx + 1),
)
conn.commit()
return chosen["group_id"]
# ========== 查询 ==========
def get_all_wechat_user_ids() -> list:
"""获取所有设计师的 wechat_user_id用于同步在线状态"""
with _get_conn() as conn:
rows = conn.execute("SELECT wechat_user_id FROM designers").fetchall()
return [r["wechat_user_id"] for r in rows]
def list_designers():
"""列出所有设计师及其店铺分组"""
with _get_conn() as conn:
designers = conn.execute("SELECT id, name, wechat_user_id FROM designers").fetchall()
result = []
for d in designers:
shops = conn.execute(
_sql("SELECT shop_id, group_id FROM designer_shops WHERE designer_id = ?"),
(d["id"],),
).fetchall()
online = conn.execute(
_sql("SELECT is_online FROM designer_online WHERE wechat_user_id = ?"),
(d["wechat_user_id"],),
).fetchone()
result.append({
"id": d["id"],
"name": d["name"],
"wechat_user_id": d["wechat_user_id"],
"shops": {s["shop_id"]: s["group_id"] for s in shops},
"is_online": bool(online and online["is_online"]),
})
return result

Binary file not shown.

View File

@@ -1,480 +1,141 @@
# -*- coding: utf-8 -*-
"""
图片任务数据库管理
支持客户后续增加需求细节
"""
import sqlite3
import json
import logging
import uuid
import os
from typing import Optional, List, Dict
from pathlib import Path
from datetime import datetime
from enum import Enum
import os
logger = logging.getLogger(__name__)
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
_MYSQL_USER = os.getenv("MYSQL_USER", "root")
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
def _is_mysql() -> bool:
return _DB_TYPE in ("mysql", "mariadb")
def _sql(query: str) -> str:
return query.replace("?", "%s") if _is_mysql() else query
def _now_str() -> str:
if _is_mysql():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return datetime.now().isoformat()
class TaskStatus(Enum):
"""任务状态"""
PENDING = "pending" # 待付款
PAID = "paid" # 已付款,待处理
PROCESSING = "processing" # 处理中
AWAITING_CONFIRM = "awaiting_confirm" # 已完成,待客户确认
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
CANCELLED = "cancelled" # 已取消
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
class ImageTaskManager:
"""图片任务管理器"""
def __init__(self, db_path: str = None):
if db_path is None:
db_path = Path(__file__).parent / "image_tasks.db"
self.db_path = db_path
self._init_db()
logger.info(f"图片任务管理器初始化完成,数据库:{self.db_path}")
def _init_db(self):
"""初始化数据库"""
if _is_mysql():
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS image_tasks (
task_id VARCHAR(128) PRIMARY KEY,
customer_id VARCHAR(128) NOT NULL,
customer_name VARCHAR(255),
original_image TEXT NOT NULL,
operation VARCHAR(64) DEFAULT 'enhance',
requirements TEXT,
customer_notes TEXT,
status VARCHAR(32) DEFAULT 'pending',
created_at DATETIME,
paid_at DATETIME,
started_at DATETIME,
completed_at DATETIME,
result_image TEXT,
error_message TEXT,
retry_count INT DEFAULT 0,
acc_id VARCHAR(128),
acc_type VARCHAR(64) DEFAULT 'AliWorkbench'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS task_requirement_changes (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
task_id VARCHAR(128) NOT NULL,
change_type VARCHAR(64),
old_value TEXT,
new_value TEXT,
changed_at DATETIME,
changed_by VARCHAR(32),
FOREIGN KEY (task_id) REFERENCES image_tasks(task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
''')
cursor.execute("SHOW INDEX FROM image_tasks")
exists = {str(r.get("Key_name", "")) for r in cursor.fetchall()}
if "idx_customer" not in exists:
cursor.execute('CREATE INDEX idx_customer ON image_tasks(customer_id)')
if "idx_status" not in exists:
cursor.execute('CREATE INDEX idx_status ON image_tasks(status)')
if "idx_created" not in exists:
cursor.execute('CREATE INDEX idx_created ON image_tasks(created_at)')
conn.commit()
conn.close()
else:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS image_tasks (
task_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
customer_name TEXT,
original_image TEXT NOT NULL,
operation TEXT DEFAULT 'enhance',
requirements TEXT,
customer_notes TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT,
paid_at TEXT,
started_at TEXT,
completed_at TEXT,
result_image TEXT,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
acc_id TEXT,
acc_type TEXT DEFAULT 'AliWorkbench'
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS task_requirement_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
change_type TEXT,
old_value TEXT,
new_value TEXT,
changed_at TEXT,
changed_by TEXT,
FOREIGN KEY (task_id) REFERENCES image_tasks(task_id)
)
''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_customer ON image_tasks(customer_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON image_tasks(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_created ON image_tasks(created_at)')
conn.commit()
conn.close()
logger.info("数据库表初始化完成")
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 核心任务表
cursor.execute('''
CREATE TABLE IF NOT EXISTS image_tasks (
task_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
platform TEXT DEFAULT 'qianniu',
original_image TEXT NOT NULL,
operation TEXT DEFAULT 'enhance',
requirements TEXT,
status TEXT DEFAULT 'pending',
result_image TEXT,
price REAL DEFAULT 0.0,
outcome TEXT DEFAULT 'pending',
created_at TEXT,
updated_at TEXT
)
''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON image_tasks(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cust_plat ON image_tasks(customer_id, platform)')
# 兼容旧库:补齐缺失字段
cursor.execute("PRAGMA table_info(image_tasks)")
existing_cols = {row[1] for row in cursor.fetchall()}
if "outcome" not in existing_cols:
cursor.execute("ALTER TABLE image_tasks ADD COLUMN outcome TEXT DEFAULT 'pending'")
if "price" not in existing_cols:
cursor.execute("ALTER TABLE image_tasks ADD COLUMN price REAL DEFAULT 0.0")
conn.commit()
conn.close()
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,
)
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def create_task(self, task_id: str, customer_id: str, customer_name: str,
original_image: str, operation: str = 'enhance',
requirements: dict = None, acc_id: str = '', acc_type: str = 'AliWorkbench') -> bool:
"""创建图片任务"""
def add_task(self, customer_id: str, platform: str, original_image: str, operation: str, requirements: str = "", status: str = "pending") -> str:
task_id = str(uuid.uuid4())
now = datetime.now().isoformat()
try:
conn = self._get_conn()
cursor = conn.cursor()
requirements_json = json.dumps(requirements) if requirements else None
cursor.execute(_sql('''
INSERT INTO image_tasks (
task_id, customer_id, customer_name, original_image,
operation, requirements, customer_notes, status,
created_at, acc_id, acc_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
'''), (
task_id,
customer_id,
customer_name,
original_image,
operation,
requirements_json,
'', # 初始备注为空
TaskStatus.PENDING.value,
_now_str(),
acc_id,
acc_type
))
cursor.execute('''
INSERT INTO image_tasks (task_id, customer_id, platform, original_image, operation, requirements, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (task_id, customer_id, platform, original_image, operation, requirements, status, now, now))
conn.commit()
conn.close()
logger.info(f"图片任务创建成功:{task_id}")
return True
return task_id
except Exception as e:
logger.error(f"创建图片任务失败:{e}")
return False
logger.error(f"Failed to add task: {e}")
return ""
def get_task(self, task_id: str) -> Optional[dict]:
"""查询任务"""
def update_status(self, task_id: str, status: str, result_image: str = ""):
now = datetime.now().isoformat()
try:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute(_sql('SELECT * FROM image_tasks WHERE task_id = ?'), (task_id,))
row = cursor.fetchone()
conn.close()
if row:
task = dict(row)
# 解析 JSON 字段
if task.get('requirements'):
task['requirements'] = json.loads(task['requirements'])
return task
return None
except Exception as e:
logger.error(f"查询任务失败:{e}")
return None
def get_customer_tasks(self, customer_id: str, status: str = None) -> List[dict]:
"""查询客户的任务列表"""
try:
conn = self._get_conn()
cursor = conn.cursor()
if status:
cursor.execute(_sql('''
SELECT * FROM image_tasks
WHERE customer_id = ? AND status = ?
ORDER BY created_at DESC
'''), (customer_id, status))
if result_image:
cursor.execute('UPDATE image_tasks SET status = ?, result_image = ?, updated_at = ? WHERE task_id = ?',
(status, result_image, now, task_id))
else:
cursor.execute(_sql('''
SELECT * FROM image_tasks
WHERE customer_id = ?
ORDER BY created_at DESC
'''), (customer_id,))
rows = cursor.fetchall()
conn.close()
tasks = []
for row in rows:
task = dict(row)
if task.get('requirements'):
task['requirements'] = json.loads(task['requirements'])
tasks.append(task)
return tasks
except Exception as e:
logger.error(f"查询客户任务失败:{e}")
return []
def update_status(self, task_id: str, status: TaskStatus):
"""更新任务状态"""
try:
conn = self._get_conn()
cursor = conn.cursor()
placeholder = "%s" if _is_mysql() else "?"
updates = [f'status = {placeholder}']
params = [status.value]
# 根据状态设置时间
if status == TaskStatus.PAID:
updates.append(f'paid_at = {placeholder}')
params.append(_now_str())
elif status == TaskStatus.PROCESSING:
updates.append(f'started_at = {placeholder}')
params.append(_now_str())
elif status in [TaskStatus.COMPLETED, TaskStatus.FAILED]:
updates.append(f'completed_at = {placeholder}')
params.append(_now_str())
params.append(task_id)
cursor.execute(_sql(f'''
UPDATE image_tasks
SET {', '.join(updates)}
WHERE task_id = ?
'''), params)
cursor.execute('UPDATE image_tasks SET status = ?, updated_at = ? WHERE task_id = ?',
(status, now, task_id))
conn.commit()
conn.close()
logger.info(f"任务状态更新:{task_id} -> {status.value}")
except Exception as e:
logger.error(f"更新任务状态失败:{e}")
logger.error(f"Failed to update task status: {e}")
def update_result(self, task_id: str, result_image: str, error_message: str = None):
"""更新处理结果"""
def update_price(self, customer_id: str, platform: str, price: float):
"""记录任务的成交价格"""
now = datetime.now().isoformat()
try:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute(_sql('''
cursor.execute('''
UPDATE image_tasks
SET result_image = ?, error_message = ?
WHERE task_id = ?
'''), (result_image, error_message, task_id))
SET price = ?, updated_at = ?
WHERE task_id = (
SELECT task_id FROM image_tasks
WHERE customer_id = ? AND platform = ?
ORDER BY created_at DESC LIMIT 1
)
''', (price, now, customer_id, platform))
conn.commit()
conn.close()
logger.info(f"任务结果更新:{task_id}")
logger.info(f"[DB] 客户 {customer_id} 任务价格更新为: ¥{price}")
except Exception as e:
logger.error(f"更新任务结果失败:{e}")
logger.error(f"Failed to update price: {e}")
def add_customer_note(self, task_id: str, note: str, changed_by: str = 'customer') -> bool:
"""
客户添加需求备注(支持后续增加细节)
Args:
task_id: 任务 ID
note: 备注内容
changed_by: 修改者customer/staff
Returns:
bool: 是否成功
"""
def update_outcome(self, customer_id: str, platform: str, outcome: str):
"""记录任务的最终结局(用于训练样本分类)"""
now = datetime.now().isoformat()
try:
conn = self._get_conn()
cursor = conn.cursor()
# 获取旧备注
cursor.execute(_sql('SELECT customer_notes FROM image_tasks WHERE task_id = ?'), (task_id,))
row = cursor.fetchone()
old_note = row['customer_notes'] if row else ''
# 更新备注
new_note = f"{old_note}\n[{datetime.now().strftime('%m-%d %H:%M')}] {note}" if old_note else f"[{datetime.now().strftime('%m-%d %H:%M')}] {note}"
cursor.execute(_sql('''
cursor.execute('''
UPDATE image_tasks
SET customer_notes = ?
WHERE task_id = ?
'''), (new_note, task_id))
# 记录变更历史
cursor.execute(_sql('''
INSERT INTO task_requirement_changes (
task_id, change_type, old_value, new_value, changed_at, changed_by
) VALUES (?, ?, ?, ?, ?, ?)
'''), (
task_id,
'add_note',
old_note or '',
note,
_now_str(),
changed_by
))
SET outcome = ?, updated_at = ?
WHERE task_id = (
SELECT task_id FROM image_tasks
WHERE customer_id = ? AND platform = ?
ORDER BY created_at DESC LIMIT 1
)
''', (outcome, now, customer_id, platform))
conn.commit()
conn.close()
logger.info(f"客户添加备注成功:{task_id}")
return True
logger.info(f"[DB] 客户 {customer_id} 任务结局更新为: {outcome}")
except Exception as e:
logger.error(f"添加客户备注失败:{e}")
return False
def modify_operation(self, task_id: str, new_operation: str, changed_by: str = 'customer') -> bool:
"""
修改操作类型(客户后续修改需求)
Args:
task_id: 任务 ID
new_operation: 新操作类型
changed_by: 修改者
Returns:
bool: 是否成功
"""
try:
conn = self._get_conn()
cursor = conn.cursor()
# 获取旧操作
cursor.execute(_sql('SELECT operation FROM image_tasks WHERE task_id = ?'), (task_id,))
row = cursor.fetchone()
old_operation = row['operation'] if row else ''
# 更新操作
cursor.execute(_sql('''
UPDATE image_tasks
SET operation = ?
WHERE task_id = ?
'''), (new_operation, task_id))
# 记录变更历史
cursor.execute(_sql('''
INSERT INTO task_requirement_changes (
task_id, change_type, old_value, new_value, changed_at, changed_by
) VALUES (?, ?, ?, ?, ?, ?)
'''), (
task_id,
'modify_operation',
old_operation,
new_operation,
_now_str(),
changed_by
))
conn.commit()
conn.close()
logger.info(f"修改操作类型成功:{task_id} -> {new_operation}")
return True
except Exception as e:
logger.error(f"修改操作类型失败:{e}")
return False
def get_requirement_history(self, task_id: str) -> List[dict]:
"""获取需求变更历史"""
try:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute(_sql('''
SELECT * FROM task_requirement_changes
WHERE task_id = ?
ORDER BY changed_at DESC
'''), (task_id,))
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
except Exception as e:
logger.error(f"查询需求历史失败:{e}")
return []
def get_pending_tasks(self) -> List[dict]:
"""获取所有待处理任务"""
return self.get_customer_tasks('', 'pending')
def increment_retry(self, task_id: str) -> int:
"""增加重试次数"""
try:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute(_sql('''
UPDATE image_tasks
SET retry_count = retry_count + 1
WHERE task_id = ?
'''), (task_id,))
cursor.execute(_sql('SELECT retry_count FROM image_tasks WHERE task_id = ?'), (task_id,))
row = cursor.fetchone()
conn.close()
return row['retry_count'] if row else 0
except Exception as e:
logger.error(f"增加重试次数失败:{e}")
return 999
logger.error(f"Failed to update outcome: {e}")
# 单例
_task_manager: Optional[ImageTaskManager] = None
def get_image_task_manager() -> ImageTaskManager:
"""获取图片任务管理器单例"""
global _task_manager
if _task_manager is None:
_task_manager = ImageTaskManager()
return _task_manager
db = ImageTaskManager()

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

View File

@@ -1,2 +0,0 @@
"""Self-evolution MVP utilities for the customer service agent."""

View File

@@ -1,591 +0,0 @@
from __future__ import annotations
import json
import os
import sqlite3
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
ROOT = Path(__file__).resolve().parent.parent
ARTIFACT_DIR = ROOT / "evolution" / "artifacts"
DEFAULT_POLICY_PATH = ROOT / "config" / "evolution_policy.json"
DEFAULT_CANDIDATE_PATH = ROOT / "config" / "evolution_candidate.json"
RISK_KEYWORDS = (
"退款",
"退货",
"投诉",
"差评",
"举报",
"欺骗",
"骗人",
"不满意",
"生气",
"法院",
"起诉",
)
TRANSFER_HINTS = ("转人工", "人工", "为您转接", "专员", "稍后联系")
WEAK_REPLY_HINTS = ("不清楚", "不知道", "稍后", "晚点", "我再看下", "等会")
EMPATHY_HINTS = ("抱歉", "不好意思", "理解", "辛苦", "感谢反馈")
@dataclass
class Sample:
customer_id: str
acc_id: str
in_ts: str
in_text: str
out_ts: str
out_text: str
latency_sec: int
@dataclass
class Finding:
kind: str
severity: str
customer_id: str
acc_id: str
in_ts: str
in_text: str
out_text: str
detail: str
@dataclass
class ChatSourceConfig:
source: str = "auto" # auto | sqlite | mysql
sqlite_path: str = str(ROOT / "db" / "chat_log_db" / "chats.db")
mysql_host: str = os.getenv("MYSQL_HOST", "127.0.0.1")
mysql_port: int = int(os.getenv("MYSQL_PORT", "3306"))
mysql_user: str = os.getenv("MYSQL_USER", "root")
mysql_password: str = os.getenv("MYSQL_PASSWORD", "")
mysql_database: str = os.getenv("MYSQL_DATABASE", "ai_cs")
def _parse_ts(ts_text: str) -> Optional[datetime]:
if not ts_text:
return None
try:
return datetime.strptime(ts_text, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
def _to_ts_text(value: Any) -> str:
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
if value is None:
return ""
return str(value)
def _iter_recent_conversations_sqlite(
cfg: ChatSourceConfig,
hours: int,
max_customers: int,
max_messages_per_customer: int,
) -> Iterable[Tuple[str, List[Dict[str, Any]]]]:
cutoff_dt = datetime.now() - timedelta(hours=hours)
cutoff_text = cutoff_dt.strftime("%Y-%m-%d %H:%M:%S")
db_path = Path(cfg.sqlite_path)
if not db_path.exists():
return
conn = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
try:
cur = conn.execute(
"""
SELECT customer_id, MAX(timestamp) AS last_ts
FROM chat_logs
WHERE timestamp >= ?
GROUP BY customer_id
ORDER BY last_ts DESC
LIMIT ?
""",
(cutoff_text, max_customers),
)
customers = [dict(r) for r in cur.fetchall()]
for c in customers:
customer_id = str(c.get("customer_id") or "").strip()
if not customer_id:
continue
rows_cur = conn.execute(
"""
SELECT direction, message, timestamp, acc_id
FROM chat_logs
WHERE customer_id = ? AND timestamp >= ?
ORDER BY timestamp ASC, id ASC
LIMIT ?
""",
(customer_id, cutoff_text, max_messages_per_customer),
)
rows = [dict(r) for r in rows_cur.fetchall()]
if rows:
yield customer_id, rows
finally:
conn.close()
def _iter_recent_conversations_mysql(
cfg: ChatSourceConfig,
hours: int,
max_customers: int,
max_messages_per_customer: int,
) -> Iterable[Tuple[str, List[Dict[str, Any]]]]:
try:
import pymysql
except Exception:
return
cutoff_dt = datetime.now() - timedelta(hours=hours)
try:
conn = pymysql.connect(
host=cfg.mysql_host,
port=cfg.mysql_port,
user=cfg.mysql_user,
password=cfg.mysql_password,
database=cfg.mysql_database,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
)
except Exception:
return
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT customer_id, MAX(timestamp) AS last_ts
FROM chat_logs
WHERE timestamp >= %s
GROUP BY customer_id
ORDER BY last_ts DESC
LIMIT %s
""",
(cutoff_dt, max_customers),
)
customers = cur.fetchall() or []
for c in customers:
customer_id = str(c.get("customer_id") or "").strip()
if not customer_id:
continue
with conn.cursor() as cur:
cur.execute(
"""
SELECT direction, message, timestamp, acc_id
FROM chat_logs
WHERE customer_id = %s AND timestamp >= %s
ORDER BY timestamp ASC, id ASC
LIMIT %s
""",
(customer_id, cutoff_dt, max_messages_per_customer),
)
rows = cur.fetchall() or []
normalized = []
for r in rows:
normalized.append(
{
"direction": r.get("direction"),
"message": r.get("message"),
"timestamp": _to_ts_text(r.get("timestamp")),
"acc_id": r.get("acc_id"),
}
)
if normalized:
yield customer_id, normalized
finally:
conn.close()
def _iter_recent_conversations(
cfg: ChatSourceConfig,
hours: int,
max_customers: int,
max_messages_per_customer: int,
) -> Iterable[Tuple[str, List[Dict[str, Any]]]]:
source = (cfg.source or "auto").strip().lower()
if source == "sqlite":
yield from _iter_recent_conversations_sqlite(cfg, hours, max_customers, max_messages_per_customer)
return
if source == "mysql":
yield from _iter_recent_conversations_mysql(cfg, hours, max_customers, max_messages_per_customer)
return
# auto: prefer mysql when DB_TYPE=mysql, otherwise sqlite
db_type = os.getenv("DB_TYPE", "").strip().lower()
if db_type in ("mysql", "mariadb"):
got_any = False
for item in _iter_recent_conversations_mysql(cfg, hours, max_customers, max_messages_per_customer):
got_any = True
yield item
if got_any:
return
yield from _iter_recent_conversations_sqlite(cfg, hours, max_customers, max_messages_per_customer)
def build_samples(
hours: int = 24,
max_customers: int = 200,
max_messages_per_customer: int = 80,
chat_source: Optional[ChatSourceConfig] = None,
) -> List[Sample]:
cfg = chat_source or ChatSourceConfig()
samples: List[Sample] = []
for customer_id, rows in _iter_recent_conversations(
cfg=cfg,
hours=hours,
max_customers=max_customers,
max_messages_per_customer=max_messages_per_customer,
):
pending_in: Optional[Dict[str, Any]] = None
for row in rows:
direction = str(row.get("direction") or "")
if direction == "in":
pending_in = row
continue
if direction != "out" or pending_in is None:
continue
in_text = str(pending_in.get("message") or "").strip()
out_text = str(row.get("message") or "").strip()
if not in_text:
pending_in = None
continue
in_ts = _parse_ts(str(pending_in.get("timestamp") or ""))
out_ts = _parse_ts(str(row.get("timestamp") or ""))
latency = 0
if in_ts and out_ts:
latency = int((out_ts - in_ts).total_seconds())
samples.append(
Sample(
customer_id=customer_id,
acc_id=str(row.get("acc_id") or pending_in.get("acc_id") or ""),
in_ts=str(pending_in.get("timestamp") or ""),
in_text=in_text,
out_ts=str(row.get("timestamp") or ""),
out_text=out_text,
latency_sec=max(0, latency),
)
)
pending_in = None
return samples
def evaluate_samples(samples: List[Sample]) -> List[Finding]:
findings: List[Finding] = []
for s in samples:
in_text = s.in_text
out_text = s.out_text
inbound_risky = any(k in in_text for k in RISK_KEYWORDS)
if not out_text:
findings.append(
Finding(
kind="empty_reply",
severity="high",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail="收到消息但回复为空",
)
)
continue
if s.latency_sec > 600:
findings.append(
Finding(
kind="slow_reply",
severity="medium",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail=f"回复耗时 {s.latency_sec}s (>600s)",
)
)
if inbound_risky:
has_transfer = any(k in out_text for k in TRANSFER_HINTS)
has_empathy = any(k in out_text for k in EMPATHY_HINTS)
if not has_transfer:
findings.append(
Finding(
kind="risk_not_transferred",
severity="high",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail="高风险诉求未出现转人工提示",
)
)
if not has_empathy:
findings.append(
Finding(
kind="risk_no_empathy",
severity="medium",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail="高风险诉求回复缺少安抚语气",
)
)
if any(k in out_text for k in WEAK_REPLY_HINTS):
findings.append(
Finding(
kind="weak_reply",
severity="medium",
customer_id=s.customer_id,
acc_id=s.acc_id,
in_ts=s.in_ts,
in_text=s.in_text,
out_text=s.out_text,
detail="回复存在低置信度兜底话术",
)
)
return findings
def summarize_findings(findings: List[Finding]) -> Dict[str, Any]:
by_kind: Dict[str, int] = {}
by_severity: Dict[str, int] = {}
for f in findings:
by_kind[f.kind] = by_kind.get(f.kind, 0) + 1
by_severity[f.severity] = by_severity.get(f.severity, 0) + 1
return {"total": len(findings), "by_kind": by_kind, "by_severity": by_severity}
def make_proposals(findings: List[Finding], sample_count: int) -> List[Dict[str, Any]]:
summary = summarize_findings(findings)
by_kind = summary["by_kind"]
proposals: List[Dict[str, Any]] = []
if by_kind.get("risk_not_transferred", 0) > 0:
proposals.append(
{
"id": "policy-risk-transfer",
"priority": "p0",
"module": "policy/prompt",
"title": "风险关键词触发后强制转人工",
"suggestion": "在风险路由的系统提示词中增加硬规则:遇到退款/投诉/法律威胁类诉求必须调用 transfer_to_human。",
"evidence_count": by_kind["risk_not_transferred"],
}
)
if by_kind.get("risk_no_empathy", 0) > 0:
proposals.append(
{
"id": "tone-empathy-pack",
"priority": "p1",
"module": "policy/prompt",
"title": "高风险场景补充安抚模板",
"suggestion": "为投诉类回复追加一段安抚模板,降低激化概率。",
"evidence_count": by_kind["risk_no_empathy"],
}
)
if by_kind.get("weak_reply", 0) > 0:
proposals.append(
{
"id": "fallback-reduction",
"priority": "p1",
"module": "intent/router",
"title": "减少低置信度兜底话术",
"suggestion": "出现“不清楚/稍后”等兜底词时,优先触发澄清问题或转人工而非直接结束。",
"evidence_count": by_kind["weak_reply"],
}
)
if by_kind.get("slow_reply", 0) > 0:
proposals.append(
{
"id": "slow-path-timeout",
"priority": "p2",
"module": "tools/workflow",
"title": "慢链路超时与短回复兜底",
"suggestion": "当工具调用超过阈值时先发短确认回复,避免长时间无响应。",
"evidence_count": by_kind["slow_reply"],
}
)
proposals.append(
{
"id": "ops-regression-gate",
"priority": "p0",
"module": "eval/pipeline",
"title": "上线前回归门禁",
"suggestion": "新增候选策略必须在离线评测集上通过,再灰度 5% 流量后扩大。",
"evidence_count": sample_count,
}
)
return proposals
def load_policy(path: Path = DEFAULT_POLICY_PATH) -> Dict[str, Any]:
if not path.exists():
return {
"publish_gate": {
"min_sample_count": 30,
"max_high_findings_rate": 0.08,
"max_ai_fail_rate": 5.0,
"max_transfer_rate": 45.0,
}
}
return json.loads(path.read_text(encoding="utf-8"))
def can_publish_candidate(samples: List[Sample], findings: List[Finding], runtime_hours: int, policy: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
try:
from utils.metrics_tracker import get_runtime_summary
except Exception:
def get_runtime_summary(hours: int = 24) -> Dict[str, Any]:
return {"window_hours": hours, "counts": {}, "rates": {"ai_fail_rate": 0.0, "transfer_rate": 0.0}}
gate = (policy or {}).get("publish_gate", {})
min_sample_count = int(gate.get("min_sample_count", 30))
max_high_rate = float(gate.get("max_high_findings_rate", 0.08))
max_ai_fail_rate = float(gate.get("max_ai_fail_rate", 5.0))
max_transfer_rate = float(gate.get("max_transfer_rate", 45.0))
high_cnt = sum(1 for f in findings if f.severity == "high")
sample_count = max(1, len(samples))
high_rate = high_cnt / sample_count
runtime = get_runtime_summary(hours=runtime_hours)
ai_fail_rate = float(runtime.get("rates", {}).get("ai_fail_rate", 0.0))
transfer_rate = float(runtime.get("rates", {}).get("transfer_rate", 0.0))
reasons = []
ok = True
if len(samples) < min_sample_count:
ok = False
reasons.append(f"样本不足: {len(samples)} < {min_sample_count}")
if high_rate > max_high_rate:
ok = False
reasons.append(f"高危发现占比过高: {high_rate:.2%} > {max_high_rate:.2%}")
if ai_fail_rate > max_ai_fail_rate:
ok = False
reasons.append(f"AI失败率过高: {ai_fail_rate:.2f}% > {max_ai_fail_rate:.2f}%")
if transfer_rate > max_transfer_rate:
ok = False
reasons.append(f"转人工率过高: {transfer_rate:.2f}% > {max_transfer_rate:.2f}%")
return ok, {
"sample_count": len(samples),
"high_findings": high_cnt,
"high_findings_rate": round(high_rate, 4),
"runtime": runtime,
"policy_gate": gate,
"reasons": reasons,
}
def _write_json(path: Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _write_jsonl(path: Path, rows: Iterable[Dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def run_cycle(
hours: int = 24,
max_customers: int = 200,
max_messages_per_customer: int = 80,
runtime_hours: int = 24,
publish: bool = False,
chat_source: Optional[ChatSourceConfig] = None,
policy_path: Path = DEFAULT_POLICY_PATH,
candidate_path: Path = DEFAULT_CANDIDATE_PATH,
) -> Dict[str, Any]:
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
now_tag = datetime.now().strftime("%Y%m%d_%H%M%S")
source_error = ""
try:
samples = build_samples(
hours=hours,
max_customers=max_customers,
max_messages_per_customer=max_messages_per_customer,
chat_source=chat_source,
)
except Exception as e:
samples = []
source_error = str(e)
findings = evaluate_samples(samples)
proposals = make_proposals(findings=findings, sample_count=len(samples))
policy = load_policy(path=policy_path)
publish_ok, gate_report = can_publish_candidate(
samples=samples,
findings=findings,
runtime_hours=runtime_hours,
policy=policy,
)
sample_file = ARTIFACT_DIR / f"samples_{now_tag}.jsonl"
eval_file = ARTIFACT_DIR / f"eval_report_{now_tag}.json"
proposal_file = ARTIFACT_DIR / f"proposals_{now_tag}.json"
_write_jsonl(sample_file, (asdict(s) for s in samples))
_write_json(
eval_file,
{
"generated_at": datetime.now().isoformat(timespec="seconds"),
"sample_count": len(samples),
"finding_summary": summarize_findings(findings),
"publish_gate_report": gate_report,
},
)
_write_json(
proposal_file,
{
"generated_at": datetime.now().isoformat(timespec="seconds"),
"proposals": proposals,
},
)
published = False
candidate_payload: Dict[str, Any] = {}
if publish and publish_ok:
candidate_payload = {
"version": f"candidate-{now_tag}",
"created_at": datetime.now().isoformat(timespec="seconds"),
"sample_file": str(sample_file),
"eval_file": str(eval_file),
"proposal_file": str(proposal_file),
"gate_report": gate_report,
"proposals": proposals,
"status": "ready_for_gray_5_percent",
}
_write_json(candidate_path, candidate_payload)
published = True
source_view = asdict(chat_source) if chat_source else asdict(ChatSourceConfig())
if source_view.get("mysql_password"):
source_view["mysql_password"] = "***"
return {
"samples": len(samples),
"findings": len(findings),
"publish_ok": publish_ok,
"published": published,
"chat_source": source_view,
"source_error": source_error,
"artifacts": {
"samples": str(sample_file),
"evaluation": str(eval_file),
"proposals": str(proposal_file),
"candidate": str(candidate_path) if published else "",
},
"gate_report": gate_report,
"top_proposals": proposals[:3],
}

View File

@@ -1,101 +0,0 @@
# 压价引导注册功能
## 功能说明
当客户觉得价格太贵时AI 客服会分两步应对:
### 第一步:直接让价
- 只让价一次
- 不低于底线价格
- 话术自然多变
### 第二步:引导客户自己去注册
如果让价后客户还是嫌贵,引导客户**自己去注册 xinhui.cloud**获取新用户余额。
## ⚠️ 核心要求
1. **每个话术都必须带网站地址 xinhui.cloud**
2. **强调客户自己去弄**(自己去操作/自己下单/自己弄)
3. 语气直接,不要软
4. 不要说具体金额
## 触发条件
客户消息包含以下关键词:
- "贵"
- "有点贵"
- "太贵了"
- "便宜点"
- "少点"
- "打折"
## 话术示例(每个都带网站)
### 让价话术
- "那给你少点XX 吧"
- "最低 XX 了"
- "给你个优惠价 XX"
- "看你诚心要XX 给你"
### 注册引导话术(让价后仍嫌贵时使用)
**每个话术都必须包含 xinhui.cloud 网址!**
1. 「嫌贵就去 xinhui.cloud 注册个号,自己有余额可以弄」
2. 「xinhui.cloud 去注册下,新用户送体验金,自己弄便宜」
3. 「去 xinhui.cloud 注册个账号,里面有余额,自己下单划算」
4. 「自己上 xinhui.cloud 注册,新人有福利,比直接找我便宜」
5. 「xinhui.cloud 去注册个号,自己充值有优惠,自己弄吧」
6. 「嫌贵就自己去 xinhui.cloud 弄,注册送余额,比找我划算」
7. 「xinhui.cloud 注册个号,自己充值自己弄,便宜些」
8. 「去 xinhui.cloud 注册,里面有余额可以用,自己操作吧」
## 对话示例
```
客户:这个多少钱?
AI: 20 元
客户:太贵了,便宜点吧
AI: 那给你少点15 吧
客户:还是有点贵
AI: 嫌贵就去 xinhui.cloud 注册个号,自己有余额可以弄
```
或:
```
客户:有点贵啊
AI: 最低 15 了
客户:还是贵
AI: xinhui.cloud 去注册下,新用户送体验金,自己弄便宜
```
## 注意事项
1. ⚠️ **每个话术都必须带 xinhui.cloud 网址**
2. ⚠️ **强调"自己去弄""自己操作""自己下单"**
3. 不说具体余额金额
4. 每次选择不同话术变体
5. 只在让价后仍嫌贵时使用
6. 语气直接,不要软
## 代码位置
- 技能文档:`/skills/customer-service/customer-skill.md`
- Agent 代码:`/core/pydantic_ai_agent.py` 第 1745 行
## 测试方法
```bash
# 启动 AI 客服系统
systemctl start ai-cs
# 查看日志
journalctl -u ai-cs -f
```
模拟客户消息:"太贵了,便宜点吧"
预期 AI 回复:先让价,如仍嫌贵则引导客户自己去 xinhui.cloud 注册

View File

@@ -1,129 +0,0 @@
# 找茬客户识别功能
## 功能说明
识别高风险找茬客户,建议 AI 客服不做这单,直接拒绝或转人工。
---
## 🚨 高风险信号(识别 3 个以上→拒绝接单)
| 信号 | 示例 | 风险等级 |
|------|------|----------|
| **下单后立即申请退款** | 刚拍下就退款 0.88 元 | 🔴 高 |
| **从高价砍到低价** | 30 元砍到 10 元 | 🔴 高 |
| **反复问退款2 次+** | "不满意可以退吗"×2 | 🔴 高 |
| **质疑服务内容** | "源文件还是什么" | 🟡 中 |
| **质疑价值** | "就一张图片" | 🟡 中 |
| **想占便宜** | "小一点就快一点的嘛" | 🟡 中 |
| **重复问同一问题** | 同样的问题问 2 次+ | 🔴 高 |
---
## 应对策略
### 识别到 3 个以上高风险信号
**直接拒绝接单**
**话术示例**
- 「不好意思,这单做不了」
- 「去别家做吧」
- 「这单接不了,你找别人吧」
**不要**
- ❌ 不要解释原因
- ❌ 不要讨价还价
- ❌ 不要妥协
---
## 真实案例分析
### 案例:高风险找茬客户
**对话记录**
```
客户:这个原图有吗
AI: 没问题,拍下安排。
客户:拍啦
[客户立即申请退款 0.88 元]
客户太贵了10 元
AI: 30
[退款成功]
客户:要多久?
客户300×50cm
AI: 1 个小时
客户:小一点就快一点的嘛
客户20 可以吗
AI: [不回应]
客户25
客户:源文件还是什么?
客户:就一张图片
客户:不满意可以退吗
客户:不满意可以退吗(第 2 次问)
AI: 去别家做吧
```
**风险信号识别**
1. ✅ 下单后立即申请退款
2. ✅ 从 30 砍到 10 元
3. ✅ 质疑价值("就一张图片"
4. ✅ 想占便宜("小一点就快一点"
5. ✅ 重复问退款2 次)
**结论**5 个高风险信号 → **拒绝接单**
---
## 代码位置
- Agent 代码:`/core/pydantic_ai_agent.py` - 找茬客户识别规则
- 技能文档:`/skills/customer-service/customer-skill.md` - 客服话术指南
---
## 测试方法
### 模拟高风险客户
```bash
# 启动 AI 客服
systemctl start ai-cs
# 查看日志
journalctl -u ai-cs -f
```
**模拟对话**
```
客户20 可以吗
AI: 最低 30
客户25
客户:不满意可以退吗
客户:不满意可以退吗(第 2 次)
```
**预期 AI 回复**
- 「不好意思,这单做不了」
- 「去别家做吧」
---
## 注意事项
1. **识别 3 个以上信号才拒绝**:不要误伤正常客户
2. **话术简洁**:不要解释原因
3. **态度坚定**:不要妥协
4. **不调用报价工具**:直接拒绝
---
## 与转人工的区别
| 情况 | 处理方式 |
|------|----------|
| 退款/投诉/情绪激动 | 转人工 |
| 找茬客户3 个+信号) | 直接拒绝 |
| 敏感内容 | 直接拒绝 |

View File

@@ -1,45 +0,0 @@
# 自我进化 MVP可控版
目标:让客服 agent 持续变聪明,同时避免“自动改坏线上”。
## 1. 已落地能力
- 失败样本采集:从 `db/chat_log_db/chats.db` 抽取近 N 小时客服问答对。
- 离线评测:自动识别高风险未转人工、低置信度兜底、慢回复等问题。
- 改进建议生成:输出可执行的模块级 proposalprompt/router/workflow
- 发布门禁:结合运行指标(`config/.runtime_metrics.jsonl`)判断是否允许发布候选版本。
- 候选产物:通过门禁后写入 `config/evolution_candidate.json`,用于 5% 灰度。
## 2. 运行方式
```bash
python scripts/evolution_cycle.py --hours 24 --publish
```
默认即读取线上 MySQL`--source mysql`)。连接信息来自 `.env``MYSQL_*`
常用参数:
- `--max-customers 200`
- `--max-messages-per-customer 80`
- `--runtime-hours 24`
- `--policy-path config/evolution_policy.json`
## 3. 产物说明
运行后会在 `evolution/artifacts/` 生成:
- `samples_*.jsonl`:评测样本
- `eval_report_*.json`:评测摘要与门禁结果
- `proposals_*.json`:改进建议列表
`--publish` 且门禁通过时:
- 写入 `config/evolution_candidate.json`
- 状态标记为 `ready_for_gray_5_percent`
## 4. 下一步建议
-`scripts/evolution_cycle.py` 加入每日定时任务(例如凌晨 2 点)。
- 在灰度层接入 `evolution_candidate.json` 的版本号,按店铺或客户哈希做 5% 放量。
- 将 proposal 落地为具体 patch 后,先跑 `tests/` 回归,再扩大流量。

View File

@@ -1,158 +0,0 @@
# 文字加价功能
## 功能说明
当识别到图片含有很多文字时AI 客服系统会自动提高报价,不能低价。
**核心原则**:有文字跟没文字是两个价格!
---
## 价格规则
### 含文字很多时
| 原复杂度 | 原价区间 | 加价后 | 加价后区间 |
|---------|---------|--------|----------|
| simple | 10-15 元 | → normal | 15-20 元 |
| normal | 15-20 元 | → complex | 20-25 元 |
| complex | 20-25 元 | 保持不变 | 20-25 元 |
| hard | 25-30 元 | 保持不变 | 25-30 元 |
### 判断标准
**含文字很多**(需要加价):
- ✅ 图片里有大量小字
- ✅ 需要精细保留文字清晰度
- ✅ 文字需要清晰化处理
**不含文字或文字很少**(不加价):
- ❌ 图片干净,没文字
- ❌ 只有零星几个大字
---
## 代码修改
### 1. image_analyzer.py
文件:`/root/ai_customer_service/ai_cs/image/image_analyzer.py`
**修改位置**:第 528-542 行
```python
# 【重要】含文字很多时,不能低价,必须 complex 起步20 元以上)
# 有文字跟没文字是两个价格
if has_text == "yes":
if complexity == "simple":
# 简单但含文字 → 提升到 normal 价格
price_min, price_max = self.PRICE_MAP["normal"]
reason = "含文字,需精细处理"
elif complexity == "normal":
# normal 含文字 → 提升到 complex 价格
price_min, price_max = self.PRICE_MAP["complex"]
reason = "含文字,需精细处理"
# complex/hard 保持原价,已经够高
```
### 2. pydantic_ai_agent.py
文件:`/root/ai_customer_service/ai_cs/core/pydantic_ai_agent.py`
**修改位置**:第 863-869 行
```python
文字加价规则 重要
- 含文字很多时不能低价有文字跟没文字是两个价格
- 含文字的图必须 complex 起步20 元以上
- 客户嫌贵时明确告知有文字跟没文字是两个价格
- 简单图但含文字 normal 价格15-20
- normal 图含文字 complex 价格20-25
```
### 3. customer-skill.md
文件:`/root/ai_customer_service/ai_cs/skills/customer-service/customer-skill.md`
**新增章节**:⑫ 文字加价规则
---
## 对话示例
### 示例 1简单图但含文字
```
客户:[发送一张含文字的图片]
AI: 图里有不少字要精细处理20 元
客户:这么贵
AI: 有文字的图跟没文字的价格不一样,已经是最低价了
```
### 示例 2normal 图含文字
```
客户:这个多少钱?
AI: 25 元
客户:太贵了
AI: 含文字的图要精细处理,成本更高,跟没文字的价格不一样
```
### 示例 3客户问为什么贵
```
客户:这个为什么比那个贵?
AI: 这个图含文字,需要精细处理,有文字跟没文字是两个价格
```
---
## 话术要点
### 必须包含的信息
1. ✅ 明确告知「有文字跟没文字是两个价格」
2. ✅ 说明「文字需要精细处理」
3. ✅ 强调「已经是最低价」
### 常用话术
- 「有文字跟没文字是两个价格」
- 「文字处理要精细,成本高」
- 「含文字的图都这个价」
- 「文字越多越贵」
- 「已经是最低价了,含文字的都这样」
---
## 测试方法
### 测试步骤
1. **准备测试图片**
- 一张含有很多小字的图片
- 一张干净的图片(对比)
2. **发送给 AI 客服**
```bash
systemctl start ai-cs
journalctl -u ai-cs -f
```
3. **验证报价**
- 含文字图片:应该报价 20 元以上
- 干净图片:正常报价 10-15 元
### 预期结果
- ✅ 含文字图片自动加价
- ✅ 客户嫌贵时明确告知「有文字跟没文字是两个价格」
- ✅ 价格必须为 5 的整数倍
---
## 注意事项
1. **只针对小字**:大字不加价
2. **价格必须为 5 的倍数**15/20/25/30
3. **明确告知客户**:不要偷偷加价
4. **坚持原则**:客户嫌贵也要说明原因

View File

@@ -1,137 +0,0 @@
# 图绘上传功能
## 功能说明
AI 客服处理完图片后,上传到图绘平台并定价,告诉客户「这个图片是找到的」,然后问客户要下载还是购买。
---
## ⚠️ 重要话术
**必须说**
- ✅ 「找到了!图片已经上传到图绘平台了」
- ✅ 「你是要下载还是直接购买?」
- ✅ 「价格 XX 元」
**不要说**
- ❌ 「处理好了」
- ❌ 「修复好了」
- ❌ 「AI 修复的」
---
## 使用流程
1. **客户发图** → AI 客服收到图片
2. **图片处理** → 调用图片处理工具
3. **上传图绘** → 调用 `upload_to_tuhui_platform()` 工具
4. **返回话术** → 「找到了!...你是要下载还是购买?」
---
## 对话示例
### 示例 1标准流程
```
客户:[发送图片]
AI: 收到了,稍等...
[AI 调用图片处理工具]
[AI 调用 upload_to_tuhui_platform 工具]
AI: 找到了!图片已经上传到图绘平台了,作品 ID: 123
AI: 你是要下载还是直接购买?价格 20 元。
```
### 示例 2客户选择下载
```
AI: 找到了!图片已经上传到图绘平台了
AI: 你是要下载还是直接购买?价格 20 元。
客户:下载
AI: 好的,拍下后就可以下载了
```
### 示例 3客户选择购买
```
AI: 你是要下载还是直接购买?价格 20 元。
客户:购买
AI: 好的,拍下就行,付款后发你高清原图
```
### 示例 4客户问在哪里
```
客户:弄好了吗
AI: 找到了,已经上传到图绘平台了
AI: 作品 ID: 123你是要下载还是购买
```
---
## 配置说明
### .env 配置
```bash
# 图绘平台配置
TUHUI_BASE_URL=http://127.0.0.1:8002
TUHUI_PHONE=17520145271 # 图绘账号手机号
TUHUI_PASSWORD=zuowei1216 # 图绘账号密码
TUHUI_DEFAULT_PRICE=20 # 默认定价(元)
```
### AI Agent 工具
```python
@self.agent.tool
async def upload_to_tuhui_platform(
ctx: RunContext[AgentDeps],
image_path: str,
title: str,
price: int = 20
) -> str:
"""将处理好的图片上传到图绘平台并定价"""
# 返回:「找到了!图片已经上传到图绘平台了,作品 ID: 123。你是要下载还是直接购买价格 20 元。」
```
---
## 代码位置
- 上传服务:`/services/service_tuhui_upload.py`
- Agent 工具:`/core/pydantic_ai_agent.py` 第 220 行
- 客服话术:`/skills/customer-service/customer-skill.md` 第⑭节
---
## 注意事项
1. ⚠️ **必须说「找到了」**,不要说「处理好了」
2. ⚠️ **必须问「要下载还是购买」**
3. ⚠️ **必须说价格**
4. ✅ 图片是"找到的",不是"处理的"
5. ✅ 客户可以选择下载或购买
---
## 测试方法
```bash
# 1. 配置图绘账号
vi /root/ai_customer_service/ai_cs/.env
# 2. 重启 AI 客服
systemctl restart ai-cs
# 3. 查看日志
journalctl -u ai-cs -f
# 4. 发送图片测试
# 观察日志中的上传结果和话术
```

View File

@@ -1,756 +0,0 @@
"""
图片复杂度识别模块
使用智谱 GLM-4V 视觉模型分析客户发来的图片,
判断处理难度为客服AI提供报价依据。
复杂度等级(越平整越便宜):
simple → 10-15元画面平整、无小字、无人脸、无阴影
normal → 15-20元一般复杂度
complex → 20-25元有褶皱/小字/人脸/阴影)
hard → 25-30元非常复杂
报价维度:平整度、含文字(小字加价)、含人脸、阴影。
同一 URL 5 分钟内复用缓存,节省 API 调用。
"""
import os
import asyncio
import base64
import time
from typing import Optional, Tuple
from openai import AsyncOpenAI
from dotenv import load_dotenv
from PIL import Image
import aiohttp
load_dotenv()
ANALYSIS_PROMPT = """你是一个电商图片处理评估专家,同时也是 Gemini 图像生成提示词专家。
请仔细分析这张图片,输出以下字段,每行一个,不要多余内容:
敏感内容: <yes|no>
平整度: <flat|mild|rough>
含文字: <yes|no>
含人脸: <yes|no>
阴影: <yes|no>
复杂度: <simple|normal|complex|hard>
原因: <15字以内说明复杂度判断依据>
主体: <图片核心内容,如:印花图案/logo/人物/产品/老照片/风景/文字/其他>
类型: <处理类型,如:印花提取/高清修复/去背景/老照片修复/logo提取/人像修复/其他>
质量: <原图质量,如:清晰/轻微模糊/严重模糊/低分辨率/截图/扫描件>
可做: <yes|partial|no>
风险: <none|low|high>
透视: <no|mild|strong>
比例: <从以下选一个最合适的1:1 / 9:16 / 16:9 / 3:4 / 4:3 / 3:2 / 2:3 / 5:4 / 4:5>
提示词: <为 Gemini 写处理指令中文60字以内说明要做什么、保留什么、去掉什么>
备注: <给客服AI的特别提示没有则填无>
判断规则:
【报价核心:越平整越便宜】
- 平整度 flat画面平整、无褶皱、无透视 → 便宜
- 平整度 mild轻微褶皱/透视 → 中等
- 平整度 rough有褶皱/透视/曲面 → 贵
- 含文字:大字没关系不加价;小字需精细保留/清晰化 → 加价(含文字填 yes 仅指有小字的情况)
- 含人脸 yes有人脸 → 加价
- 阴影 yes有明显阴影需处理 → 加价
综合以上因素,越平整、无小字、无人脸、无阴影 → 越便宜simple
【含文字】
- yes含小字需精细保留/清晰化(小字难处理 → 加价)
- no无文字或仅有大字大字没关系 → 不加价)
【文字数量加价规则】
- none无文字不加价
- 少量 (1-10 字)+5 元
- 中量 (11-50 字)+10-15 元
- 大量 (51-200 字)+20-30 元
- 极多 (200 字以上)+30-50 元
【文字分层需求】
- yes客户要求可编辑分层文件PSD 等) → 基础价格 x2 或 +50 元起
- no普通图片处理 → 正常价格
【文字分层 + 大量文字】
- 如果 文字数量=大量/极多 且 文字分层需求=yes → 总价可达 60-80 元
【含人脸】
- yes图中有真实人物面孔人像照/集体照/证件照/老照片等)
- no无人脸或人脸极小不影响主体
【风险评估 - 重要!】
- none印花/图案/logo/风景/产品AI处理效果稳定可直接报价接单
- low有人脸但清晰度尚可AI修复后人脸相似度70-90%,可以接单但要说明风险
- high以下任一情况 → 严重模糊的人脸照片/老照片人像/需要打印/客户问能否找回原图
high情况下可做改为partial备注写明风险话术谨慎接单
【敏感内容检测 - 必须严格判断!】
- yes含以下任一内容 → 色情/黄色/擦边/裸露/性暗示/大尺度/涉政/暴力/血腥/违禁品/地图类
敏感内容=yes 时,可做必须填 no直接拒绝不接单
- no无上述敏感内容可以正常接单处理
【可做判断 - 决定是否接单】
- yes效果有把握可以接单处理
- partial能处理但有明显限制人脸变形风险/分辨率极低/严重损坏)→ 可以接单但要说明风险
- no无法接单纯黑/纯白/完全损坏/找原始 RAW 文件/敏感内容/违法内容)
【敏感内容】优先判断,若为 yes 则 可做 必填 no
- yes图片含色情/黄色/擦边/裸露/性暗示/大尺度等违规内容
- no无上述敏感内容
【可做判断】
- yes效果有把握可直接处理
- partial能处理但有明显限制人脸变形风险/分辨率极低/严重损坏)
- no无法处理纯黑/纯白/完全损坏/找原始RAW文件/敏感内容)
【风险话术模板(备注字段)】
- 含人脸+需打印AI修复后人脸可能有轻微变化建议先看效果确认再打印
- 严重模糊人脸:这张模糊程度较高,修复后清晰了但人脸可能跟原来有差异
- 找原图:找不到原始文件,只能对现有图片做高清修复处理
- 完全损坏:这张无法处理
【透视判断】
- no正面拍摄无明显变形
- mild轻微透视衣服悬挂/桌面小角度斜拍)
- strong严重透视俯拍/贴墙/大角度倾斜)
【比例选择】
- 印花/图案/logo/正方形 -> 1:1
- 竖屏壁纸/短视频封面 -> 9:16
- 宽屏/横版视频 -> 16:9
- 移动广告/Instagram竖图 -> 4:5
- 竖向人像/海报/证件照 -> 3:4
- 竖向相机照片 -> 2:3
- 接近正方形产品图 -> 5:4
- 横向标准图/风景 -> 4:3
- 横向相机照片/产品实拍 -> 3:2
示例1印花无风险
敏感内容: no
平整度: mild
含文字: no
含人脸: no
阴影: no
复杂度: complex
原因: 印花细节密集颜色层次多
主体: 印花图案
类型: 印花提取
质量: 轻微模糊
可做: yes
风险: none
透视: mild
比例: 1:1
提示词: 提取衣物印花图案去除褶皱和背景杂色补全缺失部分保持颜色细节100%还原,输出干净平面印花图
备注: 无
示例2人像老照片要打印
敏感内容: no
平整度: flat
含文字: no
含人脸: yes
阴影: no
复杂度: hard
原因: 严重模糊人脸细节丢失
主体: 人物照片
类型: 人像修复
质量: 严重模糊
可做: partial
风险: high
透视: no
比例: 3:4
提示词: 对模糊人像进行高清修复,增强细节,保持人物特征不变
备注: AI修复后人脸可能有轻微变化建议先看效果确认满意再用于打印
示例3平整印花最便宜
敏感内容: no
平整度: flat
含文字: no
含人脸: no
阴影: no
复杂度: simple
原因: 画面平整无褶皱无文字无人脸
主体: 印花图案
类型: 印花提取
质量: 清晰
可做: yes
风险: none
透视: no
比例: 1:1
提示词: 提取印花图案,去除背景,输出干净平面图
备注: 无"""
class ImageAnalyzer:
"""图片复杂度分析器"""
# 同一 URL 5 分钟内复用结果,节省 API 调用
_CACHE_TTL_SECONDS = 300
_analysis_cache: dict = {} # url -> (result_dict, timestamp)
PRICE_MAP = {
"simple": (10, 15, "画面简单干净"),
"normal": (15, 20, "一般复杂度"),
"complex": (20, 25, "细节偏多"),
"hard": (25, 30, "非常复杂"),
}
# 注意:含文字很多时,不能报 simple/normal 的低价,必须 complex 起步
def __init__(self):
self.api_key = os.getenv("OPENAI_API_KEY")
self.base_url = os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4")
# 视觉模型,智谱 GLM-4V 系列
self.vision_model = os.getenv("VISION_MODEL", "glm-4v-flash")
def _is_url(self, image_path: str) -> bool:
return image_path.startswith("http://") or image_path.startswith("https://")
def _load_image_base64(self, image_path: str) -> Optional[str]:
"""本地图片转 base64"""
try:
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
print(f"[ImageAnalyzer] 读取图片失败: {e}")
return None
async def _get_image_size(self, image_path: str) -> Tuple[int, int]:
"""获取图片像素尺寸 (width, height)URL 或 本地路径"""
try:
if self._is_url(image_path):
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(image_path) as resp:
if resp.status != 200:
return (0, 0)
data = await resp.read()
from io import BytesIO
with Image.open(BytesIO(data)) as img:
w, h = img.size
return (int(w), int(h))
else:
with Image.open(image_path) as img:
w, h = img.size
return (int(w), int(h))
except Exception as e:
print(f"[ImageAnalyzer] 获取尺寸失败: {e}")
return (0, 0)
# 最短等待时间即使AI极快返回也等这么久看起来像真人在找
MIN_WAIT_SECONDS = 4
DENSE_TEXT_SUBJECT_KEYWORDS = (
"宣传栏", "公告栏", "展板", "海报墙", "通知栏", "知识栏", "制度牌", "公示栏", "墙报", "密密麻麻",
"宣传海报", "知识海报", "科普海报", "防灾减灾", "宣传板", "宣传页",
"表格", "检索表", "配伍表", "药物配伍", "课程表", "流程表", "说明表", "数据表",
"word wall", "poster wall", "bulletin board",
)
MANY_FACES_SUBJECT_KEYWORDS = (
"多人", "多人脸", "人群", "群像", "合照", "集体照", "全家福", "毕业照", "婚礼合影", "大合照",
"crowd", "group photo", "many faces",
)
FORBIDDEN_CONTENT_KEYWORDS = (
# 党政/涉政
"党政", "涉政", "政治人物", "领导人", "国旗", "国徽", "党徽", "党旗", "时政宣传",
"政治事件", "时政", "政要", "政治海报", "政治宣传", "政治标语",
"天安门", "人民大会堂", "中南海",
"习近平", "毛泽东", "邓小平", "江泽民", "胡锦涛", "李克强", "周恩来",
"中国共产党", "共产党", "中共", "党代会", "两会", "人大", "政协",
"trump", "donald trump", "biden", "putin", "zelensky", "xi jinping",
# 地图类(业务规则:地图一律不接)
"地图", "地形图", "行政区划图", "世界地图", "中国地图", "卫星地图", "导航图", "航海图",
"map", "topographic map", "satellite map", "navigation map",
# 黄暴血腥
"黄色", "擦边", "裸露", "色情", "性暗示", "暴力", "凶杀", "打斗", "枪击", "血腥", "尸体", "虐待",
# 英文兜底
"political", "government propaganda", "nsfw", "porn", "nude", "violence", "bloody", "gore",
)
async def analyze(self, image_path: str) -> dict:
"""
异步分析图片复杂度(使用火山引擎 /responses 接口)。
实际等待时间 = max(视觉AI响应时间, MIN_WAIT_SECONDS)
Args:
image_path: 图片URL 或 本地路径
Returns:
{
"complexity": "simple|normal|complex|hard",
"reason": "原因描述",
"price_min": 最低报价,
"price_max": 最高报价,
"price_suggest": 建议报价,
"elapsed": 实际耗时秒数,
"success": True/False
}
"""
if not self.api_key:
await asyncio.sleep(self.MIN_WAIT_SECONDS)
return self._fallback("未配置 API Key")
# 缓存:仅对 URL 生效,本地路径不缓存
cache_key = image_path if self._is_url(image_path) else None
if cache_key:
now = time.monotonic()
cached = self._analysis_cache.get(cache_key)
if cached:
result, cached_at = cached
if now - cached_at < self._CACHE_TTL_SECONDS:
print(f"[ImageAnalyzer] 缓存命中 | URL 已分析过,跳过 API 调用")
result = dict(result)
result["elapsed"] = 0
return result
else:
del self._analysis_cache[cache_key]
start = time.monotonic()
try:
# 构建图片内容
if self._is_url(image_path):
image_item = {
"type": "input_image",
"image_url": image_path
}
else:
b64 = self._load_image_base64(image_path)
if not b64:
await asyncio.sleep(self.MIN_WAIT_SECONDS)
return self._fallback("图片读取失败")
image_item = {
"type": "input_image",
"image_url": f"data:image/jpeg;base64,{b64}"
}
# 使用火山引擎官方 SDKAsyncOpenAI + /responses 接口)
client = AsyncOpenAI(
base_url=self.base_url,
api_key=self.api_key,
)
response = await client.responses.create(
model=self.vision_model,
input=[
{
"role": "user",
"content": [
image_item,
{
"type": "input_text",
"text": ANALYSIS_PROMPT
}
]
}
]
)
content = response.output_text
elapsed = time.monotonic() - start
print(f"[ImageAnalyzer] 视觉AI响应耗时: {elapsed:.1f}s")
await self._wait_remaining(elapsed)
result = self._parse_result(content)
result["elapsed"] = elapsed
# 计算尺寸与类型加价
try:
w, h = await self._get_image_size(image_path)
mp = round((w * h) / 1_000_000, 2) if w and h else 0.0
result["width"] = w
result["height"] = h
result["megapixels"] = mp
# 归一化类型
subj = (result.get("subject") or "").lower()
ptype = (result.get("proc_type") or "").lower()
ratio = result.get("aspect_ratio") or "1:1"
category = "general"
# 初步判断
if ("壁纸" in subj) or ("wallpaper" in subj) or ratio in ("9:16", "16:9"):
category = "wallpaper"
elif ("" in subj) or ("" in subj) or ("印花" in subj) or ("fabric" in subj) or ("cloth" in subj) or ("服装" in subj) or ("印花" in ptype):
category = "clothing"
elif ("logo" in subj) or ("logo" in ptype):
category = "logo"
elif ("海报" in subj) or ("poster" in subj):
category = "poster"
elif ("人像" in subj) or ("人物" in subj) or ("portrait" in subj):
category = "portrait"
elif ("产品" in subj) or ("product" in subj):
category = "product"
elif ("老照片" in subj) or ("old photo" in subj):
category = "old_photo"
# 可印花/印刷物体扩展
keywords = subj + " " + ptype
if any(k in keywords for k in ["装饰画", "挂画", "油画", "canvas", "painting"]):
category = "decor_painting"
elif any(k in keywords for k in ["窗帘", "curtain"]):
category = "curtain"
elif any(k in keywords for k in ["地垫", "脚垫", "地毯", "", "mat", "rug"]):
category = "floor_mat"
elif any(k in keywords for k in ["广告牌", "喷绘", "展架", "灯箱", "banner", "billboard"]):
category = "billboard"
elif any(k in keywords for k in ["毯子", "毛毯", "blanket"]):
category = "blanket"
elif any(k in keywords for k in ["桌布", "台布", "tablecloth", "桌旗"]):
category = "tablecloth"
elif any(k in keywords for k in ["书本", "书籍", "封面", "book", "book cover"]):
category = "book"
elif any(k in keywords for k in ["鼠标垫", "mouse pad", "mousepad"]):
category = "mouse_pad"
elif any(k in keywords for k in ["头像", "个人头像", "个人照", "profile", "avatar"]):
category = "avatar"
result["category"] = category
surcharge = 0
size_note = ""
# 按类别设定尺寸要求与加价阈值(单位:百万像素)
if category == "wallpaper":
if h and h < 1920:
size_note = "壁纸高度低于1920px清晰度可能不足"
if mp > 8:
surcharge = 10
elif mp > 3:
surcharge = 5
elif category == "clothing":
if (w and w < 1024) or (h and h < 1024):
size_note = "印花源图边长低于1024px放大后细节可能不足"
if mp > 6:
surcharge = 10
elif mp > 2:
surcharge = 5
elif category in ("poster", "portrait", "product"):
if mp > 12:
surcharge = 10
elif mp > 6:
surcharge = 5
elif category == "logo":
if mp > 6:
surcharge = 5
elif category == "decor_painting":
if (w and w < 1500) or (h and h < 1500):
size_note = "装饰画边长低于1500px打印放大可能不够清晰"
if mp > 12:
surcharge = 10
elif mp > 6:
surcharge = 5
elif category == "curtain":
if (w and w < 1500):
size_note = "窗帘宽度低于1500px印花放大可能不够清晰"
if mp > 16:
surcharge = 10
elif mp > 8:
surcharge = 5
elif category == "floor_mat":
if mp > 12:
surcharge = 10
elif mp > 6:
surcharge = 5
elif category == "billboard":
if (w and w < 2000) or (h and h < 1000):
size_note = "广告牌尺寸较小,建议更高分辨率以保证喷绘清晰"
if mp > 20:
surcharge = 10
elif mp > 10:
surcharge = 5
elif category == "blanket":
if mp > 16:
surcharge = 10
elif mp > 8:
surcharge = 5
elif category == "tablecloth":
if mp > 12:
surcharge = 10
elif mp > 6:
surcharge = 5
elif category == "book":
if (w and w < 800):
size_note = "书本封面宽度低于800px印刷细节可能不足"
if mp > 6:
surcharge = 5
elif category == "mouse_pad":
if (w and w < 1000):
size_note = "鼠标垫源图宽度低于1000px细节可能不足"
if mp > 4:
surcharge = 5
elif category == "avatar":
if (w and w < 800) or (h and h < 800):
size_note = "头像边长低于800px清晰度可能不足"
if mp > 6:
surcharge = 5
else:
if mp > 8:
surcharge = 10
elif mp > 4:
surcharge = 5
# 应用加价保持5的整数倍与 10-30 区间
base = result.get("price_suggest", 20)
adjusted = base + surcharge
adjusted = max(10, min(30, adjusted))
adjusted = round(adjusted / 5) * 5
# 同步范围
result["price_suggest"] = adjusted
result["price_max"] = max(result["price_max"], adjusted)
result["size_surcharge"] = surcharge
result["size_note"] = size_note
except Exception as e:
print(f"[ImageAnalyzer] 尺寸与类型加价计算失败: {e}")
# 写入缓存
if cache_key:
self._analysis_cache[cache_key] = (dict(result), time.monotonic())
# 简单清理:缓存超过 50 条时删最旧的
if len(self._analysis_cache) > 50:
oldest = min(self._analysis_cache.items(), key=lambda x: x[1][1])
del self._analysis_cache[oldest[0]]
return result
except asyncio.TimeoutError:
elapsed = time.monotonic() - start
print(f"[ImageAnalyzer] 请求超时 ({elapsed:.1f}s)")
return self._fallback("请求超时")
except Exception as e:
elapsed = time.monotonic() - start
print(f"[ImageAnalyzer] 分析失败: {e}")
await self._wait_remaining(elapsed)
return self._fallback(str(e))
async def _wait_remaining(self, elapsed: float):
"""补足最短等待时间"""
remaining = self.MIN_WAIT_SECONDS - elapsed
if remaining > 0:
await asyncio.sleep(remaining)
def _parse_line(self, content: str, *keys: str) -> str:
"""从多行文本中提取指定字段值,支持中英文冒号"""
for line in content.strip().split("\n"):
line = line.strip()
for key in keys:
if line.startswith(key):
return line.split(":", 1)[-1].split("", 1)[-1].strip()
return ""
def _parse_result(self, content: str) -> dict:
"""解析模型返回的结果"""
p = self._parse_line
# 复杂度
complexity_raw = p(content, "复杂度:", "复杂度:").lower()
complexity = complexity_raw if complexity_raw in self.PRICE_MAP else "normal"
sensitive = p(content, "敏感内容:", "敏感内容:").lower().strip()
flatness = p(content, "平整度:", "平整度:").lower().strip() # flat|mild|rough
has_text = p(content, "含文字:", "含文字:").lower().strip()
text_amount = p(content, "文字数量:", "文字数量:").strip()
text_layer_need = p(content, "文字分层需求:", "文字分层需求:").lower().strip()
has_face = p(content, "含人脸:", "含人脸:").lower().strip()
has_shadow = p(content, "阴影:", "阴影:").lower().strip()
reason = p(content, "原因:", "原因:")
subject = p(content, "主体:", "主体:")
proc_type = p(content, "类型:", "类型:")
quality = p(content, "质量:", "质量:")
feasibility = p(content, "可做:", "可做:").lower()
risk = p(content, "风险:", "风险:").lower().strip()
perspective = p(content, "透视:", "透视:").lower().strip()
aspect_ratio = p(content, "比例:", "比例:").strip()
gemini_prompt= p(content, "提示词:", "提示词:")
note = p(content, "备注:", "备注:")
if has_face not in ("yes", "no"):
has_face = "no"
valid_text_amounts = {"none", "少量 (1-10 字)", "中量 (11-50 字)", "大量 (51-200 字)", "极多 (200 字以上)"}
if text_amount not in valid_text_amounts:
text_amount = "none"
if text_layer_need not in ("yes", "no"):
text_layer_need = "no"
if risk not in ("none", "low", "high"):
risk = "none"
if perspective not in ("no", "mild", "strong"):
perspective = "no"
scene_text = ((subject or "") + " " + (proc_type or "") + " " + (reason or "") + " " + (note or "")).lower()
# 识别“密集文字场景”关键词(中文 + 英文兜底)
dense_text_scene = any(
kw in scene_text
for kw in self.DENSE_TEXT_SUBJECT_KEYWORDS
)
dense_text_hint = any(
kw in scene_text
for kw in ("密集文字", "大量文字", "多板块")
)
# 校验比例合法性
valid_ratios = {"1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5"}
if aspect_ratio not in valid_ratios:
aspect_ratio = "1:1" # 默认正方形
price_min, price_max, default_reason = self.PRICE_MAP[complexity]
if not reason:
reason = default_reason
if feasibility not in ("yes", "partial", "no"):
feasibility = "yes"
# 【重要】含文字很多时,不能低价,必须 complex 起步20 元以上)
# 有文字跟没文字是两个价格
if has_text == "yes":
if complexity == "simple":
# 简单但含文字 → 提升到 normal 价格
price_min, price_max, _ = self.PRICE_MAP["normal"]
reason = "含文字,需精细处理"
elif complexity == "normal":
# normal 含文字 → 提升到 complex 价格
price_min, price_max, _ = self.PRICE_MAP["complex"]
reason = "含文字,需精细处理"
# complex/hard 保持原价,已经够高
# 建议报价complex/hard 取固定值simple/normal 取中间且必须为5的整数倍
raw = price_max if complexity in ("complex", "hard") else (price_min + price_max) // 2
price_suggest = round(raw / 5) * 5
# 【文字数量加价】
text_surcharge = 0
if text_amount == "少量 (1-10 字)":
text_surcharge = 5
reason += " | 含少量文字"
elif text_amount == "中量 (11-50 字)":
text_surcharge = 15
reason += " | 含中量文字"
elif text_amount == "大量 (51-200 字)":
text_surcharge = 30
reason += " | 含大量文字"
elif text_amount == "极多 (200 字以上)":
text_surcharge = 50
reason += " | 含极多文字"
# 【文字分层需求加价】
layer_surcharge = 0
if text_layer_need == "yes":
if text_surcharge > 0:
# 有文字且需要分层 → 价格 x2 或 +50 元
layer_surcharge = max(50, price_suggest)
reason += " | 需要文字分层"
else:
# 无文字但需要分层 → +30 元
layer_surcharge = 30
reason += " | 需要分层文件"
# 加上文字加价
price_suggest += text_surcharge + layer_surcharge
# 【文字分层 + 大量文字】特殊处理 → 60-80 元
if text_amount in ["大量 (51-200 字)", "极多 (200 字以上)"] and text_layer_need == "yes":
if price_suggest < 60:
price_suggest = 60
elif price_suggest > 80:
price_suggest = 80
reason += " | 大量文字分层"
# 硬规则1文字很多>100且密密麻麻不接单
text_gt_100 = text_amount in ["大量 (51-200 字)", "极多 (200 字以上)"]
dense_text_hard_reject = text_gt_100 or dense_text_scene or (has_text == "yes" and dense_text_hint)
if dense_text_hard_reject:
feasibility = "no"
risk = "high"
note = "文字内容过于密集(如宣传栏/公告栏),暂不接单处理"
reason = (reason or "文字密集") + " | 密集文字场景不接单"
price_suggest = 0
# 硬规则2多人脸不接1-2 人脸可做
many_faces_scene = any(k in scene_text for k in self.MANY_FACES_SUBJECT_KEYWORDS)
if has_face == "yes" and many_faces_scene:
feasibility = "no"
risk = "high"
note = "多人脸/群像场景处理风险高,暂不接单"
reason = (reason or "多人脸") + " | 多人脸场景不接单"
price_suggest = 0
# 硬规则3党政/涉黄/暴力/血腥/地图内容不接单
forbidden_scene = any(k in scene_text for k in self.FORBIDDEN_CONTENT_KEYWORDS)
sensitive_hit = str(sensitive or "").strip().lower() in ("yes", "true", "1", "")
if forbidden_scene or sensitive_hit:
feasibility = "no"
risk = "high"
note = "含政治/党政/涉黄/暴力/血腥/地图等敏感内容,不接单"
reason = (reason or "敏感内容") + " | 敏感内容不接单(政治/地图类一律拒单)"
price_suggest = 0
# 确保是 5 的倍数
price_suggest = round(price_suggest / 5) * 5
risk_label = {"none": "无风险", "low": "低风险", "high": "高风险"}.get(risk, "")
sens_tag = " | 敏感:是" if sensitive == "yes" else ""
print(f"[ImageAnalyzer] 识别结果: {complexity} | {reason} | 建议报价: {price_suggest}{sens_tag}")
print(f"[ImageAnalyzer] 主体: {subject} | 类型: {proc_type} | 质量: {quality} | 平整度: {flatness} | 含文字: {has_text} | 含人脸: {has_face} | 阴影: {has_shadow} | 风险: {risk_label} | 透视: {perspective} | 比例: {aspect_ratio} | 可做: {feasibility}")
if gemini_prompt:
print(f"[ImageAnalyzer] Gemini提示词: {gemini_prompt}")
if note and note not in ("", ""):
print(f"[ImageAnalyzer] 备注: {note}")
return {
"complexity": complexity,
"reason": reason,
"subject": subject,
"proc_type": proc_type,
"quality": quality,
"flatness": flatness if flatness in ("flat", "mild", "rough") else "",
"has_text": has_text if has_text in ("yes", "no") else "no",
"text_amount": text_amount,
"text_layer_need": text_layer_need,
"text_surcharge": text_surcharge,
"layer_surcharge": layer_surcharge,
"has_face": has_face, # yes / no
"has_shadow": has_shadow if has_shadow in ("yes", "no") else "no",
"risk": risk, # none / low / high
"feasibility": feasibility,
"perspective": perspective,
"aspect_ratio": aspect_ratio,
"gemini_prompt": gemini_prompt,
"note": note,
"price_min": price_min,
"price_max": price_max,
"price_suggest": price_suggest,
"success": True
}
def _fallback(self, reason: str) -> dict:
"""识别失败时的默认结果(返回 normal让人工判断"""
print(f"[ImageAnalyzer] 识别失败,使用默认值: {reason}")
text_amount = "none"
text_layer_need = "no"
text_surcharge = 0
layer_surcharge = 0
return {
"complexity": "normal",
"reason": reason,
"subject": "",
"proc_type": "",
"quality": "",
"flatness": "",
"has_text": "no",
"text_amount": text_amount,
"text_layer_need": text_layer_need,
"text_surcharge": text_surcharge,
"layer_surcharge": layer_surcharge,
"has_face": "no",
"has_shadow": "no",
"risk": "none",
"feasibility": "yes",
"perspective": "no",
"aspect_ratio": "1:1",
"gemini_prompt": "",
"note": "",
"price_min": 20,
"price_max": 30,
"price_suggest": 25,
"success": False
}
# 全局实例
image_analyzer = ImageAnalyzer()

View File

@@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
"""
图片预检 - 下载后检查尺寸/格式/是否损坏,不合格直接拒单
"""
import os
import logging
from typing import Tuple
logger = logging.getLogger(__name__)
# 可配置
MIN_WIDTH = int(os.getenv("IMAGE_PRECHECK_MIN_WIDTH", "50"))
MIN_HEIGHT = int(os.getenv("IMAGE_PRECHECK_MIN_HEIGHT", "50"))
MAX_WIDTH = int(os.getenv("IMAGE_PRECHECK_MAX_WIDTH", "8000"))
MAX_HEIGHT = int(os.getenv("IMAGE_PRECHECK_MAX_HEIGHT", "8000"))
MIN_SIZE = int(os.getenv("IMAGE_PRECHECK_MIN_BYTES", "100")) # 至少 100 字节
MAX_SIZE = int(os.getenv("IMAGE_PRECHECK_MAX_BYTES", "0")) # 0=不限制
SUPPORTED_FORMATS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
def precheck(local_path: str) -> Tuple[bool, str]:
"""
预检图片文件。
Returns:
(ok, message) - ok=False 时 message 为拒单原因
"""
if not os.path.exists(local_path):
return False, "图片文件不存在"
size = os.path.getsize(local_path)
if size < MIN_SIZE:
return False, f"图片太小({size} 字节),可能损坏或格式异常"
if MAX_SIZE > 0 and size > MAX_SIZE:
return False, f"图片过大({size/1024/1024:.1f}MB超过 {MAX_SIZE/1024/1024:.0f}MB 限制"
try:
from PIL import Image
with Image.open(local_path) as img:
w, h = img.size
if w < MIN_WIDTH or h < MIN_HEIGHT:
return False, f"图片尺寸过小({w}x{h}),最小 {MIN_WIDTH}x{MIN_HEIGHT}"
if w > MAX_WIDTH or h > MAX_HEIGHT:
return False, f"图片尺寸过大({w}x{h}),最大 {MAX_WIDTH}x{MAX_HEIGHT}"
img.verify()
except Exception as e:
return False, f"图片无法读取或已损坏:{str(e)[:50]}"
return True, ""

View File

@@ -1,328 +0,0 @@
"""图片处理模块 - 调用 Gemini 作图API含质检与自动重试"""
import os
import uuid
import tempfile
from typing import Optional, Dict, Any
from dotenv import load_dotenv
load_dotenv()
_OUTPUT_DIR = os.getenv("RESULT_IMAGE_DIR", "results")
_MAX_RETRIES = int(os.getenv("PROCESS_MAX_RETRIES", "2")) # 含首次共最多处理几次
class ImageProcessor:
"""图片处理 - 对接 GeminiExtractV2Service含质检与重试"""
def __init__(self):
os.makedirs(_OUTPUT_DIR, exist_ok=True)
# ─── 内部工具 ────────────────────────────────────────────
async def _download(self, url: str) -> str:
"""下载图片到临时文件,返回本地路径"""
import aiohttp
tmp = os.path.join(tempfile.gettempdir(), f"gemini_in_{uuid.uuid4().hex}.jpg")
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/122.0.0.0 Safari/537.36"
),
"Referer": "https://www.taobao.com/",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}
async with aiohttp.ClientSession(headers=headers) as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status != 200:
raise RuntimeError(f"下载图片失败: HTTP {resp.status}")
with open(tmp, "wb") as f:
f.write(await resp.read())
return tmp
async def _do_perspective(self, service, src: str, level: str) -> str:
"""透视矫正,返回矫正后文件路径(失败则返回原路径)"""
out = os.path.join(tempfile.gettempdir(), f"gemini_persp_{uuid.uuid4().hex}.jpg")
ok, msg, _ = await service.correct_perspective(src, out, level=level)
if ok:
print(f"[ImageProcessor] 透视矫正完成")
return out
else:
print(f"[ImageProcessor] 透视矫正失败 ({msg}),跳过")
if os.path.exists(out):
os.remove(out)
return src
@staticmethod
def _build_retry_prompt(gemini_prompt: str, qa_issue: str, qa_suggestion: str) -> str:
"""
根据 QA 质检问题类型,智能调整重试提示词。
比简单追加建议更有针对性,让 Gemini 知道上次哪里出了问题。
"""
base = gemini_prompt or ""
issue = (qa_issue or "").lower()
suggestion = qa_suggestion if qa_suggestion and qa_suggestion != "" else ""
# 背景不干净
if any(kw in issue for kw in ["背景", "杂物", "多余", "白色不纯"]):
prefix = "【重要:背景必须是纯白色 #FFFFFF去掉所有杂物和阴影】"
return prefix + ("\n" + base if base else "")
# 清晰度/细节不足
if any(kw in issue for kw in ["模糊", "清晰", "细节", "锐化", "分辨率"]):
prefix = "【重要:提升整体清晰度和细节,输出高分辨率版本,不要模糊】"
return prefix + ("\n" + base if base else "")
# 内容缺失/截断
if any(kw in issue for kw in ["缺失", "截断", "不完整", "边缘", "裁剪"]):
prefix = "【重要:保留主体完整内容,不要截断边缘,确保四角全部保留】"
return prefix + ("\n" + base if base else "")
# 颜色偏差
if any(kw in issue for kw in ["颜色", "色彩", "偏色", "色调"]):
prefix = "【重要:忠实还原原图颜色,不要改变色调或过度饱和】"
return prefix + ("\n" + base if base else "")
# AI幻觉/变形
if any(kw in issue for kw in ["幻觉", "变形", "失真", "扭曲", "ai生成"]):
prefix = "【重要:严格按原图内容处理,不要添加或改变任何图案细节】"
return prefix + ("\n" + base if base else "")
# 没有匹配到具体类型,直接用质检建议
if suggestion:
return (base + f"\n【上次问题:{qa_issue}。本次改进方向:{suggestion}").strip()
return base
async def _do_main(self, service, src: str, gemini_prompt: str, aspect_ratio: str,
attempt: int, qa_issue: str = "", qa_suggestion: str = "") -> tuple[bool, str, str]:
"""
执行一次主处理。
重试时根据 QA 问题类型智能调整提示词。
Returns:
(success, output_path, message)
"""
out_name = f"result_{uuid.uuid4().hex}.jpg"
output_path = os.path.join(_OUTPUT_DIR, out_name)
if attempt == 1:
prompt = gemini_prompt or None
else:
prompt = self._build_retry_prompt(gemini_prompt, qa_issue, qa_suggestion)
print(f"[ImageProcessor] 重试策略 | 问题: {qa_issue} | 提示词: {(prompt or '')[:80]}...")
print(f"[ImageProcessor] 主处理第 {attempt} 次 (比例={aspect_ratio})...")
success, message, _ = await service.extract_pattern(
input_path=src,
output_path=output_path,
custom_prompt=prompt,
aspect_ratio=aspect_ratio,
)
return success, output_path, message
# ─── 主入口 ──────────────────────────────────────────────
async def process_image(
self,
image_url: str,
operation: str,
requirements: str = "",
gemini_prompt: str = "",
aspect_ratio: str = "1:1",
perspective: str = "no",
proc_type: str = "",
subject: str = "",
quality: str = "",
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
完整处理流程:下载 → 透视矫正(可选)→ Gemini主处理 → 质检 → 重试(可选)
Returns:
{
"success": bool,
"result_path": str,
"message": str,
"qa_score": int, # 质检得分 0-100
"qa_pass": bool, # 是否通过质检
"qa_issue": str, # 质检发现的问题
"attempts": int, # 共处理了几次
}
"""
from services.service_gemini import GeminiExtractV2Service
from image.image_qa import image_qa
# Step 1: 下载原图
try:
tmp_input = await self._download(image_url)
except Exception as e:
return {
"success": False, "result_path": "", "message": str(e),
"qa_score": 0, "qa_pass": False, "qa_issue": "下载失败", "attempts": 0,
}
# Step 1.5: 敏感图片检测
try:
from utils.content_filter import is_sensitive_image
sensitive, reason = await is_sensitive_image(tmp_input)
if sensitive:
if os.path.exists(tmp_input):
os.remove(tmp_input)
return {
"success": False, "result_path": "", "message": reason,
"qa_score": 0, "qa_pass": False, "qa_issue": "敏感图片", "attempts": 0,
}
except Exception as e:
print(f"[ImageProcessor] 敏感图片检测异常: {e},继续处理")
# Step 1.6: 预检(尺寸/格式/损坏)
try:
from image.image_precheck import precheck
ok, msg = precheck(tmp_input)
if not ok:
if os.path.exists(tmp_input):
os.remove(tmp_input)
return {
"success": False, "result_path": "", "message": msg,
"qa_score": 0, "qa_pass": False, "qa_issue": "预检不通过", "attempts": 0,
}
except Exception as e:
print(f"[ImageProcessor] 预检异常: {e},继续处理")
service = GeminiExtractV2Service()
tmp_files = [tmp_input]
try:
# Step 2: 透视矫正
current_input = tmp_input
if perspective in ("mild", "strong"):
print(f"[ImageProcessor] 透视矫正中 (level={perspective})...")
corrected = await self._do_perspective(service, tmp_input, perspective)
if corrected != tmp_input:
tmp_files.append(corrected)
current_input = corrected
# Step 3: 主处理 + 质检,最多 _MAX_RETRIES 次
qa_result = {"score": 0, "pass": False, "issue": "未质检", "suggestion": ""}
output_path = ""
last_message = ""
qa_issue = ""
qa_suggestion = ""
for attempt in range(1, _MAX_RETRIES + 1):
ok, output_path, last_message = await self._do_main(
service, current_input, gemini_prompt, aspect_ratio,
attempt=attempt, qa_issue=qa_issue, qa_suggestion=qa_suggestion,
)
if not ok:
print(f"[ImageProcessor] 第 {attempt} 次处理失败: {last_message}")
if attempt < _MAX_RETRIES:
continue
return {
"success": False, "result_path": "", "message": last_message,
"qa_score": 0, "qa_pass": False, "qa_issue": "Gemini处理失败", "attempts": attempt,
}
# Step 4: 质检
print(f"[ImageProcessor] 质检中 (第 {attempt} 次结果)...")
qa_result = await image_qa.check(
original_path=current_input,
result_path=output_path,
proc_type=proc_type,
subject=subject,
quality=quality,
gemini_prompt=gemini_prompt,
)
qa_issue = qa_result.get("issue", "")
qa_suggestion = qa_result.get("suggestion", "")
if qa_result["pass"]:
print(f"[ImageProcessor] 质检通过 ({qa_result['score']}分),共处理 {attempt}")
break
else:
print(f"[ImageProcessor] 质检不合格 ({qa_result['score']}分),问题: {qa_result['issue']}")
if attempt < _MAX_RETRIES:
# 清理这次不合格的结果
if os.path.exists(output_path):
os.remove(output_path)
print(f"[ImageProcessor] 准备第 {attempt + 1} 次重试...")
else:
print(f"[ImageProcessor] 已达最大重试次数 {_MAX_RETRIES},保留最后结果,人工跟进")
return {
"success": True,
"result_path": output_path,
"message": last_message,
"qa_score": qa_result.get("score", 0),
"qa_pass": qa_result.get("pass", False),
"qa_issue": qa_result.get("issue", ""),
"attempts": attempt,
}
except Exception as e:
return {
"success": False, "result_path": "", "message": f"处理异常: {e}",
"qa_score": 0, "qa_pass": False, "qa_issue": str(e), "attempts": 0,
}
finally:
await service.cleanup()
for f in tmp_files:
if os.path.exists(f):
os.remove(f)
async def enhance(self, image_url: str) -> Dict[str, Any]:
return await self.process_image(image_url, "enhance")
async def remove_bg(self, image_url: str) -> Dict[str, Any]:
return await self.process_image(image_url, "remove_bg")
async def resize(self, image_url: str, width: int, height: int = 0) -> Dict[str, Any]:
"""
改尺寸:下载图片(或读取本地路径),按指定宽高缩放,保存到 results/。
Args:
image_url: 图片 URL 或本地路径
width: 目标宽度(像素)
height: 目标高度0=按宽度等比缩放)
Returns:
{"success": bool, "result_path": str, "message": str}
"""
from PIL import Image
is_temp = image_url.startswith(("http://", "https://"))
try:
if is_temp:
tmp = await self._download(image_url)
else:
tmp = image_url
if not os.path.exists(tmp):
return {"success": False, "result_path": "", "message": f"文件不存在: {tmp}"}
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
try:
img = Image.open(tmp).convert("RGB")
w_orig, h_orig = img.size
if width <= 0 or width > 10000:
return {"success": False, "result_path": "", "message": f"宽度无效: {width}"}
if height == 0:
ratio = width / w_orig
height = int(h_orig * ratio)
elif height <= 0 or height > 10000:
return {"success": False, "result_path": "", "message": f"高度无效: {height}"}
resized = img.resize((width, height), Image.Resampling.LANCZOS)
out_name = f"resize_{uuid.uuid4().hex}.jpg"
out_path = os.path.join(_OUTPUT_DIR, out_name)
resized.save(out_path, "JPEG", quality=95)
print(f"[ImageProcessor] 改尺寸完成: {w_orig}x{h_orig}{width}x{height}")
return {"success": True, "result_path": out_path, "message": f"已改为 {width}x{height}"}
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
finally:
if is_temp and os.path.exists(tmp):
os.remove(tmp)
# 全局实例
image_processor = ImageProcessor()

View File

@@ -1,189 +0,0 @@
"""
图片处理结果质检模块
处理完成后,用视觉 AI 对比原图和结果图,判断是否符合客户需求。
评分 0-100低于阈值则判定不合格触发重试或人工跟进。
"""
import base64
import os
import time
import asyncio
from typing import Optional
from dotenv import load_dotenv
load_dotenv()
_QA_PASS_SCORE = int(os.getenv("QA_PASS_SCORE", "70")) # 合格分数线默认70
QA_PROMPT_TEMPLATE = """\
你是一名专业的图片处理质检员,需要评估处理结果是否满足要求。
【处理类型】{proc_type}
【客户需求/Gemini提示词】{gemini_prompt}
【原图描述】主体:{subject},类型:{proc_type},质量:{quality}
请对比左图原图和右图处理结果从以下维度打分每项0-25分
1. 内容完整性:主体图案/内容是否完整保留,有无缺失、截断
2. 畸变去除:褶皱/透视变形/背景是否已被清除
3. 细节还原:颜色、线条、纹理等细节与原图的匹配程度
4. 输出干净度背景是否干净有无多余内容、AI幻觉、模糊块
输出格式(严格按照此格式,每行一个字段):
完整性: <0-25>
畸变: <0-25>
细节: <0-25>
干净: <0-25>
总分: <0-100>
结论: <pass|fail>
问题: <简述主要问题不超过30字无问题填"">
建议: <如果fail给出重试改进建议不超过40字pass则填"">
"""
class ImageQA:
"""处理结果质检器"""
def __init__(self):
self.api_key = os.getenv("OPENAI_API_KEY")
self.base_url = os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4")
self.model = os.getenv("VISION_MODEL", "glm-4v-flash")
self.pass_score = _QA_PASS_SCORE
def _to_base64(self, path: str) -> Optional[str]:
try:
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
print(f"[ImageQA] 读取图片失败 {path}: {e}")
return None
def _parse(self, text: str) -> dict:
def p(key):
for line in text.splitlines():
line = line.strip()
for k in [f"{key}:", f"{key}"]:
if line.startswith(k):
return line[len(k):].strip()
return ""
try:
score = int(p("总分"))
except ValueError:
score = 0
conclusion = p("结论").lower()
if conclusion not in ("pass", "fail"):
conclusion = "pass" if score >= self.pass_score else "fail"
return {
"score": score,
"pass": conclusion == "pass",
"issue": p("问题"),
"suggestion": p("建议"),
"detail": {
"completeness": p("完整性"),
"distortion": p("畸变"),
"detail": p("细节"),
"clean": p("干净"),
},
"raw": text,
}
async def check(
self,
original_path: str,
result_path: str,
proc_type: str = "",
subject: str = "",
quality: str = "",
gemini_prompt: str = "",
) -> dict:
"""
质检处理结果。
Args:
original_path: 原图本地路径
result_path: 处理结果本地路径
proc_type: 处理类型(印花提取 / 高清修复等)
subject: 主体描述
quality: 原图质量
gemini_prompt: 传给 Gemini 的提示词(体现客户需求)
Returns:
{
"score": int, # 0-100
"pass": bool, # 是否合格
"issue": str, # 主要问题
"suggestion": str, # 重试改进建议
"detail": dict, # 各维度分数
}
"""
if not self.api_key:
print("[ImageQA] 未配置 API Key跳过质检默认通过")
return {"score": 80, "pass": True, "issue": "", "suggestion": "", "detail": {}}
orig_b64 = self._to_base64(original_path)
result_b64 = self._to_base64(result_path)
if not orig_b64 or not result_b64:
print("[ImageQA] 图片读取失败,跳过质检")
return {"score": 75, "pass": True, "issue": "质检图片读取失败", "suggestion": "", "detail": {}}
prompt = QA_PROMPT_TEMPLATE.format(
proc_type=proc_type or "图片处理",
subject=subject or "未知",
quality=quality or "未知",
gemini_prompt=gemini_prompt or "按标准处理",
)
start = time.monotonic()
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url=self.base_url, api_key=self.api_key)
response = await client.responses.create(
model=self.model,
input=[
{
"role": "user",
"content": [
{
"type": "input_image",
"image_url": f"data:image/jpeg;base64,{orig_b64}",
},
{
"type": "input_image",
"image_url": f"data:image/jpeg;base64,{result_b64}",
},
{
"type": "input_text",
"text": prompt,
},
],
}
],
)
content = response.output_text
elapsed = time.monotonic() - start
result = self._parse(content)
result["elapsed"] = round(elapsed, 1)
status = "✓ 合格" if result["pass"] else "✗ 不合格"
print(f"[ImageQA] {status} | 得分: {result['score']}/100 | 问题: {result['issue']} | 耗时: {elapsed:.1f}s")
if not result["pass"]:
print(f"[ImageQA] 改进建议: {result['suggestion']}")
try:
from utils.api_cost_tracker import record
record("gemini_vision", count=1)
except Exception:
pass
return result
except Exception as e:
elapsed = time.monotonic() - start
print(f"[ImageQA] 质检失败 ({elapsed:.1f}s): {e}")
return {"score": 75, "pass": True, "issue": f"质检异常: {e}", "suggestion": "", "detail": {}}
# 全局实例
image_qa = ImageQA()

View File

@@ -1,293 +0,0 @@
"""
图片处理独立工具 - 可单独调用,也可被主流程复用。
主流程(付款触发)不变,这些工具供 AI 按需组合使用。
"""
import os
import uuid
import tempfile
from typing import Dict, Any, Optional
_OUTPUT_DIR = os.getenv("RESULT_IMAGE_DIR", "results")
os.makedirs(_OUTPUT_DIR, exist_ok=True)
async def _download(url: str) -> str:
"""下载图片到临时文件"""
import aiohttp
tmp = os.path.join(tempfile.gettempdir(), f"img_{uuid.uuid4().hex}.jpg")
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://www.taobao.com/",
}
async with aiohttp.ClientSession(headers=headers) as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status != 200:
raise RuntimeError(f"下载失败: HTTP {resp.status}")
with open(tmp, "wb") as f:
f.write(await resp.read())
return tmp
async def remove_background(image_url: str, save_path: str = "") -> Dict[str, Any]:
"""
【独立工具】去背景 → 纯白/纯色背景。
输入 URL 或本地路径,输出白底产品图。
"""
from image.perspective_fix import _gemini_call, PROMPT_WHITE_BG
tmp = None
try:
if image_url.startswith(("http://", "https://")):
tmp = await _download(image_url)
src = tmp
else:
src = image_url
if not os.path.exists(src):
return {"success": False, "result_path": "", "message": f"文件不存在: {src}"}
out = save_path or os.path.join(_OUTPUT_DIR, f"bg_{uuid.uuid4().hex}.jpg")
ok = await _gemini_call(src, out, PROMPT_WHITE_BG, aspect_ratio="auto", label="去背景")
if ok:
return {"success": True, "result_path": out, "message": "去背景完成"}
return {"success": False, "result_path": "", "message": "去背景失败"}
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
async def perspective_correct(image_url: str, save_path: str = "") -> Dict[str, Any]:
"""
【独立工具】透视矫正。
输入需为白底图(可先调 remove_background输出展平后的图。
"""
import cv2
from image.perspective_fix import find_quad, four_point_transform
tmp = None
try:
if image_url.startswith(("http://", "https://")):
tmp = await _download(image_url)
src = tmp
else:
src = image_url
if not os.path.exists(src):
return {"success": False, "result_path": "", "message": f"文件不存在: {src}"}
img = cv2.imread(src)
if img is None:
return {"success": False, "result_path": "", "message": "无法读取图片"}
pts = find_quad(img)
if pts is None:
return {"success": False, "result_path": "", "message": "未检测到四边形,无法透视矫正"}
warped = four_point_transform(img, pts)
out = save_path or os.path.join(_OUTPUT_DIR, f"persp_{uuid.uuid4().hex}.jpg")
cv2.imwrite(out, warped, [cv2.IMWRITE_JPEG_QUALITY, 95])
return {"success": True, "result_path": out, "message": "透视矫正完成"}
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
async def extract_pattern(image_url: str, prompt: str = "", aspect_ratio: str = "1:1",
save_path: str = "") -> Dict[str, Any]:
"""
【独立工具】印花提取/主处理。
按提示词和比例输出处理后的图。
"""
from services.service_gemini import GeminiExtractV2Service
tmp = None
try:
if image_url.startswith(("http://", "https://")):
tmp = await _download(image_url)
src = tmp
else:
src = image_url
if not os.path.exists(src):
return {"success": False, "result_path": "", "message": f"文件不存在: {src}"}
out = save_path or os.path.join(_OUTPUT_DIR, f"extract_{uuid.uuid4().hex}.jpg")
service = GeminiExtractV2Service()
try:
ok, msg, _ = await service.extract_pattern(
input_path=src, output_path=out,
custom_prompt=prompt or None, aspect_ratio=aspect_ratio,
)
if ok and os.path.exists(out):
return {"success": True, "result_path": out, "message": "提取完成"}
return {"success": False, "result_path": "", "message": msg or "提取失败"}
finally:
await service.cleanup()
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
async def enhance_image(image_url: str, save_path: str = "") -> Dict[str, Any]:
"""
【独立工具】高清增强。
使用 Qwen RunningHub失败时降级 Gemini。
"""
from services.service_qwen import 清晰化_api
from image.perspective_fix import _gemini_call, PROMPT_ENHANCE_SIMPLE
tmp = None
try:
if image_url.startswith(("http://", "https://")):
tmp = await _download(image_url)
src = tmp
else:
src = image_url
if not os.path.exists(src):
return {"success": False, "result_path": "", "message": f"文件不存在: {src}"}
out = save_path or os.path.join(_OUTPUT_DIR, f"enh_{uuid.uuid4().hex}.jpg")
ok = await 清晰化_api(img_path=src, save_path=out)
if not ok:
ok = await _gemini_call(src, out, PROMPT_ENHANCE_SIMPLE, aspect_ratio="auto", label="增强")
if ok:
return {"success": True, "result_path": out, "message": "高清增强完成"}
return {"success": False, "result_path": "", "message": "高清增强失败"}
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
async def color_match_images(orig_url: str, result_url: str, save_path: str = "",
strength: float = 0.75) -> Dict[str, Any]:
"""
【独立工具】颜色匹配。将 result 的色调匹配到 orig。
"""
import cv2
from image.perspective_fix import _color_match
tmp_orig = tmp_result = None
try:
if orig_url.startswith(("http://", "https://")):
tmp_orig = await _download(orig_url)
orig_path = tmp_orig
else:
orig_path = orig_url
if result_url.startswith(("http://", "https://")):
tmp_result = await _download(result_url)
result_path = tmp_result
else:
result_path = result_url
orig_img = cv2.imread(orig_path)
result_img = cv2.imread(result_path)
if orig_img is None or result_img is None:
return {"success": False, "result_path": "", "message": "图片读取失败"}
matched = _color_match(orig_img, result_img, strength=strength)
out = save_path or os.path.join(_OUTPUT_DIR, f"color_{uuid.uuid4().hex}.jpg")
cv2.imwrite(out, matched, [cv2.IMWRITE_JPEG_QUALITY, 95])
return {"success": True, "result_path": out, "message": f"颜色匹配完成(强度{strength:.0%})"}
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
finally:
for t in (tmp_orig, tmp_result):
if t and os.path.exists(t):
os.remove(t)
async def trim_border(image_url: str, save_path: str = "") -> Dict[str, Any]:
"""
【独立工具】裁切四周背景边(支持任意颜色:白/黄/米等)。
"""
import cv2
from image.perspective_fix import tool_trim_white_border
tmp = None
try:
if image_url.startswith(("http://", "https://")):
tmp = await _download(image_url)
src = tmp
else:
src = image_url
if not os.path.exists(src):
return {"success": False, "result_path": "", "message": f"文件不存在: {src}"}
img = cv2.imread(src)
if img is None:
return {"success": False, "result_path": "", "message": "无法读取图片"}
trimmed, did_trim, info = tool_trim_white_border(img)
out = save_path or os.path.join(_OUTPUT_DIR, f"trim_{uuid.uuid4().hex}.jpg")
cv2.imwrite(out, trimmed, [cv2.IMWRITE_JPEG_QUALITY, 95])
return {"success": True, "result_path": out, "message": "裁边完成" if did_trim else "无需裁边"}
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
async def vectorize_to_eps(image_url: str, save_path: str = "") -> Dict[str, Any]:
"""
【独立工具】矢量化 - 将图片转为 EPS 矢量文件。
客户要做矢量图、转 EPS、转 AI 格式时调用。
"""
tmp = None
try:
if image_url.startswith(("http://", "https://")):
tmp = await _download(image_url)
src = tmp
else:
src = image_url
if not os.path.exists(src):
return {"success": False, "result_path": "", "message": f"文件不存在: {src}"}
from services.service_vectorizer import VectorizerService
svc = VectorizerService()
out = save_path or os.path.join(_OUTPUT_DIR, f"vec_{uuid.uuid4().hex}.eps")
result_path = await svc.image_to_eps(src, save_eps_path=out)
if result_path and os.path.exists(result_path):
return {"success": True, "result_path": result_path, "message": "矢量化完成,已生成 EPS 文件"}
return {"success": False, "result_path": "", "message": "矢量化失败"}
except ImportError as e:
return {"success": False, "result_path": "", "message": f"矢量化服务不可用: {e}"}
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
async def meitu_enhance(image_url: str, mode: str = "standard", save_path: str = "") -> Dict[str, Any]:
"""
【独立工具】美图画质增强。
模式: crystal(极速重绘) standard(标准) enhance(增强) hdr(HDR) portrait(人像优化)
客户要画质增强、清晰化、美图处理时调用。
"""
tmp = None
try:
if image_url.startswith(("http://", "https://")):
tmp = await _download(image_url)
src = tmp
else:
src = image_url
if not os.path.exists(src):
return {"success": False, "result_path": "", "message": f"文件不存在: {src}"}
from pathlib import Path
from services.service_meitu import MeituAPIService
svc = MeituAPIService()
output_dir = Path(_OUTPUT_DIR)
result = await svc.process_image(src, mode=mode, output_dir=output_dir)
out = result.get("processed_path")
if out and os.path.exists(str(out)):
if save_path:
import shutil
shutil.copy(str(out), save_path)
out = save_path
return {"success": True, "result_path": str(out), "message": f"画质增强完成({result.get('mode_name', mode)}"}
return {"success": False, "result_path": "", "message": "美图处理失败"}
except ImportError as e:
return {"success": False, "result_path": "", "message": f"美图服务不可用: {e}"}
except Exception as e:
return {"success": False, "result_path": "", "message": str(e)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)

Some files were not shown because too many files have changed in this diff Show More