init
34
.env
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 火山引擎 Ark API
|
||||||
|
OPENAI_API_KEY=09f8ef95-382a-4f3a-b90b-8775e0ceb5da
|
||||||
|
OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
OPENAI_MODEL=doubao-seed-2-0-lite-260215
|
||||||
|
VISION_MODEL=doubao-seed-2-0-mini-260215
|
||||||
|
|
||||||
|
# 作图API配置
|
||||||
|
IMAGE_API_URL=https://your-image-api.com/process
|
||||||
|
IMAGE_API_KEY=your_image_api_key
|
||||||
|
|
||||||
|
# 邮件SMTP配置
|
||||||
|
SMTP_HOST=smtp.qq.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=357805318@qq.com
|
||||||
|
SMTP_PASSWORD=bnnppvaweytkcadc
|
||||||
|
SENDER_NAME=修图客服
|
||||||
|
EMAIL_POLL_INTERVAL=30
|
||||||
|
|
||||||
|
# 企业微信群机器人 Webhook(日报推送)
|
||||||
|
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=cc88bdef-a13f-4d7e-bdb6-ee51b68b8205
|
||||||
|
|
||||||
|
# 每日日报接收邮箱(留空则不发邮件)
|
||||||
|
SUMMARY_EMAIL=
|
||||||
|
|
||||||
|
# 日报发送时间(默认 23:50)
|
||||||
|
SUMMARY_HOUR=23
|
||||||
|
SUMMARY_MINUTE=50
|
||||||
|
|
||||||
|
# 设计师在线查询(转人工时按需 GET),如 http://huichang.online:8001/online
|
||||||
|
DESIGNER_ROSTER_API=
|
||||||
|
|
||||||
|
# 可选:代理设置
|
||||||
|
# HTTP_PROXY=http://127.0.0.1:7890
|
||||||
|
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||||
55
.env.example
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 火山引擎 Ark API(豆包)
|
||||||
|
OPENAI_API_KEY=你的ArkAPIKey
|
||||||
|
OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
OPENAI_MODEL=doubao-seed-2-0-lite-260215
|
||||||
|
VISION_MODEL=doubao-seed-2-0-mini-260215
|
||||||
|
|
||||||
|
# 邮件 SMTP
|
||||||
|
SMTP_HOST=smtp.qq.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your_email@qq.com
|
||||||
|
SMTP_PASSWORD=你的授权码
|
||||||
|
SENDER_NAME=修图客服
|
||||||
|
|
||||||
|
# 邮件 IMAP(接收,可选)
|
||||||
|
IMAP_HOST=imap.qq.com
|
||||||
|
IMAP_USER=
|
||||||
|
IMAP_PASSWORD=
|
||||||
|
|
||||||
|
# 企业微信群机器人 Webhook
|
||||||
|
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
|
||||||
|
|
||||||
|
# 设计师在线查询服务(另一台电脑,返回 上线/下线 消息)
|
||||||
|
# DESIGNER_ROSTER_API=http://192.168.1.100:8080/wechat/group/messages
|
||||||
|
|
||||||
|
# 质检
|
||||||
|
QA_PASS_SCORE=70
|
||||||
|
PROCESS_MAX_RETRIES=2
|
||||||
|
RESULT_IMAGE_DIR=results
|
||||||
|
|
||||||
|
# 日报
|
||||||
|
SUMMARY_EMAIL=
|
||||||
|
SUMMARY_HOUR=23
|
||||||
|
SUMMARY_MINUTE=50
|
||||||
|
|
||||||
|
# 可选:语义匹配(embedding 意图/情绪,不配置则用关键词)
|
||||||
|
# EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
|
# 可选:美图 / 矢量化
|
||||||
|
# MEITU_API_URL=http://your-meitu-api:port
|
||||||
|
|
||||||
|
# 日志轮转(默认 10MB 切分,保留 7 份)
|
||||||
|
# LOG_MAX_BYTES=10
|
||||||
|
# LOG_BACKUP_COUNT=7
|
||||||
|
|
||||||
|
# 图片队列(默认并发 2,队列上限 20)
|
||||||
|
# IMAGE_QUEUE_MAX_CONCURRENT=2
|
||||||
|
# IMAGE_QUEUE_MAX_SIZE=20
|
||||||
|
|
||||||
|
# 健康检查(默认 60 秒)
|
||||||
|
# HEALTH_CHECK_INTERVAL=60
|
||||||
|
# HEALTH_CHECK_WECHAT_PING=false
|
||||||
|
|
||||||
|
# 可选:代理
|
||||||
|
# HTTP_PROXY=http://127.0.0.1:7890
|
||||||
|
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||||
346
README.md
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
# 电商客服 AI 自动回复系统
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
基于 PydanticAI 的淘宝修图店客服系统,支持自动回复、智能报价、转接人工、客户画像等功能。
|
||||||
|
付款后自动触发图片处理流水线,完成后通过邮件将结果发送给客户。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最近更新
|
||||||
|
|
||||||
|
| 更新项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| **消息处理非阻塞** | 图片分析、Agent 回复改为后台任务,接收循环不阻塞,可同时处理多客户 |
|
||||||
|
| **同客户串行** | 按客户加锁,保证「发图→这个高清」等顺序,避免误判 |
|
||||||
|
| **并发限流** | agent_reply 最多 8 个并发,防止 API 打满 |
|
||||||
|
| **图片分析缓存** | 同一 URL 5 分钟内复用结果,节省视觉 API 调用 |
|
||||||
|
| **报价维度** | 平整度、含文字(小字加价)、含人脸、阴影,越平整越便宜 |
|
||||||
|
| **项目结构** | 主文件归类到 `core/`、`db/`、`image/`、`services/`、`mail/`、`utils/` 子目录 |
|
||||||
|
| **配置中心** | `config/config.py` 统一路径、常量,支持 `LOG_MAX_BYTES`、`IMAGE_QUEUE_*` 等 |
|
||||||
|
| **转接分组** | `config/transfer_groups.json` 店铺→分组映射 |
|
||||||
|
| **设计师派单** | SQLite 存储,转人工时按需查询在线,轮询派单 |
|
||||||
|
| **健康检查** | 定时检测轻简连接,断线企微告警 |
|
||||||
|
| **日志轮转** | 按 10MB 切分,保留 7 份 |
|
||||||
|
| **图片队列** | 并发限制 2,高并发时排队 |
|
||||||
|
| **邮件重试** | 发送失败自动重试 1 次 |
|
||||||
|
| **Web 启动器** | `scripts/launcher_ui.py` 酷炫控制台,http://localhost:5679 |
|
||||||
|
| **矢量化/美图 Tool** | `vectorize_to_eps_tool`、`meitu_enhance_tool` |
|
||||||
|
| **客服对话增强** | 语义匹配、多轮记忆、个性化、主动预测 |
|
||||||
|
| **单元测试** | `tests/test_config.py`、`test_image_queue.py`、`test_health_check.py` 等 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. 配置环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env 填入 API Key、邮件等
|
||||||
|
|
||||||
|
# 3. 启动(需轻简软件已运行在 ws://127.0.0.1:9528)
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能列表
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 自动回复 | 收到消息自动回复,防抖合并连续短消息,后台处理不阻塞接收 |
|
||||||
|
| 售前分流 | 自动识别售前/售后阶段 |
|
||||||
|
| 智能报价 | 图片分析后按复杂度报价(10-30元,5的整数倍),平整度/文字/人脸/阴影影响价格 |
|
||||||
|
| 压价应对 | 只让价一次,记录历史让价次数 |
|
||||||
|
| 转接人工 | 退款/投诉/情绪激动自动转接 |
|
||||||
|
| 订单识别 | 自动识别系统订单消息,付款后触发作图 |
|
||||||
|
| 付款检测 | 催单时核查付款状态,未付款不误导客户 |
|
||||||
|
| 图片处理 | 透视矫正 + Qwen高清增强五步流水线 |
|
||||||
|
| 质检重试 | 视觉AI质检,不合格自动重试(最多2次)|
|
||||||
|
| 颜色匹配 | 类PS「匹配颜色」算法,修正AI处理后色差 |
|
||||||
|
| 边框裁切 | 自动检测任意颜色背景边并裁切 |
|
||||||
|
| 客户画像 | 自动提取邮箱/电话/微信/性格/价格敏感度 |
|
||||||
|
| 企微通知 | API异常/质检失败/订单金额异常推送企微 |
|
||||||
|
| SKILL.md | 支持技能文档动态加载 |
|
||||||
|
| 矢量化 | 图片转 EPS 矢量文件(独立 Tool)|
|
||||||
|
| 美图增强 | 画质增强(极速/标准/增强/HDR/人像)|
|
||||||
|
| Web 启动器 | 酷炫控制台一键启停客服机器人 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
D:\Terminator\
|
||||||
|
├── run.py # 项目入口(启动客服机器人)
|
||||||
|
├── core/ # 核心逻辑
|
||||||
|
│ ├── websocket_client.py # WebSocket 客户端(主程序,含防抖)
|
||||||
|
│ ├── pydantic_ai_agent.py# AI Agent 核心(含报价/风险/订单逻辑)
|
||||||
|
│ └── workflow.py # 工作流(付款触发 → 作图 → 发邮件)
|
||||||
|
├── db/ # 数据层
|
||||||
|
│ ├── customer_db.py # 客户画像数据库(SQLite)
|
||||||
|
│ ├── chat_log_db.py # 聊天记录数据库
|
||||||
|
│ └── designer_roster_db.py # 设计师派单(同一人不同店铺不同分组,轮询)
|
||||||
|
├── image/ # 图片处理
|
||||||
|
│ ├── image_analyzer.py # 图片分析(复杂度/风险/Gemini提示词)
|
||||||
|
│ ├── image_processor.py # 图片处理主模块(下载→透视→增强→质检)
|
||||||
|
│ ├── image_tools.py # 独立图片工具(去背景/透视/增强/裁边等)
|
||||||
|
│ ├── image_qa.py # 视觉AI质检(对比原图和结果,0-100分)
|
||||||
|
│ └── perspective_fix.py # 透视矫正五步流水线(独立可运行)
|
||||||
|
├── services/ # 外部服务
|
||||||
|
│ ├── service_gemini.py # Gemini API(去背景/增强)
|
||||||
|
│ ├── service_qwen.py # Qwen RunningHub API(高清增强)
|
||||||
|
│ ├── service_meitu.py # 美图 API(画质增强)
|
||||||
|
│ └── service_vectorizer.py # 矢量化服务(转 EPS)
|
||||||
|
├── mail/ # 邮件(避免与标准库 email 冲突)
|
||||||
|
│ ├── email_sender.py # 邮件发送(SMTP)
|
||||||
|
│ └── email_receiver.py # 邮件接收(IMAP 轮询)
|
||||||
|
├── utils/
|
||||||
|
│ ├── daily_summary.py # 日报推送
|
||||||
|
│ ├── service_base.py # 服务基类(矢量化等)
|
||||||
|
│ ├── intent_analyzer.py # 语义匹配(意图/情绪)
|
||||||
|
│ ├── image_queue.py # 图片处理队列
|
||||||
|
│ ├── health_check.py # 健康检查
|
||||||
|
│ └── designer_roster.py # 设计师在线(转人工时按需查询)
|
||||||
|
├── config/
|
||||||
|
│ ├── config.py # 配置中心(路径、常量)
|
||||||
|
│ └── transfer_groups.json # 店铺 acc_id → 转接分组 group_id 映射
|
||||||
|
├── scripts/ # 可执行脚本
|
||||||
|
│ ├── launcher_ui.py # Web 控制台(一键启停客服机器人)
|
||||||
|
│ ├── init_designer_roster.py # 设计师派单数据初始化
|
||||||
|
│ ├── chat_ui.py # 聊天记录 Web 查看器
|
||||||
|
│ └── chat_log_viewer.py # 聊天记录 CLI 查看器
|
||||||
|
├── tests/
|
||||||
|
│ ├── test_process.py # 图片处理流程测试
|
||||||
|
│ ├── test_config.py # 配置中心测试
|
||||||
|
│ ├── test_image_queue.py # 图片队列测试
|
||||||
|
│ └── test_health_check.py# 健康检查测试
|
||||||
|
├── archive/ # 归档(未用/旧版文件)
|
||||||
|
├── logs/ # 日志
|
||||||
|
├── results/ # 处理结果图片
|
||||||
|
└── .env # 环境配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 图片处理流水线(perspective_fix.py)
|
||||||
|
|
||||||
|
客户付款后自动运行,共五步:
|
||||||
|
|
||||||
|
```
|
||||||
|
原图
|
||||||
|
│
|
||||||
|
▼ Step 1 Gemini 去背景 → 纯白/纯色背景
|
||||||
|
│ ┗ 自动检测白色覆盖率,< 20% 则换强化提示词重试
|
||||||
|
│
|
||||||
|
▼ Step 2 OpenCV 轮廓检测 + 透视矫正
|
||||||
|
│ ┗ 三种策略:approxPolyDP → 凸包极值 → minAreaRect
|
||||||
|
│ ┗ 自动检测 Gemini 旋转问题并纠正方向
|
||||||
|
│
|
||||||
|
▼ Step 3 Qwen(RunningHub ComfyUI)高清增强
|
||||||
|
│ ┗ 失败时降级到 Gemini 简化提示词兜底
|
||||||
|
│
|
||||||
|
▼ Step 4 豆包视觉 AI 决策后处理
|
||||||
|
│ ┣ 颜色匹配(需要时):LAB色彩空间 Reinhard 算法
|
||||||
|
│ │ ┗ 按颜色差异程度自动调整强度(明显80% / 轻微55%)
|
||||||
|
│ ┗ 背景边裁切(需要时):自适应背景色检测,支持任意颜色边框
|
||||||
|
│ ┗ 从四角采样背景色,逐行/列扫描(非硬编码白色)
|
||||||
|
│
|
||||||
|
▼ 输出最终图片(results/ 目录)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 独立运行
|
||||||
|
```bash
|
||||||
|
python -m image.perspective_fix <图片路径或URL> [--debug] [--skip-step1] [--skip-step3]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境配置 (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 火山引擎豆包 API
|
||||||
|
OPENAI_API_KEY=你的API Key
|
||||||
|
OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
OPENAI_MODEL=doubao-seed-2-0-lite-260215
|
||||||
|
VISION_MODEL=doubao-seed-2-0-mini-260215
|
||||||
|
|
||||||
|
# 邮件SMTP
|
||||||
|
SMTP_HOST=smtp.qq.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your_email@qq.com
|
||||||
|
SMTP_PASSWORD=your_smtp_password
|
||||||
|
SENDER_NAME=修图客服
|
||||||
|
|
||||||
|
# 企业微信群机器人 Webhook
|
||||||
|
WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key
|
||||||
|
|
||||||
|
# 质检配置
|
||||||
|
QA_PASS_SCORE=70 # 质检合格分(0-100)
|
||||||
|
PROCESS_MAX_RETRIES=2 # 最大重试次数
|
||||||
|
|
||||||
|
# 日报
|
||||||
|
SUMMARY_EMAIL= # 接收日报的邮箱,留空不发
|
||||||
|
SUMMARY_HOUR=23
|
||||||
|
SUMMARY_MINUTE=50
|
||||||
|
|
||||||
|
# 可选:语义匹配(embedding 意图/情绪,不配置则用关键词)
|
||||||
|
# EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
|
# 可选:美图画质增强(Tool 调用时需)
|
||||||
|
# MEITU_API_URL=http://your-meitu-api:port
|
||||||
|
|
||||||
|
# 可选:矢量化服务(Tool 调用时需)
|
||||||
|
# 矢量化服务 base_url 在 service_vectorizer.py 中默认配置
|
||||||
|
|
||||||
|
# 日志轮转(默认 10MB 切分,保留 7 份)
|
||||||
|
# LOG_MAX_BYTES=10
|
||||||
|
# LOG_BACKUP_COUNT=7
|
||||||
|
|
||||||
|
# 图片队列(默认并发 2,队列上限 20)
|
||||||
|
# IMAGE_QUEUE_MAX_CONCURRENT=2
|
||||||
|
# IMAGE_QUEUE_MAX_SIZE=20
|
||||||
|
|
||||||
|
# 健康检查(默认 60 秒)
|
||||||
|
# HEALTH_CHECK_INTERVAL=60
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 运行方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动主程序(客服机器人)
|
||||||
|
python run.py
|
||||||
|
|
||||||
|
# 不启用 AI(仅监听消息)
|
||||||
|
python run.py --no-agent
|
||||||
|
|
||||||
|
# Web 控制台(酷炫界面一键启停,含 Agent 开关)
|
||||||
|
python scripts/launcher_ui.py
|
||||||
|
# 访问 http://localhost:5679
|
||||||
|
|
||||||
|
# 单独测试图片处理流水线
|
||||||
|
python -m image.perspective_fix results/your_image.jpg --debug
|
||||||
|
|
||||||
|
# 测试完整流程(含付款触发)
|
||||||
|
python tests/test_process.py
|
||||||
|
|
||||||
|
# 运行单元测试
|
||||||
|
python tests/test_config.py
|
||||||
|
python tests/test_image_queue.py
|
||||||
|
python tests/test_health_check.py
|
||||||
|
|
||||||
|
# 聊天记录 Web UI
|
||||||
|
python scripts/chat_ui.py
|
||||||
|
# 访问 http://localhost:5678
|
||||||
|
|
||||||
|
# 聊天记录 CLI 查看
|
||||||
|
python scripts/chat_log_viewer.py # 列出所有客户
|
||||||
|
python scripts/chat_log_viewer.py <客户ID> # 查看某客户全部对话
|
||||||
|
python scripts/chat_log_viewer.py -s <关键词> # 全局搜索
|
||||||
|
python scripts/chat_log_viewer.py -t <客户ID> # 只看今天
|
||||||
|
python scripts/chat_log_viewer.py -l # 实时监听最新消息
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 报价逻辑
|
||||||
|
|
||||||
|
| 复杂度 | 价格区间 | 说明 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| 简单 | 10-15 元 | 画面平整、无小字、无人脸、无阴影 |
|
||||||
|
| 一般 | 15-20 元 | 一般复杂度 |
|
||||||
|
| 复杂 | 20-25 元 | 细节偏多、有褶皱/小字/人脸/阴影 |
|
||||||
|
| 困难 | 25-30 元 | 非常复杂 |
|
||||||
|
|
||||||
|
**价格必须为 5 的整数倍**(10/15/20/25/30)
|
||||||
|
|
||||||
|
**报价维度(越平整越便宜):**
|
||||||
|
- **平整度**:flat 便宜 → mild 中等 → rough 贵
|
||||||
|
- **含文字**:大字不加价,小字需精细保留则加价
|
||||||
|
- **含人脸**:有人脸加价
|
||||||
|
- **阴影**:有明显阴影需处理则加价
|
||||||
|
|
||||||
|
**风险等级:**
|
||||||
|
- `none`:直接报价
|
||||||
|
- `low`(含人脸):报价 + 风险提示,说明人脸相似度约70-90%
|
||||||
|
- `high`(严重模糊/需打印/老照片):必须先说明风险再报价
|
||||||
|
- `no`(无法处理):告知客户换图,不报价
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 触发转接
|
||||||
|
|
||||||
|
发送以下关键词自动转接人工:
|
||||||
|
- `test`(测试)
|
||||||
|
- `我要退款` / `退货` / `投诉`
|
||||||
|
- 情绪激动
|
||||||
|
|
||||||
|
**店铺→分组映射**:不同店铺对应不同客服分组,相同客服在不同店铺的分组 ID 不同。在 `config/transfer_groups.json` 中配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default": "20252916034",
|
||||||
|
"店铺A_acc_id": "分组ID1",
|
||||||
|
"店铺B_acc_id": "分组ID2"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `default`:未配置店铺时的默认分组
|
||||||
|
- 其他 key 为店铺 `acc_id`,value 为该店铺的转接分组 ID
|
||||||
|
|
||||||
|
**设计师派单(可选)**:SQLite 存储,同一设计师不同店铺不同 group_id。`python scripts/init_designer_roster.py example` 初始化。转人工时按需 GET `DESIGNER_ROSTER_API` 同步在线状态,无人在线时发企微「谁在线啊」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 客户画像字段
|
||||||
|
|
||||||
|
从对话自动提取并持久化:
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 联系方式 | 邮箱、手机、微信 |
|
||||||
|
| 消费记录 | 订单数、历史报价、最低接受价 |
|
||||||
|
| 性格标签 | 爽快/纠结/砍价/批量 |
|
||||||
|
| 图片偏好 | 处理类型、格式偏好、尺寸需求 |
|
||||||
|
| 最近图片 | URL、Gemini提示词、比例、透视状态 |
|
||||||
|
| 处理参数 | gemini_prompt、aspect_ratio、perspective |
|
||||||
|
|
||||||
|
数据库:`customer_db/customers.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已实现
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **config/config.py** | 配置中心,统一路径与常量 |
|
||||||
|
| **健康检查** | 定时检测轻简连接,断线时企微告警 |
|
||||||
|
| **日志轮转** | 按 10MB 切分,保留 7 份 |
|
||||||
|
| **图片队列** | 并发限制 2,队列上限 20,高并发时排队 |
|
||||||
|
| **单元测试** | `tests/test_*.py` |
|
||||||
|
| **客服对话增强** | 语义匹配、多轮记忆、个性化、主动预测 |
|
||||||
|
|
||||||
|
### 客服对话增强
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **语义匹配** | 配置 `EMBEDDING_MODEL` 后用 embedding 识别意图/情绪,否则关键词 |
|
||||||
|
| **多轮记忆** | 重启后从数据库加载近期对话,补充上下文 |
|
||||||
|
| **个性化** | 按性格(爽快/砍价/纠结)调整语气,按价格敏感度调整报价策略 |
|
||||||
|
| **主动预测** | 批量潜力客户主动推打包价,老客爽快直接推成交 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 需要轻简软件运行在 `ws://127.0.0.1:9528`
|
||||||
|
2. 转接格式:`话术|[转移会话],分组{group_id},无原因`,分组 ID 由 `config/transfer_groups.json` 按店铺映射
|
||||||
|
3. Gemini 使用西风代理接口,需配置对应 API Key
|
||||||
|
4. Qwen 高清增强使用 RunningHub ComfyUI 工作流,需配置 `api_key`(service_qwen.py)
|
||||||
|
5. 图片处理结果保存在 `results/` 目录(可通过 `RESULT_IMAGE_DIR` 环境变量修改)
|
||||||
|
6. 美图、矢量化 Tool 需对应服务可用;缺失依赖(如 aiofiles)时 Tool 会返回友好提示
|
||||||
BIN
__pycache__/chat_log_db.cpython-310.pyc
Normal file
BIN
__pycache__/customer_db.cpython-310.pyc
Normal file
BIN
__pycache__/daily_summary.cpython-310.pyc
Normal file
BIN
__pycache__/email_receiver.cpython-310.pyc
Normal file
BIN
__pycache__/email_sender.cpython-310.pyc
Normal file
BIN
__pycache__/image_analyzer.cpython-310.pyc
Normal file
BIN
__pycache__/image_processor.cpython-310.pyc
Normal file
BIN
__pycache__/image_qa.cpython-310.pyc
Normal file
BIN
__pycache__/image_tools.cpython-310.pyc
Normal file
BIN
__pycache__/perspective_fix.cpython-310.pyc
Normal file
BIN
__pycache__/pydantic_ai_agent.cpython-310.pyc
Normal file
BIN
__pycache__/service_gemini.cpython-310.pyc
Normal file
BIN
__pycache__/service_qwen.cpython-310.pyc
Normal file
BIN
__pycache__/workflow.cpython-310.pyc
Normal file
12
archive/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 归档文件
|
||||||
|
|
||||||
|
此目录存放暂未使用或已替代的旧文件,主流程不依赖。
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| service_meitu.py | 美图服务(未接入) |
|
||||||
|
| service_vectorizer.py | 矢量化服务(未接入) |
|
||||||
|
| view_chats.py | 旧版聊天查看(已由 chat_log_viewer 替代) |
|
||||||
|
| test_import.py | 导入测试 |
|
||||||
|
| test_battle.py | 压价话术测试 |
|
||||||
|
| viewer_out.txt | 临时输出 |
|
||||||
298
archive/test_battle.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"""
|
||||||
|
AI 左右互搏测试工具
|
||||||
|
|
||||||
|
客服AI(真实) vs 买家AI(模拟)
|
||||||
|
用来调试客服AI的回复质量,不需要真实客户
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import httpx
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from pydantic_ai_agent import CustomerServiceAgent, CustomerMessage
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
WECHAT_WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=cc88bdef-a13f-4d7e-bdb6-ee51b68b8205"
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_wechat(content: str):
|
||||||
|
"""发送企业微信机器人通知"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
await client.post(WECHAT_WEBHOOK, json={
|
||||||
|
"msgtype": "text",
|
||||||
|
"text": {"content": content}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[通知] 企业微信发送失败: {e}")
|
||||||
|
|
||||||
|
# ========== 颜色输出 ==========
|
||||||
|
RESET = "\033[0m"
|
||||||
|
BLUE = "\033[94m" # 买家
|
||||||
|
GREEN = "\033[92m" # 客服
|
||||||
|
YELLOW = "\033[93m" # 系统提示
|
||||||
|
GRAY = "\033[90m" # 分隔线
|
||||||
|
|
||||||
|
|
||||||
|
def print_buyer(msg):
|
||||||
|
print(f"{BLUE}【买家】{msg}{RESET}")
|
||||||
|
|
||||||
|
def print_shop(msg):
|
||||||
|
print(f"{GREEN}【客服】{msg}{RESET}")
|
||||||
|
|
||||||
|
def print_system(msg):
|
||||||
|
print(f"{YELLOW}{msg}{RESET}")
|
||||||
|
|
||||||
|
def print_sep():
|
||||||
|
print(f"{GRAY}{'─' * 50}{RESET}")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 买家人设配置 ==========
|
||||||
|
BUYER_PERSONAS = {
|
||||||
|
"普通买家": """你是一个普通淘宝买家,想找一张图的高清版本。
|
||||||
|
行为:先打招呼,发图问价,可能小砍一次价,觉得合适就说要拍了。
|
||||||
|
说话简短自然,像手机上发消息,1-2句话。不要太正式。""",
|
||||||
|
|
||||||
|
"砍价客": """你是个爱砍价的淘宝买家,总想便宜一点。
|
||||||
|
行为:问价后嫌贵,砍价2-3次,最后可能接受也可能走人。
|
||||||
|
说话直接,喜欢说"能不能便宜点""太贵了""别家更便宜"。""",
|
||||||
|
|
||||||
|
"爱问细节": """你是个很谨慎的买家,买东西前喜欢问清楚。
|
||||||
|
行为:先问在不在,问能不能找到,问格式,问效果,问不满意能退吗,最后才考虑下单。
|
||||||
|
说话有点啰嗦,喜欢多问几个问题。""",
|
||||||
|
|
||||||
|
"快节奏": """你是个很忙的买家,说话简短,不废话。
|
||||||
|
行为:直接发图问多少钱,价格合适立刻说拍了,不合适直接走。
|
||||||
|
每次只说3-5个字,极度简短。""",
|
||||||
|
|
||||||
|
"售后投诉": """你是个已经付款的买家,但对收到的图片不满意,想要退款或重做。
|
||||||
|
行为:先说拿到图了但效果不行,描述哪里不满意(模糊/颜色不对/尺寸不够),
|
||||||
|
要求重做或者退款,态度有点不耐烦,反复追问进度。
|
||||||
|
说话带点情绪,但不至于骂人,就是那种"花钱了结果不行"的委屈感。""",
|
||||||
|
|
||||||
|
"多图打包": """你是个需要批量处理图片的买家,手头有好几张图要找高清版。
|
||||||
|
行为:先问在不在,然后说有多张图要处理,询问能不能打包便宜点,
|
||||||
|
逐步发图或询问价格,跟客服商量总价,最终决定下单还是再想想。
|
||||||
|
说话随意,像在商量事情,不太在意每张的价格,更关注总价合不合适。""",
|
||||||
|
|
||||||
|
"大批量砍价": """你是个手头有十几张图要处理的买家,量大,觉得自己有底气砍价。
|
||||||
|
行为:上来就说"我有很多图,你们量大能便宜吗",
|
||||||
|
告知大概数量(10-20张),用量大作为筹码反复压价,
|
||||||
|
问"做完这批还有下一批,能不能给个长期价""别家给我打6折了",
|
||||||
|
如果客服给的总价还算合理就下单,否则拉锯几个来回。
|
||||||
|
说话有点强势,觉得自己是大客户,应该享受优惠。""",
|
||||||
|
|
||||||
|
"要分层PSD": """你是个做设计的买家,需要带分层的PSD格式,不是普通jpg。
|
||||||
|
行为:先问在不在,发图后问"这个能出PSD吗""要分层的那种",
|
||||||
|
追问能不能保留图层、格式是不是真的PSD,听到价格后可能嫌贵问能不能便宜,
|
||||||
|
最终决定下单或者放弃。
|
||||||
|
说话带点专业感,会说"图层""分层""PSD""源文件"之类的词。""",
|
||||||
|
|
||||||
|
"问尺寸分辨率": """你是个有印刷需求的买家,非常在意图片的尺寸和分辨率。
|
||||||
|
行为:发图后先不问价,反复问"这个能做多大""分辨率能到300dpi吗""印出来会不会模糊",
|
||||||
|
问完尺寸再问价格,如果客服说能满足需求就考虑下单,否则犹豫很久。
|
||||||
|
说话里经常出现"厘米""像素""dpi""印刷""大图"。""",
|
||||||
|
|
||||||
|
"问颜色效果": """你是个对颜色很挑剔的买家,担心高清版颜色和原图有色差。
|
||||||
|
行为:发图后问"颜色会变吗""饱和度能保持吗""跟原图一样的色调吧",
|
||||||
|
让客服保证颜色效果,追问不满意能不能改,反复确认才肯下单。
|
||||||
|
说话谨慎,总要客服给"保证",但说话不凶,就是很在意品质。""",
|
||||||
|
|
||||||
|
"来源质疑": """你是个对店铺服务有疑虑的买家,总觉得有猫腻,想搞清楚原理。
|
||||||
|
行为:问"你们是怎么找的""原图是从哪里来的""是AI弄的吗还是真的原图",
|
||||||
|
追问来源和方法,对客服的回答将信将疑,继续追问,
|
||||||
|
最终可能被说服下单,也可能觉得不靠谱走人。
|
||||||
|
说话带点怀疑,喜欢反问,但不是来找茬的,是真的想搞清楚。""",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 用于测试的图片URL
|
||||||
|
TEST_IMAGE_URLS = [
|
||||||
|
"https://img.alicdn.com/imgextra/i1/O1CN01OilxfD1kr8tM4Ugg2_!!4611686018427387680-0-amp.jpg",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BuyerAgent:
|
||||||
|
"""模拟买家的AI"""
|
||||||
|
|
||||||
|
def __init__(self, persona_name: str = "普通买家"):
|
||||||
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=os.getenv("OPENAI_API_KEY"),
|
||||||
|
base_url=os.getenv("OPENAI_BASE_URL"),
|
||||||
|
)
|
||||||
|
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||||
|
self.persona_name = persona_name
|
||||||
|
self.persona = BUYER_PERSONAS.get(persona_name, BUYER_PERSONAS["普通买家"])
|
||||||
|
self.history = []
|
||||||
|
self.image_sent = False
|
||||||
|
self.rounds = 0
|
||||||
|
|
||||||
|
async def next_message(self, shop_reply: str = None) -> str:
|
||||||
|
"""根据客服回复,生成下一条买家消息"""
|
||||||
|
self.rounds += 1
|
||||||
|
|
||||||
|
if shop_reply:
|
||||||
|
self.history.append({"role": "assistant", "content": f"客服说:{shop_reply}"})
|
||||||
|
|
||||||
|
# 第一轮:打招呼或直接发问
|
||||||
|
if self.rounds == 1:
|
||||||
|
first_msgs = ["在不在", "在吗", "你好", "有人吗", "亲在吗"]
|
||||||
|
msg = random.choice(first_msgs)
|
||||||
|
self.history.append({"role": "user", "content": msg})
|
||||||
|
return msg
|
||||||
|
|
||||||
|
# 第二轮:发图+问有没有
|
||||||
|
if self.rounds == 2 and not self.image_sent:
|
||||||
|
self.image_sent = True
|
||||||
|
url = random.choice(TEST_IMAGE_URLS)
|
||||||
|
msg = f"有吗#*#{url}"
|
||||||
|
self.history.append({"role": "user", "content": msg})
|
||||||
|
return msg
|
||||||
|
|
||||||
|
# 后续:让AI根据对话历史自由生成
|
||||||
|
system = f"""{self.persona}
|
||||||
|
|
||||||
|
你正在和一家找图店的客服聊天,对话历史如下。
|
||||||
|
请根据你的人设生成下一条消息,简短自然,像真人发消息。
|
||||||
|
如果对话已经快结束(超过10轮),可以说"好的拍了"或"算了不要了"结束对话。
|
||||||
|
只输出你要发的消息内容,不要加任何前缀说明。"""
|
||||||
|
|
||||||
|
resp = await self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
*self.history,
|
||||||
|
{"role": "user", "content": "(请生成你的下一条消息)"}
|
||||||
|
],
|
||||||
|
max_tokens=80,
|
||||||
|
temperature=0.9,
|
||||||
|
)
|
||||||
|
msg = resp.choices[0].message.content.strip()
|
||||||
|
self.history.append({"role": "user", "content": msg})
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
async def run_battle(persona_name: str = "普通买家", max_rounds: int = 8):
|
||||||
|
"""运行一场对话模拟"""
|
||||||
|
|
||||||
|
print_sep()
|
||||||
|
print_system(f" 开始模拟 | 买家人设:{persona_name} | 最多 {max_rounds} 轮")
|
||||||
|
print_sep()
|
||||||
|
|
||||||
|
# 初始化双方
|
||||||
|
buyer = BuyerAgent(persona_name)
|
||||||
|
shop = CustomerServiceAgent()
|
||||||
|
|
||||||
|
buyer_id = "test_buyer_001"
|
||||||
|
shop_id = "test_shop_001"
|
||||||
|
|
||||||
|
shop_reply = None
|
||||||
|
|
||||||
|
for i in range(max_rounds):
|
||||||
|
# 买家发消息
|
||||||
|
buyer_msg = await buyer.next_message(shop_reply)
|
||||||
|
print_buyer(buyer_msg)
|
||||||
|
|
||||||
|
# 判断对话是否结束
|
||||||
|
end_words = ["算了", "不要了", "不买了", "拍了", "好的拍了", "下单了", "谢谢"]
|
||||||
|
if any(w in buyer_msg for w in end_words):
|
||||||
|
print_system(f"\n 对话结束(第 {i+1} 轮)")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 构建消息对象
|
||||||
|
customer_msg = CustomerMessage(
|
||||||
|
msg_id=f"test_{i}",
|
||||||
|
acc_id=shop_id,
|
||||||
|
msg=buyer_msg,
|
||||||
|
from_id=buyer_id,
|
||||||
|
from_name="测试买家",
|
||||||
|
cy_id=buyer_id,
|
||||||
|
acc_type="AliWorkbench",
|
||||||
|
msg_type=0,
|
||||||
|
cy_name="测试买家",
|
||||||
|
goods_name="模糊图清晰处理专业代找原图素材淘宝图片找图修复服务",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 含图片URL时先打印等待语(模拟真实客服的即时回应)
|
||||||
|
if "#*#" in buyer_msg or (buyer_msg.startswith(("http://", "https://")) and any(
|
||||||
|
h in buyer_msg for h in ("alicdn.com", "imgextra", ".jpg", ".jpeg", ".png")
|
||||||
|
)):
|
||||||
|
print_shop("我找一下看看")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 客服AI处理
|
||||||
|
response = await shop.process_message(customer_msg)
|
||||||
|
|
||||||
|
if response.need_transfer:
|
||||||
|
print_shop("[转人工]")
|
||||||
|
print_system("\n 客服决定转人工,对话结束")
|
||||||
|
break
|
||||||
|
|
||||||
|
shop_reply = response.reply if response.should_reply else None
|
||||||
|
|
||||||
|
# 过滤 AI 误输出的内部独白(与生产环境保持一致)
|
||||||
|
nonsense_patterns = [
|
||||||
|
"无需", "流程已完成", "不需要回复", "无需额外", "已完成",
|
||||||
|
"无需回复", "不需要额外", "已经完成", "无需再", "操作已完成",
|
||||||
|
"任务完成", "流程完成", "记录完成", "报价已",
|
||||||
|
]
|
||||||
|
if shop_reply and any(p in shop_reply for p in nonsense_patterns):
|
||||||
|
print_system(f" [已拦截无效回复: {shop_reply}]")
|
||||||
|
shop_reply = None
|
||||||
|
|
||||||
|
if shop_reply:
|
||||||
|
print_shop(shop_reply)
|
||||||
|
else:
|
||||||
|
print_system(" (客服决定不回复)")
|
||||||
|
shop_reply = ""
|
||||||
|
|
||||||
|
print()
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
print_sep()
|
||||||
|
print_system(" 模拟结束")
|
||||||
|
print_sep()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
import sys
|
||||||
|
|
||||||
|
personas = list(BUYER_PERSONAS.keys())
|
||||||
|
|
||||||
|
print(f"\n{YELLOW}{'=' * 50}")
|
||||||
|
print(" AI 左右互搏测试工具")
|
||||||
|
print(f"{'=' * 50}{RESET}")
|
||||||
|
print(f"{GRAY}用法: python test_battle.py [人设编号|all]")
|
||||||
|
print(f" 1=普通买家 2=砍价客 3=爱问细节 4=快节奏")
|
||||||
|
print(f" 5=售后投诉 6=多图打包 7=大批量砍价 8=要分层PSD")
|
||||||
|
print(f" 9=问尺寸分辨率 10=问颜色效果 11=来源质疑 0=全部{RESET}\n")
|
||||||
|
|
||||||
|
arg = sys.argv[1] if len(sys.argv) > 1 else "1"
|
||||||
|
|
||||||
|
if arg == "0" or arg == "all":
|
||||||
|
for persona in personas:
|
||||||
|
await run_battle(persona, max_rounds=14)
|
||||||
|
print()
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
idx = int(arg) - 1
|
||||||
|
persona = personas[idx] if 0 <= idx < len(personas) else personas[0]
|
||||||
|
except ValueError:
|
||||||
|
persona = personas[0]
|
||||||
|
await run_battle(persona, max_rounds=14)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except Exception as e:
|
||||||
|
err_str = str(e)
|
||||||
|
if "AccountOverdueError" in err_str or "overdue" in err_str.lower():
|
||||||
|
msg = "⚠️ 火山引擎 API 欠费,请立即充值!\n地址:https://console.volcengine.com/ark"
|
||||||
|
else:
|
||||||
|
msg = f"⚠️ 测试脚本异常退出:{err_str[:200]}"
|
||||||
|
print(f"\n[通知] {msg}")
|
||||||
|
asyncio.run(notify_wechat(msg))
|
||||||
|
raise
|
||||||
13
archive/test_import.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pydantic_ai_agent import CustomerServiceAgent, CustomerMessage
|
||||||
|
print("✓ Agent 模块导入成功")
|
||||||
|
|
||||||
|
# 尝试初始化
|
||||||
|
agent = CustomerServiceAgent()
|
||||||
|
print("✓ Agent 初始化成功")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 导入或初始化失败: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
119
archive/view_chats.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
聊天记录查看工具
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python view_chats.py # 列出所有客户
|
||||||
|
python view_chats.py <客户ID> # 查看某客户的全部对话
|
||||||
|
python view_chats.py <客户ID> --today # 只看今天
|
||||||
|
python view_chats.py --search <关键词> # 全文搜索
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from chat_log_db import get_customers, get_conversation, get_conversation_today, search_messages
|
||||||
|
|
||||||
|
# ANSI 颜色
|
||||||
|
GREEN = "\033[92m"
|
||||||
|
BLUE = "\033[94m"
|
||||||
|
YELLOW = "\033[93m"
|
||||||
|
GRAY = "\033[90m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
|
||||||
|
|
||||||
|
def list_customers():
|
||||||
|
customers = get_customers(limit=200)
|
||||||
|
if not customers:
|
||||||
|
print("暂无聊天记录")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{BOLD}{'='*60}{RESET}")
|
||||||
|
print(f"{BOLD} 客户聊天记录总览 共 {len(customers)} 位客户{RESET}")
|
||||||
|
print(f"{BOLD}{'='*60}{RESET}")
|
||||||
|
print(f" {'客户ID':<22} {'昵称':<12} {'平台':<15} {'消息数':>6} {'最后联系'}")
|
||||||
|
print(f" {'-'*22} {'-'*12} {'-'*15} {'-'*6} {'-'*19}")
|
||||||
|
|
||||||
|
for c in customers:
|
||||||
|
cid = c["customer_id"][:20]
|
||||||
|
name = (c["customer_name"] or "未知")[:10]
|
||||||
|
plat = (c["platform"] or "未知")[:13]
|
||||||
|
total = c["total_msgs"]
|
||||||
|
last = c["last_time"][:16]
|
||||||
|
print(f" {YELLOW}{cid:<22}{RESET} {name:<12} {GRAY}{plat:<15}{RESET} {total:>6}条 {last}")
|
||||||
|
|
||||||
|
print(f"\n{GRAY}查看某客户对话:python view_chats.py <客户ID>{RESET}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def show_conversation(customer_id: str, today_only: bool = False):
|
||||||
|
if today_only:
|
||||||
|
records = get_conversation_today(customer_id)
|
||||||
|
label = "今日对话"
|
||||||
|
else:
|
||||||
|
records = get_conversation(customer_id, limit=300)
|
||||||
|
label = "全部对话"
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
print(f" {GRAY}暂无记录{RESET}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{BOLD}{'='*60}{RESET}")
|
||||||
|
print(f"{BOLD} 客户:{customer_id} {label} 共 {len(records)} 条{RESET}")
|
||||||
|
print(f"{BOLD}{'='*60}{RESET}\n")
|
||||||
|
|
||||||
|
last_date = ""
|
||||||
|
for r in records:
|
||||||
|
ts = r["timestamp"]
|
||||||
|
date = ts[:10]
|
||||||
|
time = ts[11:16]
|
||||||
|
msg = r["message"]
|
||||||
|
direction = r["direction"]
|
||||||
|
|
||||||
|
if date != last_date:
|
||||||
|
print(f" {GRAY}── {date} ──────────────────────────────{RESET}")
|
||||||
|
last_date = date
|
||||||
|
|
||||||
|
if direction == "in":
|
||||||
|
# 客户消息,左对齐蓝色
|
||||||
|
print(f" {GRAY}{time}{RESET} {BLUE}【客户】{RESET} {msg}")
|
||||||
|
else:
|
||||||
|
# 客服回复,右对齐绿色
|
||||||
|
print(f" {GRAY}{time}{RESET} {GREEN}【客服】{RESET} {msg}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def show_search(keyword: str):
|
||||||
|
results = search_messages(keyword, limit=50)
|
||||||
|
if not results:
|
||||||
|
print(f" 未找到包含"{keyword}"的消息")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{BOLD}搜索:"{keyword}" 共 {len(results)} 条结果{RESET}\n")
|
||||||
|
for r in results:
|
||||||
|
direction = "客户" if r["direction"] == "in" else "客服"
|
||||||
|
color = BLUE if r["direction"] == "in" else GREEN
|
||||||
|
print(f" {GRAY}{r['timestamp'][:16]}{RESET} {YELLOW}{r['customer_id'][:20]}{RESET} {color}[{direction}]{RESET} {r['message']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="查看按用户分开的聊天记录",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
parser.add_argument("customer_id", nargs="?", help="客户ID(不填则列出所有客户)")
|
||||||
|
parser.add_argument("--today", action="store_true", help="只显示今天的对话")
|
||||||
|
parser.add_argument("--search", metavar="关键词", help="全文搜索所有消息")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.search:
|
||||||
|
show_search(args.search)
|
||||||
|
elif args.customer_id:
|
||||||
|
show_conversation(args.customer_id, today_only=args.today)
|
||||||
|
else:
|
||||||
|
list_customers()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
24
archive/viewer_out.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
[1m[36m────────────────────────────────────────────────────────────[0m
|
||||||
|
[1m[36m 对话记录 test_user_001 (4 条)[0m
|
||||||
|
[1m[36m────────────────────────────────────────────────────────────[0m
|
||||||
|
|
||||||
|
[2m──────────────────── 2026-02-25 ────────────────────[0m
|
||||||
|
[2m17:44[0m [97m买家[0m
|
||||||
|
[48;5;236m 你好,想问下图片处理多少钱 [0m
|
||||||
|
|
||||||
|
[2m17:44[0m [32m客服[0m
|
||||||
|
python : Traceback (most recent call last):
|
||||||
|
所在位置 C:\Users\jimi\AppData\Local\Temp\ps-script-7c6a5466-18ca-4772-ac44-a07a3d10df05.ps1:75 字符: 19
|
||||||
|
+ ... d d:\Terminator; python chat_log_viewer.py test_user_001 2>&1 | Out-F ...
|
||||||
|
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
+ CategoryInfo : NotSpecified: (Traceback (most recent call last)::String) [], RemoteException
|
||||||
|
+ FullyQualifiedErrorId : NativeCommandError
|
||||||
|
|
||||||
|
File "D:\Terminator\chat_log_viewer.py", line 233, in <module>
|
||||||
|
cmd_show_conversation(args[0])
|
||||||
|
File "D:\Terminator\chat_log_viewer.py", line 127, in cmd_show_conversation
|
||||||
|
print_bubble(m["direction"], m["message"], ts)
|
||||||
|
File "D:\Terminator\chat_log_viewer.py", line 80, in print_bubble
|
||||||
|
print(f" {GREEN}\u25b6 {line}{RESET}")
|
||||||
|
UnicodeEncodeError: 'gbk' codec can't encode character '\u25b6' in position 9: illegal multibyte sequence
|
||||||
BIN
chat_log_db/chats.db
Normal file
9
config/.api_cost.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"daily": {
|
||||||
|
"2026-02-26": 1.4850000000000005,
|
||||||
|
"2026-02-27": 1.3200000000000007
|
||||||
|
},
|
||||||
|
"monthly": {
|
||||||
|
"2026-02": 2.8050000000000015
|
||||||
|
}
|
||||||
|
}
|
||||||
10
config/DESIGNER_ROSTER_API.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 设计师在线查询 API(本端接入)
|
||||||
|
|
||||||
|
- **地址**:`.env` 中 `DESIGNER_ROSTER_API`,如 `http://huichang.online:8001/online`
|
||||||
|
- **方法**:GET
|
||||||
|
- **返回**:`{online_users: ["lz", "ZuoWei"], ...}`,本端用 `online_users` 同步本地后派单
|
||||||
|
|
||||||
|
## 调用时机
|
||||||
|
|
||||||
|
- **转人工时**按需 GET 一次,不轮询
|
||||||
|
- 无人在线时:回退到 `transfer_groups.json` 静态配置,并发送企微「谁在线啊」提醒
|
||||||
33
config/DESIGNER_ROSTER_需求-给另一台AI.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 设计师在线查询服务 - 需求(给另一台 AI)
|
||||||
|
|
||||||
|
## 你要做的
|
||||||
|
|
||||||
|
建库,从企微群解析「上线」「下线」消息并存储,提供 **GET 接口** 返回当前在线设计师名单。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
- **方法**:GET
|
||||||
|
- **路径**:如 `/online`
|
||||||
|
|
||||||
|
## 返回格式(本端已适配)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"online_count": 2,
|
||||||
|
"online_users": ["lz", "ZuoWei"],
|
||||||
|
"update_time": "2026-02-26 16:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| online_users | 是 | 当前在线设计师名单,对应 init_designer_roster 的 wechat_user_id |
|
||||||
|
|
||||||
|
## 你这边的逻辑
|
||||||
|
|
||||||
|
1. 企微群消息 → 解析「上线」/「下线」→ 存库
|
||||||
|
2. 接口从库查当前在线名单,按 `online_users` 返回
|
||||||
|
|
||||||
|
## 调用方
|
||||||
|
|
||||||
|
本端在**转人工时**按需 GET 一次,用 `online_users` 同步本地后派单。无人在线时发企微「谁在线啊」。
|
||||||
36
config/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 配置文件
|
||||||
|
|
||||||
|
## transfer_groups.json
|
||||||
|
|
||||||
|
店铺 → 转接分组映射(静态)。不同店铺(acc_id)对应不同客服分组。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default": "20252916034",
|
||||||
|
"小威哥1216": "20252916034",
|
||||||
|
"另一店铺": "12345678"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **default**:未配置的店铺使用的默认分组 ID
|
||||||
|
- **其他 key**:店铺 `acc_id`
|
||||||
|
- **value**:该店铺转接时使用的分组 ID
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计师派单(SQLite,可选)
|
||||||
|
|
||||||
|
同一设计师在不同店铺对应不同 group_id,转人工时按需查询在线状态,从在线设计师中轮询派单。
|
||||||
|
|
||||||
|
**初始化数据**:
|
||||||
|
```bash
|
||||||
|
python scripts/init_designer_roster.py example # 写入示例
|
||||||
|
python scripts/init_designer_roster.py list # 查看当前数据
|
||||||
|
```
|
||||||
|
|
||||||
|
**数据库**:`db/designer_roster_db/roster.db`
|
||||||
|
- `designers`:设计师(name, wechat_user_id)
|
||||||
|
- `designer_shops`:设计师在某店铺的 group_id(同一人不同店铺不同分组)
|
||||||
|
- `designer_online`:在线状态(转人工时按需查询外部 API 同步)
|
||||||
|
|
||||||
|
**接入**:`.env` 配置 `DESIGNER_ROSTER_API`(如 `http://xxx/online`),转人工时 GET 一次,用 `online_users` 同步。无人在线时发企微「谁在线啊」。
|
||||||
1
config/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
BIN
config/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
config/__pycache__/config.cpython-310.pyc
Normal file
49
config/config.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
配置中心 - 统一管理路径、常量
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ========== 项目路径 ==========
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
LOG_DIR = ROOT / "logs"
|
||||||
|
RESULTS_DIR = Path(os.getenv("RESULT_IMAGE_DIR", str(ROOT / "results")))
|
||||||
|
CONFIG_DIR = ROOT / "config"
|
||||||
|
CUSTOMER_DB_DIR = ROOT / "customer_db"
|
||||||
|
|
||||||
|
# ========== 轻简 ==========
|
||||||
|
QINGJIAN_WS_URI = os.getenv("QINGJIAN_WS_URI", "ws://127.0.0.1:9528")
|
||||||
|
|
||||||
|
# ========== 企微 ==========
|
||||||
|
WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
|
||||||
|
|
||||||
|
# ========== 日志 ==========
|
||||||
|
LOG_MAX_BYTES = int(os.getenv("LOG_MAX_BYTES", "10")) * 1024 * 1024 # 默认 10MB
|
||||||
|
LOG_BACKUP_COUNT = int(os.getenv("LOG_BACKUP_COUNT", "7"))
|
||||||
|
|
||||||
|
# ========== 图片队列 ==========
|
||||||
|
IMAGE_QUEUE_MAX_CONCURRENT = int(os.getenv("IMAGE_QUEUE_MAX_CONCURRENT", "2"))
|
||||||
|
IMAGE_QUEUE_MAX_SIZE = int(os.getenv("IMAGE_QUEUE_MAX_SIZE", "20"))
|
||||||
|
|
||||||
|
# ========== 健康检查 ==========
|
||||||
|
HEALTH_CHECK_INTERVAL = int(os.getenv("HEALTH_CHECK_INTERVAL", "60")) # 秒
|
||||||
|
HEALTH_CHECK_WECHAT_PING = os.getenv("HEALTH_CHECK_WECHAT_PING", "false").lower() in ("1", "true", "yes")
|
||||||
|
HEALTH_CHECK_STARTUP_GRACE = int(os.getenv("HEALTH_CHECK_STARTUP_GRACE", "15"))
|
||||||
|
HEALTH_CHECK_QINGJIAN_ALERTS_ENABLED = os.getenv("HEALTH_CHECK_QINGJIAN_ALERTS_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
# ========== 功能开关 ==========
|
||||||
|
IMAGE_MODULE_ENABLED = os.getenv("IMAGE_MODULE_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
# ========== 防抖配置 ==========
|
||||||
|
MESSAGE_DEBOUNCE_SECONDS = int(os.getenv("MESSAGE_DEBOUNCE_SECONDS", "8"))
|
||||||
|
|
||||||
|
# ========== AI 上下文加载 ==========
|
||||||
|
CHAT_CONTEXT_LIMIT = int(os.getenv("CHAT_CONTEXT_LIMIT", "30"))
|
||||||
|
CHAT_CONTEXT_TRUNCATE_LEN = int(os.getenv("CHAT_CONTEXT_TRUNCATE_LEN", "160"))
|
||||||
|
|
||||||
|
# ========== 报价底线 ==========
|
||||||
|
MIN_PRICE_FLOOR = int(os.getenv("MIN_PRICE_FLOOR", "15"))
|
||||||
27
config/shop_prompts.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"shops": {
|
||||||
|
"tb2801080146": {
|
||||||
|
"type": "gemini_api",
|
||||||
|
"hint": "【店铺类型】Gemini API 账号。客户问账号/pro/续费/没pro时,按API客服回复:续费/充值/套餐说明。"
|
||||||
|
},
|
||||||
|
"小威哥1216": {
|
||||||
|
"type": "find_image",
|
||||||
|
"hint": "【店铺类型】找原图/修图。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goods_keywords": {
|
||||||
|
"gemini": "gemini_api",
|
||||||
|
"pro": "gemini_api",
|
||||||
|
"nano": "gemini_api",
|
||||||
|
"api": "gemini_api",
|
||||||
|
"找原图": "find_image",
|
||||||
|
"修图": "find_image",
|
||||||
|
"模糊图": "find_image",
|
||||||
|
"清晰处理": "find_image"
|
||||||
|
},
|
||||||
|
"type_hints": {
|
||||||
|
"gemini_api": "【店铺类型】Gemini API 账号。客户问账号/pro/续费/没pro时,按API客服回复:续费/充值/套餐说明,自然回复。",
|
||||||
|
"find_image": "【店铺类型】找原图/修图。"
|
||||||
|
},
|
||||||
|
"_comment": "新增店铺:在 shops 加 acc_id。新增商品类型:在 goods_keywords 加关键词→类型。"
|
||||||
|
}
|
||||||
4
config/transfer_groups.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"default": "20252916034",
|
||||||
|
"小威哥1216": "20252916034"
|
||||||
|
}
|
||||||
0
core/__init__.py
Normal file
BIN
core/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
core/__pycache__/pydantic_ai_agent.cpython-310.pyc
Normal file
BIN
core/__pycache__/websocket_client.cpython-310.pyc
Normal file
BIN
core/__pycache__/workflow.cpython-310.pyc
Normal file
1797
core/pydantic_ai_agent.py
Normal file
1305
core/websocket_client.py
Normal file
616
core/workflow.py
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
"""
|
||||||
|
客服工作流 + 图片任务状态机
|
||||||
|
|
||||||
|
架构说明:
|
||||||
|
- CustomerServiceWorkflow 负责管理图片处理任务的完整生命周期
|
||||||
|
- 图片AI接入点:调用 workflow.image_ai_submit_result(task_id, result_url)
|
||||||
|
- 消息回调接口:通过 register_send_callback 注入发送函数
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Dict, Callable, Awaitable, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
_WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
print(f"[Workflow通知] 企业微信推送成功 ✓")
|
||||||
|
else:
|
||||||
|
print(f"[Workflow通知] 企业微信推送失败: {data}")
|
||||||
|
except Exception as e:
|
||||||
|
print(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)
|
||||||
|
|
||||||
|
print(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,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(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:
|
||||||
|
# 内存任务丢失(重启场景)→ 从客户档案重建
|
||||||
|
print(f"[Workflow] 付款触发:内存无任务,尝试从客户档案重建 | 客户: {customer_id}")
|
||||||
|
task = await self._rebuild_task_from_profile(customer_id, acc_id, acc_type)
|
||||||
|
if not task:
|
||||||
|
print(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,):
|
||||||
|
print(f"[Workflow] 付款触发:任务 {task.task_id[:8]}... 状态={task.status.value},跳过")
|
||||||
|
return False
|
||||||
|
|
||||||
|
task.operation = task.operation or "enhance"
|
||||||
|
print(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
|
||||||
|
print(f"[Workflow] 任务已重建 | 客户: {customer_id} | 图片: {image_url[:60]}...")
|
||||||
|
return task
|
||||||
|
except Exception as e:
|
||||||
|
print(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}"
|
||||||
|
|
||||||
|
print(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", "")
|
||||||
|
print(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']
|
||||||
|
print(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:
|
||||||
|
print(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:
|
||||||
|
print(f"[Workflow] 任务不存在: {task_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
task.result_url = result_url
|
||||||
|
task.update_status(TaskStatus.AWAITING_CONFIRM)
|
||||||
|
|
||||||
|
print(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:
|
||||||
|
print(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)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 全局实例 ==========
|
||||||
|
workflow = CustomerServiceWorkflow()
|
||||||
3810
customer_db/customers.json
Normal file
91
customer_db/schema.json
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"_注释": "本文件是 customers.json 的字段说明,不参与程序运行,仅供人工查阅。",
|
||||||
|
"字段说明": {
|
||||||
|
"customer_id": "客户ID(平台用户名/ID)",
|
||||||
|
"name": "真实姓名",
|
||||||
|
"nickname": "昵称",
|
||||||
|
"email": "邮箱",
|
||||||
|
"phone": "手机号",
|
||||||
|
"wechat": "微信号",
|
||||||
|
"address": "收货地址",
|
||||||
|
"platform": "来源平台(淘宝/拼多多/微信等)",
|
||||||
|
"platform_id": "平台内部ID",
|
||||||
|
|
||||||
|
"budget": "预算描述(客户自述)",
|
||||||
|
"budget_range_min": "预算下限(元)",
|
||||||
|
"budget_range_max": "预算上限(元)",
|
||||||
|
"requirements": "历史需求列表",
|
||||||
|
"preference_services": "偏好服务类型列表",
|
||||||
|
|
||||||
|
"total_orders": "累计下单次数",
|
||||||
|
"total_spent": "累计消费金额(元)",
|
||||||
|
"avg_order_value": "平均客单价(元)",
|
||||||
|
"purchase_frequency": "购买频次描述",
|
||||||
|
"last_order_date": "最后下单日期",
|
||||||
|
"first_order_date": "首次下单日期",
|
||||||
|
"order_ids": "订单号列表",
|
||||||
|
"pending_orders": "待处理订单数",
|
||||||
|
"completed_orders": "已完成订单数",
|
||||||
|
"refund_count": "退款次数",
|
||||||
|
|
||||||
|
"personality": "性格标签列表(如:急躁/爽快/纠结)",
|
||||||
|
"communication_prefer": "沟通偏好",
|
||||||
|
"response_speed": "回复速度描述",
|
||||||
|
"patience_level": "耐心程度",
|
||||||
|
|
||||||
|
"customer_level": "客户等级(A/B/C/D)",
|
||||||
|
"vip": "是否VIP",
|
||||||
|
"vip_level": "VIP等级(0=无)",
|
||||||
|
"vip_custom_price": "VIP专属报价(0=无专属价)",
|
||||||
|
|
||||||
|
"last_price": "上次报价金额(元)",
|
||||||
|
"last_price_time": "上次报价时间",
|
||||||
|
"last_quote_no_convert": "上次报价后未成交,下次可适当降低",
|
||||||
|
"lowest_price_accepted":"历史接受过的最低价(元)",
|
||||||
|
"discount_given_count": "累计让价次数",
|
||||||
|
"price_sensitivity": "价格敏感度(高/中/低,自动计算)",
|
||||||
|
"decision_speed": "决策速度(快/慢,自动标记)",
|
||||||
|
|
||||||
|
"good_reviews": "好评次数",
|
||||||
|
"bad_reviews": "差评次数",
|
||||||
|
"dispute_count": "纠纷次数",
|
||||||
|
|
||||||
|
"follow_up_by": "跟进负责人",
|
||||||
|
"follow_up_date": "跟进日期",
|
||||||
|
"next_follow_date": "下次跟进日期",
|
||||||
|
"source": "客户来源渠道",
|
||||||
|
"coupon_used": "使用过的优惠券",
|
||||||
|
|
||||||
|
"notes": "备注列表(含自动报价记录)",
|
||||||
|
"tags": "自定义标签列表",
|
||||||
|
"created_at": "建档时间",
|
||||||
|
"last_contact": "最后联系时间",
|
||||||
|
"last_update": "最后更新时间",
|
||||||
|
|
||||||
|
"last_image_url": "最后发来的图片URL",
|
||||||
|
"last_image_time": "最后发图时间",
|
||||||
|
"processing_status": "当前作图状态(pending/processing/done等)",
|
||||||
|
"processing_image_url": "当前处理中的图片URL",
|
||||||
|
"expected_done_at": "预计完成时间",
|
||||||
|
|
||||||
|
"preferred_format": "偏好文件格式(jpg/png/psd等)",
|
||||||
|
"preferred_size": "偏好尺寸/分辨率描述",
|
||||||
|
"total_images_sent": "历史发图总数",
|
||||||
|
"complexity_history": "历史图片复杂度列表(normal/complex/hard)",
|
||||||
|
"image_type_history": "历史图片类型列表(印花/logo/人物等)",
|
||||||
|
|
||||||
|
"bulk_potential": "批量潜力(有/无/未知)",
|
||||||
|
"churn_risk": "流失风险(高/中/低,自动计算)",
|
||||||
|
"upsell_opportunity": "加购机会标签(如:分层PSD/批量打包)",
|
||||||
|
"revision_count": "累计改稿次数",
|
||||||
|
"revision_orders": "有改稿的订单数",
|
||||||
|
"total_completed_orders":"已完成出图的订单数",
|
||||||
|
|
||||||
|
"blacklist": "是否黑名单",
|
||||||
|
"blacklist_reason": "拉黑原因",
|
||||||
|
"last_email_status": "最后邮件发送状态(sent/failed)",
|
||||||
|
|
||||||
|
"last_conversation_summary": "上次对话AI摘要(15字以内)",
|
||||||
|
"last_conversation_time": "上次对话时间"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
db/__init__.py
Normal file
BIN
db/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
db/__pycache__/chat_log_db.cpython-310.pyc
Normal file
BIN
db/__pycache__/customer_db.cpython-310.pyc
Normal file
BIN
db/__pycache__/deal_outcome_db.cpython-310.pyc
Normal file
BIN
db/__pycache__/designer_roster_db.cpython-310.pyc
Normal file
216
db/chat_log_db.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
聊天记录数据库(SQLite)
|
||||||
|
每条消息独立存储,按客户ID分开,支持查询和展示。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
_DB_PATH = os.path.join(os.path.dirname(__file__), "chat_log_db", "chats.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn() -> sqlite3.Connection:
|
||||||
|
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:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
customer_id TEXT NOT NULL,
|
||||||
|
customer_name TEXT DEFAULT '',
|
||||||
|
acc_id TEXT DEFAULT '',
|
||||||
|
platform TEXT DEFAULT '',
|
||||||
|
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
msg_type INTEGER DEFAULT 0,
|
||||||
|
timestamp TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_customer ON chat_logs(customer_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_ts ON chat_logs(timestamp)")
|
||||||
|
# 兼容旧表:若缺少 acc_id 列则补上(必须在创建该列索引之前)
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE chat_logs ADD COLUMN acc_id TEXT DEFAULT ''")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_acc ON chat_logs(acc_id)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 写入 ==========
|
||||||
|
|
||||||
|
def log_message(
|
||||||
|
customer_id: str,
|
||||||
|
message: str,
|
||||||
|
direction: str, # "in" = 客户发来,"out" = 客服回复
|
||||||
|
customer_name: str = "",
|
||||||
|
acc_id: str = "", # 店铺账号ID
|
||||||
|
platform: str = "",
|
||||||
|
msg_type: int = 0,
|
||||||
|
):
|
||||||
|
"""记录一条聊天消息"""
|
||||||
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
with _get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"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),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 查询 ==========
|
||||||
|
|
||||||
|
def get_customers(limit: int = 100) -> List[Dict]:
|
||||||
|
"""返回所有有记录的客户列表(按最新消息时间排序)"""
|
||||||
|
with _get_conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT
|
||||||
|
customer_id,
|
||||||
|
MAX(customer_name) AS customer_name,
|
||||||
|
MAX(platform) AS platform,
|
||||||
|
COUNT(*) AS total_msgs,
|
||||||
|
SUM(direction='in') AS recv,
|
||||||
|
SUM(direction='out') AS sent,
|
||||||
|
MAX(timestamp) AS last_time
|
||||||
|
FROM chat_logs
|
||||||
|
GROUP BY customer_id
|
||||||
|
ORDER BY last_time DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation(customer_id: str, limit: int = 200) -> List[Dict]:
|
||||||
|
"""返回某客户的全部对话记录(按时间升序)"""
|
||||||
|
with _get_conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT id, direction, message, msg_type, timestamp, acc_id
|
||||||
|
FROM chat_logs
|
||||||
|
WHERE customer_id = ?
|
||||||
|
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]:
|
||||||
|
"""返回某客户近期对话(同店铺),用于企微推送保持连贯"""
|
||||||
|
with _get_conn() as conn:
|
||||||
|
if acc_id:
|
||||||
|
rows = conn.execute("""
|
||||||
|
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("""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation_today(customer_id: str) -> List[Dict]:
|
||||||
|
"""返回某客户今天的对话"""
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
with _get_conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT id, direction, message, msg_type, timestamp
|
||||||
|
FROM chat_logs
|
||||||
|
WHERE customer_id = ? AND timestamp LIKE ?
|
||||||
|
ORDER BY timestamp ASC, id ASC
|
||||||
|
""", (customer_id, f"{today}%")).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_daily_stats(date: str = "") -> List[Dict]:
|
||||||
|
"""
|
||||||
|
返回指定日期各店铺的统计数据。
|
||||||
|
date 格式 'YYYY-MM-DD',默认今天。
|
||||||
|
每条记录对应一个 acc_id(店铺)。
|
||||||
|
"""
|
||||||
|
if not date:
|
||||||
|
date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
with _get_conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT
|
||||||
|
acc_id,
|
||||||
|
platform,
|
||||||
|
COUNT(DISTINCT customer_id) AS unique_customers,
|
||||||
|
COUNT(*) AS total_msgs,
|
||||||
|
SUM(direction='in') AS recv,
|
||||||
|
SUM(direction='out') AS sent,
|
||||||
|
MIN(timestamp) AS first_msg,
|
||||||
|
MAX(timestamp) AS last_msg
|
||||||
|
FROM chat_logs
|
||||||
|
WHERE timestamp LIKE ?
|
||||||
|
GROUP BY acc_id
|
||||||
|
ORDER BY unique_customers DESC
|
||||||
|
""", (f"{date}%",)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_daily_conversations(date: str = "") -> List[Dict]:
|
||||||
|
"""
|
||||||
|
返回指定日期每个客户的对话摘要(每人最多取前5条消息用于 AI 摘要)。
|
||||||
|
"""
|
||||||
|
if not date:
|
||||||
|
date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
with _get_conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT
|
||||||
|
acc_id,
|
||||||
|
customer_id,
|
||||||
|
MAX(customer_name) AS customer_name,
|
||||||
|
COUNT(*) AS msg_count,
|
||||||
|
GROUP_CONCAT(
|
||||||
|
CASE WHEN direction='in' THEN '买:' || SUBSTR(message,1,40)
|
||||||
|
ELSE '客:' || SUBSTR(message,1,40) END,
|
||||||
|
' | '
|
||||||
|
) AS snippet
|
||||||
|
FROM chat_logs
|
||||||
|
WHERE timestamp LIKE ?
|
||||||
|
GROUP BY acc_id, customer_id
|
||||||
|
ORDER BY acc_id, MAX(timestamp) DESC
|
||||||
|
""", (f"{date}%",)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def search_messages(keyword: str, customer_id: Optional[str] = None, limit: int = 50) -> List[Dict]:
|
||||||
|
"""全文搜索消息"""
|
||||||
|
if customer_id:
|
||||||
|
with _get_conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT customer_id, customer_name, direction, message, timestamp
|
||||||
|
FROM chat_logs
|
||||||
|
WHERE customer_id = ? AND message LIKE ?
|
||||||
|
ORDER BY timestamp DESC LIMIT ?
|
||||||
|
""", (customer_id, f"%{keyword}%", limit)).fetchall()
|
||||||
|
else:
|
||||||
|
with _get_conn() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT customer_id, customer_name, direction, message, timestamp
|
||||||
|
FROM chat_logs
|
||||||
|
WHERE message LIKE ?
|
||||||
|
ORDER BY timestamp DESC LIMIT ?
|
||||||
|
""", (f"%{keyword}%", limit)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
BIN
db/chat_log_db/chats.db
Normal file
729
db/customer_db.py
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
"""客户画像数据库"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CustomerProfile:
|
||||||
|
"""客户画像"""
|
||||||
|
# 基础信息
|
||||||
|
customer_id: str
|
||||||
|
name: str = ""
|
||||||
|
nickname: str = ""
|
||||||
|
email: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
wechat: str = "" # 微信号
|
||||||
|
address: str = "" # 收货地址
|
||||||
|
|
||||||
|
# 账号信息
|
||||||
|
platform: str = "" # 平台(淘宝/天猫/京东等)
|
||||||
|
platform_id: str = "" # 平台账号ID
|
||||||
|
|
||||||
|
# 需求相关
|
||||||
|
budget: str = "" # 预算(如:50-100元)
|
||||||
|
budget_range_min: float = 0 # 预算下限
|
||||||
|
budget_range_max: float = 0 # 预算上限
|
||||||
|
requirements: List[str] = None # 历史需求列表
|
||||||
|
preference_services: List[str] = None # 偏好服务类型(修图/找图/设计等)
|
||||||
|
|
||||||
|
# 消费分析
|
||||||
|
total_orders: int = 0
|
||||||
|
total_spent: float = 0.0
|
||||||
|
avg_order_value: float = 0.0 # 平均客单价
|
||||||
|
purchase_frequency: str = "" # 购买频次(高/中/低)
|
||||||
|
last_order_date: str = "" # 最后下单时间
|
||||||
|
first_order_date: str = "" # 首次下单时间
|
||||||
|
|
||||||
|
# 订单相关
|
||||||
|
order_ids: List[str] = None
|
||||||
|
pending_orders: int = 0 # 待处理订单
|
||||||
|
completed_orders: int = 0 # 已完成订单
|
||||||
|
refund_count: int = 0 # 退款次数
|
||||||
|
|
||||||
|
# 性格/行为
|
||||||
|
personality: List[str] = None # 性格标签(爽快/纠结/砍价狂/爽快/墨迹)
|
||||||
|
communication_prefer: str = "" # 沟通偏好(文字/语音/图片)
|
||||||
|
response_speed: str = "" # 响应速度偏好(快/慢)
|
||||||
|
patience_level: str = "" # 耐心程度(高/中/低)
|
||||||
|
|
||||||
|
# 客户价值
|
||||||
|
customer_level: str = "C" # 客户等级(A/B/C/D)
|
||||||
|
vip: bool = False
|
||||||
|
vip_level: int = 0 # VIP等级
|
||||||
|
|
||||||
|
# 报价记录
|
||||||
|
last_price: int = 0 # 上次报价
|
||||||
|
last_price_time: str = "" # 上次报价时间
|
||||||
|
last_quote_no_convert: bool = False # 上次报价后未成交,下次可适当降低
|
||||||
|
last_min_price: int = 0 # 最近图片分析的最低价
|
||||||
|
last_image_url: str = "" # 最近一次发来的图片URL
|
||||||
|
last_image_time: str = "" # 图片发送时间
|
||||||
|
last_gemini_prompt: str = "" # 最近一次图片的 Gemini 处理提示词
|
||||||
|
last_aspect_ratio: str = "1:1" # 最近一次图片的建议输出比例
|
||||||
|
last_perspective: str = "no" # 最近一次图片的透视状态
|
||||||
|
|
||||||
|
# 当前任务状态
|
||||||
|
processing_status: str = "" # 待处理/处理中/等待确认/已完成
|
||||||
|
processing_image_url: str = "" # 当前正在处理的图片URL
|
||||||
|
expected_done_at: str = "" # 预计完成时间
|
||||||
|
|
||||||
|
# 让价记录
|
||||||
|
discount_given_count: int = 0 # 历史累计让价次数
|
||||||
|
lowest_price_accepted: int = 0 # 客户接受过的最低价
|
||||||
|
|
||||||
|
# 格式偏好
|
||||||
|
preferred_format: str = "" # jpg / psd / png
|
||||||
|
preferred_size: str = "" # 有没有问过分辨率/尺寸要求
|
||||||
|
|
||||||
|
# 对话摘要
|
||||||
|
last_conversation_summary: str = "" # 上次对话摘要(一句话)
|
||||||
|
last_conversation_time: str = "" # 上次对话时间
|
||||||
|
|
||||||
|
# 来图习惯
|
||||||
|
total_images_sent: int = 0 # 历史发图总数
|
||||||
|
complexity_history: List[str] = None # 历史复杂度列表,如 ["hard","complex","normal"]
|
||||||
|
image_type_history: List[str] = None # 图片类型历史,如 ["印花","logo","人物"]
|
||||||
|
|
||||||
|
# AI 决策辅助
|
||||||
|
price_sensitivity: str = "" # 价格敏感度:高/中/低(自动计算)
|
||||||
|
decision_speed: str = "" # 决策速度:快/慢(自动标记)
|
||||||
|
revision_count: int = 0 # 历史总改稿次数
|
||||||
|
revision_orders: int = 0 # 有改稿的订单数
|
||||||
|
total_completed_orders: int = 0 # 已完成出图的订单数
|
||||||
|
|
||||||
|
# 业务分析
|
||||||
|
bulk_potential: str = "" # 批量潜力:有/无/未知
|
||||||
|
churn_risk: str = "" # 流失风险:高/中/低(自动计算)
|
||||||
|
upsell_opportunity: List[str] = None # 加购机会,如 ["分层PSD","批量打包"]
|
||||||
|
|
||||||
|
# 运营
|
||||||
|
blacklist: bool = False # 黑名单
|
||||||
|
blacklist_reason: str = "" # 拉黑原因
|
||||||
|
vip_custom_price: int = 0 # VIP专属报价(0=无)
|
||||||
|
last_email_status: str = "" # 最后一次邮件发送状态:sent/failed
|
||||||
|
|
||||||
|
# 评价
|
||||||
|
good_reviews: int = 0 # 好评数
|
||||||
|
bad_reviews: int = 0 # 差评数
|
||||||
|
dispute_count: int = 0 # 纠纷次数
|
||||||
|
|
||||||
|
# 跟进信息
|
||||||
|
follow_up_by: str = "" # 跟进销售
|
||||||
|
follow_up_date: str = ""
|
||||||
|
next_follow_date: str = "" # 下次跟进日期
|
||||||
|
|
||||||
|
# 来源
|
||||||
|
source: str = "" # 来源渠道(自然流量/推广/老客户转介绍)
|
||||||
|
coupon_used: str = "" # 使用过的优惠券
|
||||||
|
|
||||||
|
# 备注
|
||||||
|
notes: List[str] = None # 备注列表
|
||||||
|
tags: List[str] = None # 自定义标签
|
||||||
|
|
||||||
|
# 时间
|
||||||
|
created_at: str = ""
|
||||||
|
last_contact: str = ""
|
||||||
|
last_update: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.requirements is None:
|
||||||
|
self.requirements = []
|
||||||
|
if self.preference_services is None:
|
||||||
|
self.preference_services = []
|
||||||
|
if self.order_ids is None:
|
||||||
|
self.order_ids = []
|
||||||
|
if self.personality is None:
|
||||||
|
self.personality = []
|
||||||
|
if self.notes is None:
|
||||||
|
self.notes = []
|
||||||
|
if self.tags is None:
|
||||||
|
self.tags = []
|
||||||
|
if self.complexity_history is None:
|
||||||
|
self.complexity_history = []
|
||||||
|
if self.image_type_history is None:
|
||||||
|
self.image_type_history = []
|
||||||
|
if self.upsell_opportunity is None:
|
||||||
|
self.upsell_opportunity = []
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerDatabase:
|
||||||
|
"""客户数据库"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "customer_db"):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.customers_file = os.path.join(db_path, "customers.json")
|
||||||
|
self._ensure_db()
|
||||||
|
|
||||||
|
def _ensure_db(self):
|
||||||
|
if not os.path.exists(self.db_path):
|
||||||
|
os.makedirs(self.db_path)
|
||||||
|
if not os.path.exists(self.customers_file):
|
||||||
|
self._save_customers({})
|
||||||
|
|
||||||
|
def _load_customers(self) -> Dict[str, dict]:
|
||||||
|
try:
|
||||||
|
with open(self.customers_file, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_customers(self, customers: Dict[str, dict]):
|
||||||
|
with open(self.customers_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(customers, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def get_customer(self, customer_id: str) -> CustomerProfile:
|
||||||
|
customers = self._load_customers()
|
||||||
|
data = customers.get(customer_id, {})
|
||||||
|
# 确保不重复传递 customer_id
|
||||||
|
data.pop('customer_id', None)
|
||||||
|
return CustomerProfile(customer_id=customer_id, **data)
|
||||||
|
|
||||||
|
def save_customer(self, profile: CustomerProfile):
|
||||||
|
profile.last_update = datetime.now().isoformat()
|
||||||
|
customers = self._load_customers()
|
||||||
|
customers[profile.customer_id] = asdict(profile)
|
||||||
|
self._save_customers(customers)
|
||||||
|
|
||||||
|
# ========== 基础信息 ==========
|
||||||
|
def update_info(self, customer_id: str, **kwargs):
|
||||||
|
"""批量更新客户信息"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(profile, key):
|
||||||
|
setattr(profile, key, value)
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_email(self, customer_id: str, email: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.email = email
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_phone(self, customer_id: str, phone: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.phone = phone
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_wechat(self, customer_id: str, wechat: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.wechat = wechat
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_address(self, customer_id: str, address: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.address = address
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
# ========== 需求相关 ==========
|
||||||
|
def add_requirement(self, customer_id: str, requirement: str):
|
||||||
|
"""添加需求"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
if requirement not in profile.requirements:
|
||||||
|
profile.requirements.append(requirement)
|
||||||
|
profile.last_contact = datetime.now().isoformat()
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def set_budget(self, customer_id: str, budget: str, min_val: float = 0, max_val: float = 0):
|
||||||
|
"""设置预算"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.budget = budget
|
||||||
|
profile.budget_range_min = min_val
|
||||||
|
profile.budget_range_max = max_val
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def add_preference_service(self, customer_id: str, service: str):
|
||||||
|
"""添加偏好服务"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
if service not in profile.preference_services:
|
||||||
|
profile.preference_services.append(service)
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
# ========== 消费分析 ==========
|
||||||
|
def add_order(self, customer_id: str, order_id: str, amount: float = 0):
|
||||||
|
"""添加订单"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
|
||||||
|
if order_id not in profile.order_ids:
|
||||||
|
profile.order_ids.append(order_id)
|
||||||
|
profile.total_orders += 1
|
||||||
|
profile.total_spent += amount
|
||||||
|
|
||||||
|
# 计算平均客单价
|
||||||
|
if profile.total_orders > 0:
|
||||||
|
profile.avg_order_value = profile.total_spent / profile.total_orders
|
||||||
|
|
||||||
|
# 更新客单价等级
|
||||||
|
if profile.avg_order_value >= 100:
|
||||||
|
profile.customer_level = "A"
|
||||||
|
elif profile.avg_order_value >= 50:
|
||||||
|
profile.customer_level = "B"
|
||||||
|
elif profile.avg_order_value >= 20:
|
||||||
|
profile.customer_level = "C"
|
||||||
|
else:
|
||||||
|
profile.customer_level = "D"
|
||||||
|
|
||||||
|
profile.last_order_date = datetime.now().isoformat()
|
||||||
|
|
||||||
|
if not profile.first_order_date:
|
||||||
|
profile.first_order_date = datetime.now().isoformat()
|
||||||
|
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_order_status(self, customer_id: str, pending: int = None, completed: int = None):
|
||||||
|
"""更新订单状态"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
if pending is not None:
|
||||||
|
profile.pending_orders = pending
|
||||||
|
if completed is not None:
|
||||||
|
profile.completed_orders = completed
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def add_refund(self, customer_id: str):
|
||||||
|
"""增加退款次数"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.refund_count += 1
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
# ========== 性格分析 ==========
|
||||||
|
def add_personality_tag(self, customer_id: str, tag: str):
|
||||||
|
"""添加性格标签"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
if tag not in profile.personality:
|
||||||
|
profile.personality.append(tag)
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def set_communication_prefer(self, customer_id: str, prefer: str):
|
||||||
|
"""设置沟通偏好"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.communication_prefer = prefer
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def analyze_purchase_frequency(self, customer_id: str):
|
||||||
|
"""分析购买频次"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
|
||||||
|
if profile.first_order_date and profile.last_order_date:
|
||||||
|
try:
|
||||||
|
first = datetime.fromisoformat(profile.first_order_date)
|
||||||
|
last = datetime.fromisoformat(profile.last_order_date)
|
||||||
|
days = (last - first).days
|
||||||
|
|
||||||
|
if days == 0:
|
||||||
|
profile.purchase_frequency = "高"
|
||||||
|
elif days < 30:
|
||||||
|
profile.purchase_frequency = "高"
|
||||||
|
elif days < 90:
|
||||||
|
profile.purchase_frequency = "中"
|
||||||
|
else:
|
||||||
|
profile.purchase_frequency = "低"
|
||||||
|
|
||||||
|
self.save_customer(profile)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ========== 评价相关 ==========
|
||||||
|
def add_good_review(self, customer_id: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.good_reviews += 1
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def add_bad_review(self, customer_id: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.bad_reviews += 1
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def add_dispute(self, customer_id: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.dispute_count += 1
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
# ========== 标签/备注 ==========
|
||||||
|
def add_tag(self, customer_id: str, tag: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
if tag not in profile.tags:
|
||||||
|
profile.tags.append(tag)
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def remove_tag(self, customer_id: str, tag: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
if tag in profile.tags:
|
||||||
|
profile.tags.remove(tag)
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def add_note(self, customer_id: str, note: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
note_with_time = f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] {note}"
|
||||||
|
profile.notes.append(note_with_time)
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
# ========== 跟进 ==========
|
||||||
|
def set_follow_up(self, customer_id: str, by: str, next_date: str = ""):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.follow_up_by = by
|
||||||
|
profile.follow_up_date = datetime.now().isoformat()
|
||||||
|
profile.next_follow_date = next_date
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def set_source(self, customer_id: str, source: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.source = source
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
# ========== 报价记录 ==========
|
||||||
|
def update_last_price(self, customer_id: str, price: int):
|
||||||
|
"""更新最近一次报价"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.last_price = price
|
||||||
|
profile.last_price_time = datetime.now().isoformat()
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_last_min_price(self, customer_id: str, min_price: int):
|
||||||
|
"""更新最近图片的最低价(用于杀价拦截与策略)"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.last_min_price = int(min_price or 0)
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def mark_quote_no_convert(self, customer_id: str):
|
||||||
|
"""标记:上次报价后未成交,下次可适当降低"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.last_quote_no_convert = True
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def clear_quote_no_convert(self, customer_id: str):
|
||||||
|
"""成交后清除未成交标记"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.last_quote_no_convert = False
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_last_image(
|
||||||
|
self,
|
||||||
|
customer_id: str,
|
||||||
|
image_url: str,
|
||||||
|
complexity: str = "",
|
||||||
|
gemini_prompt: str = "",
|
||||||
|
aspect_ratio: str = "",
|
||||||
|
perspective: str = "",
|
||||||
|
):
|
||||||
|
"""更新客户最近发来的图片URL,并记录复杂度历史及处理参数"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.last_image_url = image_url
|
||||||
|
profile.last_image_time = datetime.now().isoformat()
|
||||||
|
profile.total_images_sent += 1
|
||||||
|
if complexity:
|
||||||
|
profile.complexity_history.append(complexity)
|
||||||
|
profile.complexity_history = profile.complexity_history[-20:]
|
||||||
|
if gemini_prompt:
|
||||||
|
profile.last_gemini_prompt = gemini_prompt
|
||||||
|
if aspect_ratio:
|
||||||
|
profile.last_aspect_ratio = aspect_ratio
|
||||||
|
if perspective:
|
||||||
|
profile.last_perspective = perspective
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_processing_status(self, customer_id: str, status: str, image_url: str = "", expected_done_at: str = ""):
|
||||||
|
"""更新当前任务处理状态"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.processing_status = status
|
||||||
|
if image_url:
|
||||||
|
profile.processing_image_url = image_url
|
||||||
|
if expected_done_at:
|
||||||
|
profile.expected_done_at = expected_done_at
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def record_discount(self, customer_id: str, final_price: int):
|
||||||
|
"""记录一次让价,并更新客户接受的最低价"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.discount_given_count += 1
|
||||||
|
if final_price > 0 and (profile.lowest_price_accepted == 0 or final_price < profile.lowest_price_accepted):
|
||||||
|
profile.lowest_price_accepted = final_price
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_preferred_format(self, customer_id: str, fmt: str):
|
||||||
|
"""更新客户格式偏好(jpg/psd/png)"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.preferred_format = fmt
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_preferred_size(self, customer_id: str, size_desc: str):
|
||||||
|
"""更新客户尺寸/分辨率偏好"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.preferred_size = size_desc
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def save_conversation_summary(self, customer_id: str, summary: str):
|
||||||
|
"""保存本次对话摘要(一句话)"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.last_conversation_summary = summary
|
||||||
|
profile.last_conversation_time = datetime.now().isoformat()
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def add_image_type(self, customer_id: str, image_type: str):
|
||||||
|
"""记录客户发图的类型(印花/logo/人物/产品等)"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.image_type_history.append(image_type)
|
||||||
|
profile.image_type_history = profile.image_type_history[-20:]
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_decision_speed(self, customer_id: str, speed: str):
|
||||||
|
"""更新决策速度:快/慢"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.decision_speed = speed
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def record_revision(self, customer_id: str):
|
||||||
|
"""记录一次改稿"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.revision_count += 1
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def complete_order(self, customer_id: str, had_revision: bool = False):
|
||||||
|
"""标记一笔订单完成出图"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.total_completed_orders += 1
|
||||||
|
if had_revision:
|
||||||
|
profile.revision_orders += 1
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def set_bulk_potential(self, customer_id: str, potential: str):
|
||||||
|
"""设置批量潜力:有/无/未知"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.bulk_potential = potential
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def add_upsell_opportunity(self, customer_id: str, opportunity: str):
|
||||||
|
"""添加加购机会标记,如 '分层PSD' / '批量打包'"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
if opportunity not in profile.upsell_opportunity:
|
||||||
|
profile.upsell_opportunity.append(opportunity)
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def set_blacklist(self, customer_id: str, reason: str = ""):
|
||||||
|
"""将客户加入黑名单"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.blacklist = True
|
||||||
|
profile.blacklist_reason = reason
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def unset_blacklist(self, customer_id: str):
|
||||||
|
"""解除黑名单"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.blacklist = False
|
||||||
|
profile.blacklist_reason = ""
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def set_vip_custom_price(self, customer_id: str, price: int):
|
||||||
|
"""设置 VIP 专属报价(0=取消专属价)"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.vip_custom_price = price
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def update_email_status(self, customer_id: str, status: str):
|
||||||
|
"""更新邮件发送状态:sent/failed"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.last_email_status = status
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def auto_compute_tags(self, customer_id: str):
|
||||||
|
"""自动计算并更新衍生标签(价格敏感度、流失风险、决策速度)"""
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
|
||||||
|
# 价格敏感度:根据让价次数和订单数
|
||||||
|
if profile.total_orders > 0:
|
||||||
|
ratio = profile.discount_given_count / max(profile.total_orders, 1)
|
||||||
|
if ratio >= 0.6 or profile.discount_given_count >= 3:
|
||||||
|
profile.price_sensitivity = "高"
|
||||||
|
elif ratio >= 0.2 or profile.discount_given_count >= 1:
|
||||||
|
profile.price_sensitivity = "中"
|
||||||
|
else:
|
||||||
|
profile.price_sensitivity = "低"
|
||||||
|
|
||||||
|
# 流失风险:根据最后联系时间
|
||||||
|
if profile.last_contact:
|
||||||
|
try:
|
||||||
|
last = datetime.fromisoformat(profile.last_contact)
|
||||||
|
days = (datetime.now() - last).days
|
||||||
|
if days > 60:
|
||||||
|
profile.churn_risk = "高"
|
||||||
|
elif days > 30:
|
||||||
|
profile.churn_risk = "中"
|
||||||
|
else:
|
||||||
|
profile.churn_risk = "低"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
# ========== VIP ==========
|
||||||
|
def set_vip(self, customer_id: str, vip_level: int = 1):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.vip = True
|
||||||
|
profile.vip_level = vip_level
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
def cancel_vip(self, customer_id: str):
|
||||||
|
profile = self.get_customer(customer_id)
|
||||||
|
profile.vip = False
|
||||||
|
profile.vip_level = 0
|
||||||
|
self.save_customer(profile)
|
||||||
|
|
||||||
|
# ========== 搜索 ==========
|
||||||
|
def _make_profile(self, cid: str, data: dict) -> CustomerProfile:
|
||||||
|
"""从原始数据构建 CustomerProfile,避免 customer_id 重复"""
|
||||||
|
d = dict(data)
|
||||||
|
d.pop('customer_id', None)
|
||||||
|
return CustomerProfile(customer_id=cid, **d)
|
||||||
|
|
||||||
|
def search_by_requirement(self, keyword: str) -> List[CustomerProfile]:
|
||||||
|
"""按需求搜索"""
|
||||||
|
customers = self._load_customers()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for cid, data in customers.items():
|
||||||
|
requirements = data.get('requirements', [])
|
||||||
|
if any(keyword in req for req in requirements):
|
||||||
|
results.append(self._make_profile(cid, data))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def search_by_tag(self, tag: str) -> List[CustomerProfile]:
|
||||||
|
"""按标签搜索"""
|
||||||
|
customers = self._load_customers()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for cid, data in customers.items():
|
||||||
|
tags = data.get('tags', [])
|
||||||
|
if tag in tags:
|
||||||
|
results.append(self._make_profile(cid, data))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def search_by_level(self, level: str) -> List[CustomerProfile]:
|
||||||
|
"""按客户等级搜索"""
|
||||||
|
customers = self._load_customers()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for cid, data in customers.items():
|
||||||
|
if data.get('customer_level') == level:
|
||||||
|
results.append(self._make_profile(cid, data))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_vip_customers(self) -> List[CustomerProfile]:
|
||||||
|
"""获取所有VIP客户"""
|
||||||
|
customers = self._load_customers()
|
||||||
|
results = []
|
||||||
|
for cid, data in customers.items():
|
||||||
|
if data.get('vip'):
|
||||||
|
results.append(self._make_profile(cid, data))
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_high_value_customers(self) -> List[CustomerProfile]:
|
||||||
|
"""获取高价值客户(A级)"""
|
||||||
|
return self.search_by_level("A")
|
||||||
|
|
||||||
|
# ========== 统计 ==========
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
"""获取统计信息"""
|
||||||
|
customers = self._load_customers()
|
||||||
|
|
||||||
|
total = len(customers)
|
||||||
|
levels = defaultdict(int)
|
||||||
|
vip_count = 0
|
||||||
|
total_revenue = 0
|
||||||
|
total_orders = 0
|
||||||
|
|
||||||
|
for cid, data in customers.items():
|
||||||
|
levels[data.get('customer_level', 'C')] += 1
|
||||||
|
if data.get('vip'):
|
||||||
|
vip_count += 1
|
||||||
|
total_revenue += data.get('total_spent', 0)
|
||||||
|
total_orders += data.get('total_orders', 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_customers": total,
|
||||||
|
"level_distribution": dict(levels),
|
||||||
|
"vip_count": vip_count,
|
||||||
|
"total_revenue": total_revenue,
|
||||||
|
"total_orders": total_orders,
|
||||||
|
"avg_order_value": total_revenue / total_orders if total_orders > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========== 输出 ==========
|
||||||
|
def get_profile_text(self, customer_id: str) -> str:
|
||||||
|
"""获取客户画像文本(用于AI)"""
|
||||||
|
p = self.get_customer(customer_id)
|
||||||
|
|
||||||
|
# 平均复杂度
|
||||||
|
avg_complexity = ""
|
||||||
|
if p.complexity_history:
|
||||||
|
level_map = {"simple": 1, "normal": 2, "complex": 3, "hard": 4}
|
||||||
|
label_map = {1: "简单", 2: "一般", 3: "复杂", 4: "很复杂"}
|
||||||
|
avg = sum(level_map.get(c, 2) for c in p.complexity_history) / len(p.complexity_history)
|
||||||
|
avg_complexity = label_map.get(round(avg), "一般")
|
||||||
|
|
||||||
|
# 改稿率
|
||||||
|
revision_rate = ""
|
||||||
|
if p.total_completed_orders > 0:
|
||||||
|
rate = p.revision_orders / p.total_completed_orders
|
||||||
|
revision_rate = f"{rate:.0%}({p.revision_orders}/{p.total_completed_orders}单有改稿)"
|
||||||
|
|
||||||
|
# 常见图片类型
|
||||||
|
top_types = ""
|
||||||
|
if p.image_type_history:
|
||||||
|
from collections import Counter
|
||||||
|
top = Counter(p.image_type_history).most_common(3)
|
||||||
|
top_types = "、".join(f"{t}({c}次)" for t, c in top)
|
||||||
|
|
||||||
|
blacklist_line = f"\n⚠️ 黑名单:{p.blacklist_reason or '已拉黑'}" if p.blacklist else ""
|
||||||
|
|
||||||
|
text = f"""
|
||||||
|
=== 客户档案 ==={blacklist_line}
|
||||||
|
客户ID: {p.customer_id}
|
||||||
|
姓名: {p.name or '未知'}
|
||||||
|
邮箱: {p.email or '未记录'}
|
||||||
|
电话: {p.phone or '未记录'}
|
||||||
|
微信: {p.wechat or '未记录'}
|
||||||
|
|
||||||
|
--- 消费分析 ---
|
||||||
|
客户等级: {p.customer_level}级{'(VIP)' if p.vip else ''}
|
||||||
|
总订单数: {p.total_orders} | 总消费: {p.total_spent}元
|
||||||
|
购买频次: {p.purchase_frequency or '未分析'}
|
||||||
|
|
||||||
|
--- 报价与让价 ---
|
||||||
|
上次报价: {f"{p.last_price}元" if p.last_price else "暂无"}{f' | VIP专属价: {p.vip_custom_price}元' if p.vip_custom_price else ''}
|
||||||
|
历史让价: {p.discount_given_count}次 | 接受过的最低价: {f"{p.lowest_price_accepted}元" if p.lowest_price_accepted else "暂无"}
|
||||||
|
价格敏感度: {p.price_sensitivity or '未分析'} | 决策速度: {p.decision_speed or '未知'}
|
||||||
|
|
||||||
|
--- 图片习惯 ---
|
||||||
|
累计发图: {p.total_images_sent}张 | 平均复杂度: {avg_complexity or '暂无'}
|
||||||
|
常见类型: {top_types or '暂无'}
|
||||||
|
格式偏好: {p.preferred_format or '未知'} | 尺寸要求: {p.preferred_size or '未知'}
|
||||||
|
上次发图: {p.last_image_url or '暂无'}
|
||||||
|
|
||||||
|
--- 当前任务 ---
|
||||||
|
处理状态: {p.processing_status or '无'} | 预计完成: {p.expected_done_at or '未知'}
|
||||||
|
|
||||||
|
--- 服务质量 ---
|
||||||
|
改稿率: {revision_rate or '暂无'}
|
||||||
|
批量潜力: {p.bulk_potential or '未知'} | 流失风险: {p.churn_risk or '未分析'}
|
||||||
|
加购机会: {', '.join(p.upsell_opportunity) if p.upsell_opportunity else '暂无'}
|
||||||
|
|
||||||
|
--- 上次对话 ---
|
||||||
|
摘要: {p.last_conversation_summary or '暂无'}
|
||||||
|
时间: {p.last_conversation_time or '暂无'}
|
||||||
|
|
||||||
|
--- 邮件 ---
|
||||||
|
最后发送状态: {p.last_email_status or '未知'}
|
||||||
|
|
||||||
|
--- 性格与备注 ---
|
||||||
|
性格标签: {', '.join(p.personality) if p.personality else '暂无'}
|
||||||
|
备注: {'; '.join(p.notes[-5:]) if p.notes else '暂无'}
|
||||||
|
"""
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
db = CustomerDatabase()
|
||||||
153
db/deal_outcome_db.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# -*- 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")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn() -> sqlite3.Connection:
|
||||||
|
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:
|
||||||
|
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(
|
||||||
|
"""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(
|
||||||
|
"""
|
||||||
|
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(
|
||||||
|
"""SELECT * FROM deal_outcomes
|
||||||
|
WHERE date BETWEEN ? AND ?
|
||||||
|
ORDER BY date, timestamp""",
|
||||||
|
(start_date, end_date),
|
||||||
|
).fetchall()
|
||||||
|
elif start_date:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT * FROM deal_outcomes WHERE date >= ? ORDER BY date, timestamp""",
|
||||||
|
(start_date,),
|
||||||
|
).fetchall()
|
||||||
|
elif end_date:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""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]
|
||||||
BIN
db/deal_outcome_db/outcomes.db
Normal file
159
db/designer_roster_db.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# -*- 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")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn() -> sqlite3.Connection:
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO designers (name, wechat_user_id) VALUES (?, ?)",
|
||||||
|
(name, wechat_user_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("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:
|
||||||
|
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:
|
||||||
|
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("""
|
||||||
|
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("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]
|
||||||
|
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(
|
||||||
|
"SELECT shop_id, group_id FROM designer_shops WHERE designer_id = ?",
|
||||||
|
(d["id"],),
|
||||||
|
).fetchall()
|
||||||
|
online = conn.execute(
|
||||||
|
"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
|
||||||
BIN
db/designer_roster_db/roster.db
Normal file
0
image/__init__.py
Normal file
BIN
image/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
image/__pycache__/image_analyzer.cpython-310.pyc
Normal file
BIN
image/__pycache__/image_precheck.cpython-310.pyc
Normal file
BIN
image/__pycache__/image_processor.cpython-310.pyc
Normal file
BIN
image/__pycache__/image_qa.cpython-310.pyc
Normal file
593
image/image_analyzer.py
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
"""
|
||||||
|
图片复杂度识别模块
|
||||||
|
|
||||||
|
使用智谱 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:无文字,或仅有大字(大字没关系 → 不加价)
|
||||||
|
|
||||||
|
【含人脸】
|
||||||
|
- yes:图中有真实人物面孔(人像照/集体照/证件照/老照片等)
|
||||||
|
- no:无人脸或人脸极小不影响主体
|
||||||
|
|
||||||
|
【风险评估】
|
||||||
|
- none:印花/图案/logo/风景/产品,AI处理效果稳定,可直接报价
|
||||||
|
- low:有人脸但清晰度尚可,AI修复后人脸相似度70-90%,建议先看效果
|
||||||
|
- high:以下任一情况 → 严重模糊的人脸照片/老照片人像/需要打印/客户问能否找回原图
|
||||||
|
high情况下,可做改为partial,备注写明风险话术
|
||||||
|
|
||||||
|
【敏感内容】优先判断,若为 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, "非常复杂"),
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async def analyze(self, image_path: str) -> dict:
|
||||||
|
"""
|
||||||
|
异步分析图片复杂度(使用火山引擎 /responses 接口)。
|
||||||
|
实际等待时间 = max(视觉AI响应时间, MIN_WAIT_SECONDS)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: 图片URL 或 本地路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"complexity": "simple|normal|complex|hard",
|
||||||
|
"reason": "原因描述",
|
||||||
|
"price_min": 最低报价,
|
||||||
|
"price_max": 最高报价,
|
||||||
|
"price_suggest": 建议报价,
|
||||||
|
"elapsed": 实际耗时秒数,
|
||||||
|
"success": True/False
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not self.api_key:
|
||||||
|
await asyncio.sleep(self.MIN_WAIT_SECONDS)
|
||||||
|
return self._fallback("未配置 API Key")
|
||||||
|
|
||||||
|
# 缓存:仅对 URL 生效,本地路径不缓存
|
||||||
|
cache_key = image_path if self._is_url(image_path) else None
|
||||||
|
if cache_key:
|
||||||
|
now = time.monotonic()
|
||||||
|
cached = self._analysis_cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
result, cached_at = cached
|
||||||
|
if now - cached_at < self._CACHE_TTL_SECONDS:
|
||||||
|
print(f"[ImageAnalyzer] 缓存命中 | URL 已分析过,跳过 API 调用")
|
||||||
|
result = dict(result)
|
||||||
|
result["elapsed"] = 0
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
del self._analysis_cache[cache_key]
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 构建图片内容
|
||||||
|
if self._is_url(image_path):
|
||||||
|
image_item = {
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": image_path
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
b64 = self._load_image_base64(image_path)
|
||||||
|
if not b64:
|
||||||
|
await asyncio.sleep(self.MIN_WAIT_SECONDS)
|
||||||
|
return self._fallback("图片读取失败")
|
||||||
|
image_item = {
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": f"data:image/jpeg;base64,{b64}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用火山引擎官方 SDK(AsyncOpenAI + /responses 接口)
|
||||||
|
client = AsyncOpenAI(
|
||||||
|
base_url=self.base_url,
|
||||||
|
api_key=self.api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.responses.create(
|
||||||
|
model=self.vision_model,
|
||||||
|
input=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
image_item,
|
||||||
|
{
|
||||||
|
"type": "input_text",
|
||||||
|
"text": ANALYSIS_PROMPT
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.output_text
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
print(f"[ImageAnalyzer] 视觉AI响应耗时: {elapsed:.1f}s")
|
||||||
|
|
||||||
|
await self._wait_remaining(elapsed)
|
||||||
|
|
||||||
|
result = self._parse_result(content)
|
||||||
|
result["elapsed"] = elapsed
|
||||||
|
|
||||||
|
# 计算尺寸与类型加价
|
||||||
|
try:
|
||||||
|
w, h = await self._get_image_size(image_path)
|
||||||
|
mp = round((w * h) / 1_000_000, 2) if w and h else 0.0
|
||||||
|
result["width"] = w
|
||||||
|
result["height"] = h
|
||||||
|
result["megapixels"] = mp
|
||||||
|
|
||||||
|
# 归一化类型
|
||||||
|
subj = (result.get("subject") or "").lower()
|
||||||
|
ptype = (result.get("proc_type") or "").lower()
|
||||||
|
ratio = result.get("aspect_ratio") or "1:1"
|
||||||
|
category = "general"
|
||||||
|
# 初步判断
|
||||||
|
if ("壁纸" in subj) or ("wallpaper" in subj) or ratio in ("9:16", "16:9"):
|
||||||
|
category = "wallpaper"
|
||||||
|
elif ("衣" in subj) or ("服" in subj) or ("印花" in subj) or ("fabric" in subj) or ("cloth" in subj) or ("服装" in subj) or ("印花" in ptype):
|
||||||
|
category = "clothing"
|
||||||
|
elif ("logo" in subj) or ("logo" in ptype):
|
||||||
|
category = "logo"
|
||||||
|
elif ("海报" in subj) or ("poster" in subj):
|
||||||
|
category = "poster"
|
||||||
|
elif ("人像" in subj) or ("人物" in subj) or ("portrait" in subj):
|
||||||
|
category = "portrait"
|
||||||
|
elif ("产品" in subj) or ("product" in subj):
|
||||||
|
category = "product"
|
||||||
|
elif ("老照片" in subj) or ("old photo" in subj):
|
||||||
|
category = "old_photo"
|
||||||
|
# 可印花/印刷物体扩展
|
||||||
|
keywords = subj + " " + ptype
|
||||||
|
if any(k in keywords for k in ["装饰画", "挂画", "油画", "canvas", "painting"]):
|
||||||
|
category = "decor_painting"
|
||||||
|
elif any(k in keywords for k in ["窗帘", "curtain"]):
|
||||||
|
category = "curtain"
|
||||||
|
elif any(k in keywords for k in ["地垫", "脚垫", "地毯", "垫", "mat", "rug"]):
|
||||||
|
category = "floor_mat"
|
||||||
|
elif any(k in keywords for k in ["广告牌", "喷绘", "展架", "灯箱", "banner", "billboard"]):
|
||||||
|
category = "billboard"
|
||||||
|
elif any(k in keywords for k in ["毯子", "毛毯", "blanket"]):
|
||||||
|
category = "blanket"
|
||||||
|
elif any(k in keywords for k in ["桌布", "台布", "tablecloth", "桌旗"]):
|
||||||
|
category = "tablecloth"
|
||||||
|
elif any(k in keywords for k in ["书本", "书籍", "封面", "book", "book cover"]):
|
||||||
|
category = "book"
|
||||||
|
elif any(k in keywords for k in ["鼠标垫", "mouse pad", "mousepad"]):
|
||||||
|
category = "mouse_pad"
|
||||||
|
elif any(k in keywords for k in ["头像", "个人头像", "个人照", "profile", "avatar"]):
|
||||||
|
category = "avatar"
|
||||||
|
result["category"] = category
|
||||||
|
|
||||||
|
surcharge = 0
|
||||||
|
size_note = ""
|
||||||
|
# 按类别设定尺寸要求与加价阈值(单位:百万像素)
|
||||||
|
if category == "wallpaper":
|
||||||
|
if h and h < 1920:
|
||||||
|
size_note = "壁纸高度低于1920px,清晰度可能不足"
|
||||||
|
if mp > 8:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 3:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "clothing":
|
||||||
|
if (w and w < 1024) or (h and h < 1024):
|
||||||
|
size_note = "印花源图边长低于1024px,放大后细节可能不足"
|
||||||
|
if mp > 6:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 2:
|
||||||
|
surcharge = 5
|
||||||
|
elif category in ("poster", "portrait", "product"):
|
||||||
|
if mp > 12:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 6:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "logo":
|
||||||
|
if mp > 6:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "decor_painting":
|
||||||
|
if (w and w < 1500) or (h and h < 1500):
|
||||||
|
size_note = "装饰画边长低于1500px,打印放大可能不够清晰"
|
||||||
|
if mp > 12:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 6:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "curtain":
|
||||||
|
if (w and w < 1500):
|
||||||
|
size_note = "窗帘宽度低于1500px,印花放大可能不够清晰"
|
||||||
|
if mp > 16:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 8:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "floor_mat":
|
||||||
|
if mp > 12:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 6:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "billboard":
|
||||||
|
if (w and w < 2000) or (h and h < 1000):
|
||||||
|
size_note = "广告牌尺寸较小,建议更高分辨率以保证喷绘清晰"
|
||||||
|
if mp > 20:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 10:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "blanket":
|
||||||
|
if mp > 16:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 8:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "tablecloth":
|
||||||
|
if mp > 12:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 6:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "book":
|
||||||
|
if (w and w < 800):
|
||||||
|
size_note = "书本封面宽度低于800px,印刷细节可能不足"
|
||||||
|
if mp > 6:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "mouse_pad":
|
||||||
|
if (w and w < 1000):
|
||||||
|
size_note = "鼠标垫源图宽度低于1000px,细节可能不足"
|
||||||
|
if mp > 4:
|
||||||
|
surcharge = 5
|
||||||
|
elif category == "avatar":
|
||||||
|
if (w and w < 800) or (h and h < 800):
|
||||||
|
size_note = "头像边长低于800px,清晰度可能不足"
|
||||||
|
if mp > 6:
|
||||||
|
surcharge = 5
|
||||||
|
else:
|
||||||
|
if mp > 8:
|
||||||
|
surcharge = 10
|
||||||
|
elif mp > 4:
|
||||||
|
surcharge = 5
|
||||||
|
|
||||||
|
# 应用加价,保持5的整数倍与 10-30 区间
|
||||||
|
base = result.get("price_suggest", 20)
|
||||||
|
adjusted = base + surcharge
|
||||||
|
adjusted = max(10, min(30, adjusted))
|
||||||
|
adjusted = round(adjusted / 5) * 5
|
||||||
|
# 同步范围
|
||||||
|
result["price_suggest"] = adjusted
|
||||||
|
result["price_max"] = max(result["price_max"], adjusted)
|
||||||
|
result["size_surcharge"] = surcharge
|
||||||
|
result["size_note"] = size_note
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ImageAnalyzer] 尺寸与类型加价计算失败: {e}")
|
||||||
|
|
||||||
|
# 写入缓存
|
||||||
|
if cache_key:
|
||||||
|
self._analysis_cache[cache_key] = (dict(result), time.monotonic())
|
||||||
|
# 简单清理:缓存超过 50 条时删最旧的
|
||||||
|
if len(self._analysis_cache) > 50:
|
||||||
|
oldest = min(self._analysis_cache.items(), key=lambda x: x[1][1])
|
||||||
|
del self._analysis_cache[oldest[0]]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
print(f"[ImageAnalyzer] 请求超时 ({elapsed:.1f}s)")
|
||||||
|
return self._fallback("请求超时")
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
print(f"[ImageAnalyzer] 分析失败: {e}")
|
||||||
|
await self._wait_remaining(elapsed)
|
||||||
|
return self._fallback(str(e))
|
||||||
|
|
||||||
|
async def _wait_remaining(self, elapsed: float):
|
||||||
|
"""补足最短等待时间"""
|
||||||
|
remaining = self.MIN_WAIT_SECONDS - elapsed
|
||||||
|
if remaining > 0:
|
||||||
|
await asyncio.sleep(remaining)
|
||||||
|
|
||||||
|
def _parse_line(self, content: str, *keys: str) -> str:
|
||||||
|
"""从多行文本中提取指定字段值,支持中英文冒号"""
|
||||||
|
for line in content.strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
for key in keys:
|
||||||
|
if line.startswith(key):
|
||||||
|
return line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _parse_result(self, content: str) -> dict:
|
||||||
|
"""解析模型返回的结果"""
|
||||||
|
p = self._parse_line
|
||||||
|
|
||||||
|
# 复杂度
|
||||||
|
complexity_raw = p(content, "复杂度:", "复杂度:").lower()
|
||||||
|
complexity = complexity_raw if complexity_raw in self.PRICE_MAP else "normal"
|
||||||
|
|
||||||
|
sensitive = p(content, "敏感内容:", "敏感内容:").lower().strip()
|
||||||
|
flatness = p(content, "平整度:", "平整度:").lower().strip() # flat|mild|rough
|
||||||
|
has_text = p(content, "含文字:", "含文字:").lower().strip()
|
||||||
|
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"
|
||||||
|
if risk not in ("none", "low", "high"):
|
||||||
|
risk = "none"
|
||||||
|
if perspective not in ("no", "mild", "strong"):
|
||||||
|
perspective = "no"
|
||||||
|
|
||||||
|
# 校验比例合法性
|
||||||
|
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/hard 取固定值,simple/normal 取中间,且必须为5的整数倍
|
||||||
|
raw = price_max if complexity in ("complex", "hard") else (price_min + price_max) // 2
|
||||||
|
price_suggest = round(raw / 5) * 5
|
||||||
|
|
||||||
|
if sensitive == "yes":
|
||||||
|
feasibility = "no"
|
||||||
|
note = "图片含敏感内容,不接单"
|
||||||
|
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",
|
||||||
|
"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}")
|
||||||
|
return {
|
||||||
|
"complexity": "normal",
|
||||||
|
"reason": reason,
|
||||||
|
"subject": "",
|
||||||
|
"proc_type": "",
|
||||||
|
"quality": "",
|
||||||
|
"flatness": "",
|
||||||
|
"has_text": "no",
|
||||||
|
"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()
|
||||||
47
image/image_precheck.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# -*- 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, ""
|
||||||
328
image/image_processor.py
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
"""图片处理模块 - 调用 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()
|
||||||
189
image/image_qa.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""
|
||||||
|
图片处理结果质检模块
|
||||||
|
|
||||||
|
处理完成后,用视觉 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()
|
||||||
293
image/image_tools.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""
|
||||||
|
图片处理独立工具 - 可单独调用,也可被主流程复用。
|
||||||
|
|
||||||
|
主流程(付款触发)不变,这些工具供 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)
|
||||||
651
image/perspective_fix.py
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
"""
|
||||||
|
透视矫正三步流程:
|
||||||
|
Step1: Gemini 去背景 → 纯白背景
|
||||||
|
Step2: OpenCV 在白背景图上检测四角 → warpPerspective 展平
|
||||||
|
Step3: Gemini 对展平结果做高清增强
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python perspective_fix.py <图片路径或URL> [--debug] [--skip-step1] [--skip-step3]
|
||||||
|
"""
|
||||||
|
import sys, io
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
import os, asyncio, uuid, tempfile
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
_OUTPUT_DIR = os.getenv("RESULT_IMAGE_DIR", "results")
|
||||||
|
os.makedirs(_OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Gemini 辅助函数
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def _gemini_call(input_path: str, output_path: str, prompt: str,
|
||||||
|
aspect_ratio: str = "1:1", label: str = "") -> bool:
|
||||||
|
from services.service_gemini import GeminiExtractV2Service
|
||||||
|
service = GeminiExtractV2Service()
|
||||||
|
try:
|
||||||
|
ok, msg, _ = await service.extract_pattern(
|
||||||
|
input_path=input_path,
|
||||||
|
output_path=output_path,
|
||||||
|
custom_prompt=prompt,
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
)
|
||||||
|
status = "成功" if ok else "失败"
|
||||||
|
print(f" [{label}] Gemini {status}: {msg[:80]}")
|
||||||
|
return ok and os.path.exists(output_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{label}] Gemini 异常: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
await service.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_WHITE_BG = (
|
||||||
|
"请处理这张图片:\n"
|
||||||
|
"1. 识别图中的地毯/地垫/印花布料/产品本体作为主体\n"
|
||||||
|
"2. 去掉主体上面放置的所有物品(杯子、碗、餐具、装饰品等),只保留地垫本身\n"
|
||||||
|
"3. 把所有背景(桌面、地板、墙壁、阴影)全部替换为纯白色(#FFFFFF)\n"
|
||||||
|
"4. 保持地垫/产品的颜色、图案、边缘完全不变\n"
|
||||||
|
"输出:只有主体产品、纯白背景、无杂物的干净产品图。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 当第一次去背景效果不好时(白色覆盖率过低),用更强硬的提示词重试
|
||||||
|
PROMPT_WHITE_BG_STRONG = (
|
||||||
|
"严格执行:将这张图的背景彻底替换为纯白色 RGB(255,255,255)。\n"
|
||||||
|
"只保留图片中央的产品/地毯/布料主体,其他所有区域(桌面/地板/墙/阴影/物品)"
|
||||||
|
"一律改为纯白色。产品边缘要干净锐利,不留任何半透明或灰色区域。\n"
|
||||||
|
"重要:不论主体上摆放了什么东西,统统去掉,只输出产品本身+白色背景。"
|
||||||
|
)
|
||||||
|
|
||||||
|
PROMPT_ENHANCE = (
|
||||||
|
"请对这张已展平的图案进行高清增强:提升整体清晰度和色彩饱和度,"
|
||||||
|
"修复边缘锯齿,补全缺失细节,输出印刷级高质量平面图,背景保持纯白。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step3 增强失败时的兜底提示词(更简单,成功率更高)
|
||||||
|
PROMPT_ENHANCE_SIMPLE = (
|
||||||
|
"请提升这张图片的清晰度和画质,输出高清版本,背景保持纯白。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _measure_white_coverage(image: np.ndarray) -> float:
|
||||||
|
"""返回图片中白色像素的百分比,用于判断去背景效果"""
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
_, mask = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY)
|
||||||
|
return float(np.sum(mask == 255)) / mask.size
|
||||||
|
|
||||||
|
|
||||||
|
def _color_match(source: np.ndarray, target: np.ndarray,
|
||||||
|
strength: float = 0.75, exclude_white: bool = True) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
将 target 的色调匹配到 source(类 PS「匹配颜色」)。
|
||||||
|
使用 LAB 色彩空间 Reinhard 均值/标准差迁移。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: 原图(色彩参考来源)
|
||||||
|
target: 待调整图(处理后结果)
|
||||||
|
strength: 迁移强度 0.0-1.0,推荐 0.6-0.85
|
||||||
|
exclude_white: 统计时排除白色像素,避免背景影响肤色/图案计算
|
||||||
|
Returns:
|
||||||
|
调色后的 BGR 图像
|
||||||
|
"""
|
||||||
|
src_f = source.astype(np.float32) / 255.0
|
||||||
|
tgt_f = target.astype(np.float32) / 255.0
|
||||||
|
|
||||||
|
src_lab = cv2.cvtColor(src_f, cv2.COLOR_BGR2Lab)
|
||||||
|
tgt_lab = cv2.cvtColor(tgt_f, cv2.COLOR_BGR2Lab)
|
||||||
|
result = tgt_lab.copy()
|
||||||
|
|
||||||
|
for ch in range(3):
|
||||||
|
if exclude_white:
|
||||||
|
# 排除极亮像素(L > 95)统计,只看图案区域
|
||||||
|
src_mask = src_lab[:, :, 0] < 95
|
||||||
|
tgt_mask = tgt_lab[:, :, 0] < 95
|
||||||
|
src_vals = src_lab[:, :, ch][src_mask]
|
||||||
|
tgt_vals = tgt_lab[:, :, ch][tgt_mask]
|
||||||
|
else:
|
||||||
|
src_vals = src_lab[:, :, ch].ravel()
|
||||||
|
tgt_vals = tgt_lab[:, :, ch].ravel()
|
||||||
|
|
||||||
|
if src_vals.size == 0 or tgt_vals.size == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
src_mean, src_std = float(src_vals.mean()), float(src_vals.std())
|
||||||
|
tgt_mean, tgt_std = float(tgt_vals.mean()), float(tgt_vals.std())
|
||||||
|
|
||||||
|
if tgt_std < 1e-6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Reinhard 迁移:先归一化到目标,再重映射到源分布
|
||||||
|
shifted = (tgt_lab[:, :, ch] - tgt_mean) / tgt_std * src_std + src_mean
|
||||||
|
# 按 strength 混合:strength=1 完全迁移,0 保持不变
|
||||||
|
result[:, :, ch] = shifted * strength + tgt_lab[:, :, ch] * (1.0 - strength)
|
||||||
|
|
||||||
|
result_bgr = cv2.cvtColor(result, cv2.COLOR_Lab2BGR)
|
||||||
|
result_bgr = np.clip(result_bgr * 255, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
print(f" [颜色匹配] 强度={strength:.0%} | "
|
||||||
|
f"源均值L={src_lab[:,:,0].mean():.1f} → 目标均值L={tgt_lab[:,:,0].mean():.1f}")
|
||||||
|
return result_bgr
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# OpenCV 透视矫正
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def order_points(pts: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
把四个点排列为 [左上, 右上, 右下, 左下]。
|
||||||
|
使用质心角度排序,对矩形、菱形、平行四边形等各种透视形状均适用。
|
||||||
|
"""
|
||||||
|
cx, cy = pts[:, 0].mean(), pts[:, 1].mean()
|
||||||
|
# 计算每个点相对质心的角度(从正上方顺时针)
|
||||||
|
angles = np.arctan2(pts[:, 1] - cy, pts[:, 0] - cx)
|
||||||
|
# 顺时针排序:从右上开始(角度最小的)
|
||||||
|
order = np.argsort(angles)
|
||||||
|
sorted_pts = pts[order]
|
||||||
|
# 找到最左上角作为起点(x+y 最小)
|
||||||
|
s = sorted_pts.sum(axis=1)
|
||||||
|
start = np.argmin(s)
|
||||||
|
# 从左上角开始顺时针排列 → [左上, 右上, 右下, 左下]
|
||||||
|
indices = [(start + i) % 4 for i in range(4)]
|
||||||
|
rect = sorted_pts[indices].astype("float32")
|
||||||
|
return rect
|
||||||
|
|
||||||
|
|
||||||
|
def four_point_transform(image: np.ndarray, pts: np.ndarray) -> np.ndarray:
|
||||||
|
rect = order_points(pts)
|
||||||
|
tl, tr, br, bl = rect
|
||||||
|
|
||||||
|
w1 = np.linalg.norm(br - bl)
|
||||||
|
w2 = np.linalg.norm(tr - tl)
|
||||||
|
h1 = np.linalg.norm(tr - br)
|
||||||
|
h2 = np.linalg.norm(tl - bl)
|
||||||
|
W = int(max(w1, w2))
|
||||||
|
H = int(max(h1, h2))
|
||||||
|
|
||||||
|
print(f" [CV] 角点: TL={tl.astype(int)} TR={tr.astype(int)} BR={br.astype(int)} BL={bl.astype(int)}")
|
||||||
|
print(f" [CV] 矫正后目标尺寸: {W}x{H}")
|
||||||
|
|
||||||
|
dst = np.array([
|
||||||
|
[0, 0 ],
|
||||||
|
[W - 1, 0 ],
|
||||||
|
[W - 1, H - 1],
|
||||||
|
[0, H - 1],
|
||||||
|
], dtype="float32")
|
||||||
|
|
||||||
|
M = cv2.getPerspectiveTransform(rect, dst)
|
||||||
|
warped = cv2.warpPerspective(
|
||||||
|
image, M, (W, H),
|
||||||
|
flags=cv2.INTER_LANCZOS4,
|
||||||
|
borderMode=cv2.BORDER_CONSTANT,
|
||||||
|
borderValue=(255, 255, 255),
|
||||||
|
)
|
||||||
|
return warped
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_bg_color(image: np.ndarray, corner_size: int = 24) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
从图片四个角落采样,估计背景颜色(BGR)。
|
||||||
|
适用于白色、米色、黄色、灰色等各种背景。
|
||||||
|
"""
|
||||||
|
H, W = image.shape[:2]
|
||||||
|
cs = min(corner_size, H // 5, W // 5)
|
||||||
|
corners = [
|
||||||
|
image[:cs, :cs], # 左上
|
||||||
|
image[:cs, W-cs:], # 右上
|
||||||
|
image[H-cs:, :cs], # 左下
|
||||||
|
image[H-cs:, W-cs:], # 右下
|
||||||
|
]
|
||||||
|
pixels = np.concatenate([c.reshape(-1, 3) for c in corners], axis=0)
|
||||||
|
bg = np.median(pixels, axis=0).astype(np.uint8)
|
||||||
|
return bg # BGR
|
||||||
|
|
||||||
|
|
||||||
|
def tool_trim_white_border(image: np.ndarray,
|
||||||
|
tolerance: int = 18,
|
||||||
|
bg_ratio: float = 0.90,
|
||||||
|
padding: int = 4) -> tuple[np.ndarray, bool, dict]:
|
||||||
|
"""
|
||||||
|
【Tool】智能背景边裁切(支持任意背景色:白/黄/米/灰等)。
|
||||||
|
|
||||||
|
算法:
|
||||||
|
1. 从四角采样估计背景色
|
||||||
|
2. 逐行/列扫描:若该行/列中 bg_ratio 以上的像素与背景色差异 <= tolerance,则为背景行/列
|
||||||
|
3. 找到内容区域边界后裁切
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(裁切后图片, 是否裁切, 详情dict)
|
||||||
|
"""
|
||||||
|
H, W = image.shape[:2]
|
||||||
|
bg_color = _detect_bg_color(image)
|
||||||
|
img_f = image.astype(np.int32)
|
||||||
|
|
||||||
|
# 每个像素与背景色的最大通道差异
|
||||||
|
diff = np.abs(img_f - bg_color.astype(np.int32)).max(axis=2) # H x W
|
||||||
|
is_bg = diff <= tolerance # True = 接近背景色
|
||||||
|
|
||||||
|
row_bg_ratio = is_bg.mean(axis=1) # 每行的背景像素占比
|
||||||
|
col_bg_ratio = is_bg.mean(axis=0) # 每列的背景像素占比
|
||||||
|
|
||||||
|
top = next((i for i in range(H) if row_bg_ratio[i] < bg_ratio), H)
|
||||||
|
bottom = next((i for i in range(H-1,-1,-1) if row_bg_ratio[i] < bg_ratio), -1) + 1
|
||||||
|
left = next((i for i in range(W) if col_bg_ratio[i] < bg_ratio), W)
|
||||||
|
right = next((i for i in range(W-1,-1,-1) if col_bg_ratio[i] < bg_ratio), -1) + 1
|
||||||
|
|
||||||
|
border_top = top
|
||||||
|
border_bottom = H - bottom
|
||||||
|
border_left = left
|
||||||
|
border_right = W - right
|
||||||
|
max_border = max(border_top, border_bottom, border_left, border_right)
|
||||||
|
|
||||||
|
bg_hex = "#{:02X}{:02X}{:02X}".format(int(bg_color[2]), int(bg_color[1]), int(bg_color[0]))
|
||||||
|
info = {"top": border_top, "bottom": border_bottom,
|
||||||
|
"left": border_left, "right": border_right, "bg_color": bg_hex}
|
||||||
|
|
||||||
|
if max_border < 5:
|
||||||
|
print(f" [裁边] 背景色{bg_hex} | 上{border_top} 下{border_bottom} 左{border_left} 右{border_right}px → 无需裁切")
|
||||||
|
return image, False, info
|
||||||
|
|
||||||
|
y1 = max(0, top - padding)
|
||||||
|
y2 = min(H, bottom + padding)
|
||||||
|
x1 = max(0, left - padding)
|
||||||
|
x2 = min(W, right + padding)
|
||||||
|
cropped = image[y1:y2, x1:x2]
|
||||||
|
ch, cw = cropped.shape[:2]
|
||||||
|
print(f" [裁边] 背景色{bg_hex} | 上{border_top} 下{border_bottom} 左{border_left} 右{border_right}px → 裁切 {W}x{H}→{cw}x{ch}")
|
||||||
|
return cropped, True, info
|
||||||
|
|
||||||
|
|
||||||
|
async def tool_color_match(orig_img: np.ndarray, result_img: np.ndarray,
|
||||||
|
strength: float = 0.75) -> np.ndarray:
|
||||||
|
"""【Tool】颜色匹配(封装版,供 AI 决策层调用)"""
|
||||||
|
return _color_match(orig_img, result_img, strength=strength)
|
||||||
|
|
||||||
|
|
||||||
|
async def ai_decide_postprocess(orig_img: np.ndarray, result_img: np.ndarray) -> dict:
|
||||||
|
"""
|
||||||
|
【AI 决策层】用视觉模型分析出图效果,决定是否需要颜色匹配和白边裁切。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"need_color_match": bool,
|
||||||
|
"color_strength": float, # 0.5-0.9
|
||||||
|
"need_trim": bool,
|
||||||
|
"reason": str,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
api_key = os.getenv("OPENAI_API_KEY")
|
||||||
|
base_url = os.getenv("OPENAI_BASE_URL")
|
||||||
|
model = os.getenv("VISION_MODEL", "glm-4v-flash")
|
||||||
|
|
||||||
|
# 无 API 时默认两个都做
|
||||||
|
if not api_key:
|
||||||
|
return {"need_color_match": True, "color_strength": 0.75,
|
||||||
|
"need_trim": True, "reason": "无API Key,默认执行"}
|
||||||
|
|
||||||
|
def _encode(img: np.ndarray) -> str:
|
||||||
|
resized = cv2.resize(img, (512, 512))
|
||||||
|
_, buf = cv2.imencode(".jpg", resized, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
||||||
|
return base64.b64encode(buf).decode()
|
||||||
|
|
||||||
|
orig_b64 = _encode(orig_img)
|
||||||
|
result_b64 = _encode(result_img)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"你是图片后处理决策助手。图一是原图,图二是AI处理后的结果图。请判断:\n\n"
|
||||||
|
"【问题1】颜色差异:处理后图片的整体色调与原图相比,差异是否明显?\n"
|
||||||
|
"(明显=色调/饱和度/冷暖差异很大;轻微=有轻微偏差;无=颜色基本一致)\n\n"
|
||||||
|
"【问题2】多余边框:处理后图片四周是否有不属于图案内容的多余空白边框?\n"
|
||||||
|
"注意:边框颜色不一定是白色,也可能是黄色、米色、灰色等任何纯色。\n"
|
||||||
|
"判断标准:图案内容的外围是否有一圈明显的纯色空白带。\n\n"
|
||||||
|
"严格按格式回答(每行一个字段,不要多余内容):\n"
|
||||||
|
"颜色差异: <明显|轻微|无>\n"
|
||||||
|
"多余边框: <有|无>\n"
|
||||||
|
"边框位置: <有边框的方向如「上下」,没有则填无>"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
|
||||||
|
response = await client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{orig_b64}"}},
|
||||||
|
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{result_b64}"}},
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
text = response.choices[0].message.content or ""
|
||||||
|
print(f" [AI决策] 原始回答: {text.strip()[:120]}")
|
||||||
|
|
||||||
|
def _get(key):
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith(key):
|
||||||
|
return line.split(":", 1)[-1].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
color_level = _get("颜色差异")
|
||||||
|
has_border = "有" in _get("多余边框")
|
||||||
|
border_pos = _get("边框位置")
|
||||||
|
|
||||||
|
strength_map = {"明显": 0.80, "轻微": 0.55, "无": 0.0}
|
||||||
|
color_strength = strength_map.get(color_level, 0.75)
|
||||||
|
need_color = color_strength > 0
|
||||||
|
|
||||||
|
reason = f"颜色差异={color_level or '?'}, 边框={'有('+border_pos+')' if has_border else '无'}"
|
||||||
|
print(f" [AI决策] {reason} → 颜色匹配={'✓' if need_color else '✗'}(强度{color_strength:.0%}), 裁边={'✓' if has_border else '✗'}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"need_color_match": need_color,
|
||||||
|
"color_strength": color_strength,
|
||||||
|
"need_trim": has_border,
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [AI决策] 调用失败({e}),默认执行颜色匹配+裁边")
|
||||||
|
return {"need_color_match": True, "color_strength": 0.75,
|
||||||
|
"need_trim": True, "reason": f"AI决策失败: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _points_are_unique(pts: np.ndarray, min_dist: float = 20.0) -> bool:
|
||||||
|
"""检查4个角点两两之间距离都大于 min_dist,防止重复点导致退化变换"""
|
||||||
|
for i in range(len(pts)):
|
||||||
|
for j in range(i + 1, len(pts)):
|
||||||
|
if np.linalg.norm(pts[i] - pts[j]) < min_dist:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def find_quad(image: np.ndarray):
|
||||||
|
"""
|
||||||
|
在白背景图上检测主体四边形角点。
|
||||||
|
策略(按优先级):
|
||||||
|
1. 二值化 + approxPolyDP(epsilon 从小到大尝试)
|
||||||
|
2. 凸包取极值四点(最左/最右/最上/最下)
|
||||||
|
3. minAreaRect 四角
|
||||||
|
"""
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
img_area = h * w
|
||||||
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||||
|
|
||||||
|
# ── 获取主体轮廓 ──────────────────────────────────────────
|
||||||
|
_, thresh = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY_INV)
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 20))
|
||||||
|
closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
|
||||||
|
|
||||||
|
cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
if not cnts:
|
||||||
|
edges = cv2.Canny(gray, 30, 100)
|
||||||
|
k2 = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 10))
|
||||||
|
closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, k2)
|
||||||
|
cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
if not cnts:
|
||||||
|
print(" [CV] 无法检测轮廓")
|
||||||
|
return None
|
||||||
|
|
||||||
|
c = max(cnts, key=cv2.contourArea)
|
||||||
|
area = cv2.contourArea(c)
|
||||||
|
print(f" [CV] 主体轮廓面积: {area:.0f} / {img_area} ({area/img_area*100:.1f}%)")
|
||||||
|
if area < img_area * 0.05:
|
||||||
|
print(" [CV] 面积太小,背景可能去除不完全")
|
||||||
|
return None
|
||||||
|
|
||||||
|
peri = cv2.arcLength(c, True)
|
||||||
|
|
||||||
|
# ── 策略1:approxPolyDP,epsilon 逐步放大直到得到4个唯一角点 ──
|
||||||
|
for eps_ratio in [0.02, 0.03, 0.04, 0.05, 0.06]:
|
||||||
|
approx = cv2.approxPolyDP(c, eps_ratio * peri, True)
|
||||||
|
pts = approx.reshape(-1, 2).astype("float32")
|
||||||
|
if len(pts) == 4 and _points_are_unique(pts):
|
||||||
|
print(f" [CV] approxPolyDP 成功 (eps={eps_ratio}), 4个唯一角点")
|
||||||
|
return pts
|
||||||
|
print(f" [CV] approxPolyDP eps={eps_ratio}: {len(pts)} 顶点,唯一={_points_are_unique(pts) if len(pts)==4 else 'N/A'}")
|
||||||
|
|
||||||
|
# ── 策略2:凸包极值四点(最左/最上/最右/最下)─────────────
|
||||||
|
hull = cv2.convexHull(c).reshape(-1, 2).astype("float32")
|
||||||
|
if len(hull) >= 4:
|
||||||
|
# 取4个极值方向的点
|
||||||
|
left = hull[np.argmin(hull[:, 0])] # 最左
|
||||||
|
right = hull[np.argmax(hull[:, 0])] # 最右
|
||||||
|
top = hull[np.argmin(hull[:, 1])] # 最上
|
||||||
|
bottom = hull[np.argmax(hull[:, 1])] # 最下
|
||||||
|
pts = np.array([left, top, right, bottom], dtype="float32")
|
||||||
|
if _points_are_unique(pts):
|
||||||
|
print(f" [CV] 使用凸包极值四点: L={left.astype(int)} T={top.astype(int)} R={right.astype(int)} B={bottom.astype(int)}")
|
||||||
|
return pts
|
||||||
|
|
||||||
|
# ── 策略3:minAreaRect 四角(兜底)─────────────────────────
|
||||||
|
print(f" [CV] 兜底:使用 minAreaRect")
|
||||||
|
rect = cv2.minAreaRect(c)
|
||||||
|
box = cv2.boxPoints(rect).astype("float32")
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
def save_debug_img(image: np.ndarray, pts, path: str):
|
||||||
|
"""保存带角点标注的调试图"""
|
||||||
|
dbg = image.copy()
|
||||||
|
if pts is not None:
|
||||||
|
rect = order_points(pts)
|
||||||
|
labels = ["TL", "TR", "BR", "BL"]
|
||||||
|
colors = [(0,0,255), (0,255,0), (255,0,0), (0,165,255)]
|
||||||
|
for i, (px, py) in enumerate(rect):
|
||||||
|
cv2.circle(dbg, (int(px), int(py)), 12, colors[i], -1)
|
||||||
|
cv2.putText(dbg, labels[i], (int(px)+15, int(py)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 1.2, colors[i], 3)
|
||||||
|
box = rect.reshape((-1,1,2)).astype(np.int32)
|
||||||
|
cv2.polylines(dbg, [box], True, (0,0,255), 3)
|
||||||
|
cv2.imwrite(path, dbg, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||||
|
print(f" [Debug] 调试图: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 主流程
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def process(src: str, debug: bool = False,
|
||||||
|
skip_step1: bool = False, skip_step3: bool = False) -> str | None:
|
||||||
|
uid = uuid.uuid4().hex
|
||||||
|
tmp = [] # 临时文件列表,最后统一清理
|
||||||
|
|
||||||
|
# ── 下载(URL 情况)──────────────────────────────────────
|
||||||
|
if src.startswith("http"):
|
||||||
|
import aiohttp
|
||||||
|
dl = os.path.join(tempfile.gettempdir(), f"pfix_dl_{uid}.jpg")
|
||||||
|
tmp.append(dl)
|
||||||
|
print("[下载] 原图中...")
|
||||||
|
async with aiohttp.ClientSession(headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||||
|
"Referer": "https://www.taobao.com/",
|
||||||
|
}) as sess:
|
||||||
|
async with sess.get(src, timeout=aiohttp.ClientTimeout(total=30)) as r:
|
||||||
|
if r.status != 200:
|
||||||
|
print(f"[下载] 失败: HTTP {r.status}")
|
||||||
|
return None
|
||||||
|
with open(dl, "wb") as f:
|
||||||
|
f.write(await r.read())
|
||||||
|
local_src = dl
|
||||||
|
else:
|
||||||
|
local_src = src
|
||||||
|
|
||||||
|
current = local_src # 当前处理中的文件
|
||||||
|
orig_img = cv2.imread(local_src) # 保留原图用于颜色匹配
|
||||||
|
# 记录原图宽高比,用于检测 Gemini 旋转问题
|
||||||
|
orig_ratio = (orig_img.shape[1] / orig_img.shape[0]) if orig_img is not None else 1.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── Step 1: Gemini 去背景 → 白背景 ──────────────────
|
||||||
|
if not skip_step1:
|
||||||
|
print("\n" + "─"*50)
|
||||||
|
print("Step 1 / 3 | Gemini 去背景 → 白色背景")
|
||||||
|
print("─"*50)
|
||||||
|
s1_out = os.path.join(tempfile.gettempdir(), f"pfix_s1_{uid}.jpg")
|
||||||
|
tmp.append(s1_out)
|
||||||
|
ok = await _gemini_call(current, s1_out, PROMPT_WHITE_BG,
|
||||||
|
aspect_ratio="auto", label="去背景")
|
||||||
|
if ok:
|
||||||
|
# 检查白色覆盖率,判断背景去除是否充分
|
||||||
|
s1_img = cv2.imread(s1_out)
|
||||||
|
white_pct = _measure_white_coverage(s1_img) if s1_img is not None else 0.0
|
||||||
|
print(f" [去背景] 白色覆盖率: {white_pct:.1%}", end="")
|
||||||
|
if white_pct < 0.20:
|
||||||
|
# 背景去除太差,用强化提示词重试
|
||||||
|
print(" → 太低,强化提示词重试...")
|
||||||
|
s1_retry = os.path.join(tempfile.gettempdir(), f"pfix_s1r_{uid}.jpg")
|
||||||
|
tmp.append(s1_retry)
|
||||||
|
ok2 = await _gemini_call(current, s1_retry, PROMPT_WHITE_BG_STRONG,
|
||||||
|
aspect_ratio="auto", label="去背景(强化)")
|
||||||
|
if ok2:
|
||||||
|
r_img = cv2.imread(s1_retry)
|
||||||
|
retry_pct = _measure_white_coverage(r_img) if r_img is not None else 0.0
|
||||||
|
print(f" [去背景] 重试白色覆盖率: {retry_pct:.1%}", end="")
|
||||||
|
if retry_pct >= white_pct:
|
||||||
|
print(" → 效果更好,采用重试结果")
|
||||||
|
current = s1_retry
|
||||||
|
else:
|
||||||
|
print(" → 效果未提升,保留首次结果")
|
||||||
|
current = s1_out
|
||||||
|
else:
|
||||||
|
print(" [去背景] 重试失败,保留首次结果")
|
||||||
|
current = s1_out
|
||||||
|
else:
|
||||||
|
print(" → 合格")
|
||||||
|
current = s1_out
|
||||||
|
else:
|
||||||
|
print(" Step1 失败,用原图继续")
|
||||||
|
else:
|
||||||
|
print("\n[跳过 Step1] 直接用原图")
|
||||||
|
|
||||||
|
# ── Step 2: OpenCV 在白背景图上检测+透视矫正 ─────────
|
||||||
|
print("\n" + "─"*50)
|
||||||
|
print("Step 2 / 3 | OpenCV 轮廓检测 + 透视矫正")
|
||||||
|
print("─"*50)
|
||||||
|
img = cv2.imread(current)
|
||||||
|
if img is None:
|
||||||
|
print(f" 无法读取: {current}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
print(f" 输入尺寸: {w}x{h}")
|
||||||
|
pts = find_quad(img)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
dbg_path = os.path.join(_OUTPUT_DIR, f"debug_{uid}.jpg")
|
||||||
|
save_debug_img(img, pts, dbg_path)
|
||||||
|
|
||||||
|
if pts is not None:
|
||||||
|
warped = four_point_transform(img, pts)
|
||||||
|
|
||||||
|
# ── 方向校正:Gemini 可能把图旋转 90°,需要纠正 ──
|
||||||
|
wh2, ww2 = warped.shape[:2]
|
||||||
|
warped_ratio = ww2 / wh2 # 宽/高
|
||||||
|
# 若原图横竖方向与矫正结果相反(比例差异超过 1.5 倍),旋转 90°
|
||||||
|
if orig_ratio > 1.0 and warped_ratio < 1.0 / 1.5:
|
||||||
|
# 原图横,结果竖 → 顺时针转 90°
|
||||||
|
warped = cv2.rotate(warped, cv2.ROTATE_90_CLOCKWISE)
|
||||||
|
print(f" [方向校正] 原图横({orig_ratio:.2f}) vs 矫正竖({warped_ratio:.2f}) → 旋转90°")
|
||||||
|
elif orig_ratio < 1.0 and warped_ratio > 1.5:
|
||||||
|
# 原图竖,结果横 → 逆时针转 90°
|
||||||
|
warped = cv2.rotate(warped, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
||||||
|
print(f" [方向校正] 原图竖({orig_ratio:.2f}) vs 矫正横({warped_ratio:.2f}) → 旋转-90°")
|
||||||
|
else:
|
||||||
|
print(f" [方向校正] 方向一致,无需旋转 (原图比例={orig_ratio:.2f}, 矫正比例={warped_ratio:.2f})")
|
||||||
|
|
||||||
|
s2_out = os.path.join(tempfile.gettempdir(), f"pfix_s2_{uid}.jpg")
|
||||||
|
tmp.append(s2_out)
|
||||||
|
cv2.imwrite(s2_out, warped, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||||
|
current = s2_out
|
||||||
|
wh2, ww2 = warped.shape[:2]
|
||||||
|
print(f" 透视矫正完成 → {ww2}x{wh2}")
|
||||||
|
else:
|
||||||
|
print(" 角点检测失败,跳过透视矫正,继续用白背景图")
|
||||||
|
|
||||||
|
# ── Step 3: Qwen 高清增强 ─────────────────────────────
|
||||||
|
if not skip_step3:
|
||||||
|
print("\n" + "─"*50)
|
||||||
|
print("Step 3 / 5 | Qwen 高清增强(RunningHub)")
|
||||||
|
print("─"*50)
|
||||||
|
final_out = os.path.join(_OUTPUT_DIR, f"pfix_final_{uid}.jpg")
|
||||||
|
from services.service_qwen import 清晰化_api
|
||||||
|
ok = await 清晰化_api(img_path=current, save_path=final_out)
|
||||||
|
if ok:
|
||||||
|
print(f" [高清增强] Qwen 成功")
|
||||||
|
else:
|
||||||
|
# Qwen 失败,用 Gemini 简化提示词兜底
|
||||||
|
print(" Qwen 失败,Gemini 兜底重试...")
|
||||||
|
ok = await _gemini_call(current, final_out, PROMPT_ENHANCE_SIMPLE,
|
||||||
|
aspect_ratio="auto", label="高清增强(Gemini兜底)")
|
||||||
|
if not ok:
|
||||||
|
print(" Step3 全部失败,直接保存矫正结果")
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(current, final_out)
|
||||||
|
else:
|
||||||
|
final_out = os.path.join(_OUTPUT_DIR, f"pfix_final_{uid}.jpg")
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(current, final_out)
|
||||||
|
print("\n[跳过 Step3] 直接保存矫正结果")
|
||||||
|
|
||||||
|
# ── Step 4: AI 决策 + 后处理(颜色匹配 & 白边裁切)────
|
||||||
|
print("\n" + "─"*50)
|
||||||
|
print("Step 4 / 4 | AI 决策后处理(颜色匹配 / 白边裁切)")
|
||||||
|
print("─"*50)
|
||||||
|
final_img = cv2.imread(final_out)
|
||||||
|
if final_img is not None and orig_img is not None:
|
||||||
|
decision = await ai_decide_postprocess(orig_img, final_img)
|
||||||
|
|
||||||
|
# Tool 1: 颜色匹配
|
||||||
|
if decision["need_color_match"]:
|
||||||
|
final_img = await tool_color_match(orig_img, final_img,
|
||||||
|
strength=decision["color_strength"])
|
||||||
|
cv2.imwrite(final_out, final_img, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||||
|
else:
|
||||||
|
print(" [颜色匹配] AI 判断无需调色,跳过")
|
||||||
|
|
||||||
|
# Tool 2: 白边裁切
|
||||||
|
if decision["need_trim"]:
|
||||||
|
trimmed, did_trim, _ = tool_trim_white_border(final_img)
|
||||||
|
if did_trim:
|
||||||
|
cv2.imwrite(final_out, trimmed, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||||
|
else:
|
||||||
|
print(" [裁边] AI 判断无白边,跳过")
|
||||||
|
else:
|
||||||
|
print(" [Step4] 图片读取失败,跳过后处理")
|
||||||
|
|
||||||
|
size_kb = os.path.getsize(final_out) / 1024
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f" 完成!输出文件: {final_out}")
|
||||||
|
print(f" 文件大小: {size_kb:.0f} KB")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
return final_out
|
||||||
|
|
||||||
|
finally:
|
||||||
|
for f in tmp:
|
||||||
|
if os.path.exists(f):
|
||||||
|
os.remove(f)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("用法: python perspective_fix.py <图片路径或URL> [--debug] [--skip-step1] [--skip-step3]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
src_arg = sys.argv[1]
|
||||||
|
debug_arg = "--debug" in sys.argv
|
||||||
|
skip1_arg = "--skip-step1" in sys.argv
|
||||||
|
skip3_arg = "--skip-step3" in sys.argv
|
||||||
|
asyncio.run(process(src_arg, debug=debug_arg, skip_step1=skip1_arg, skip_step3=skip3_arg))
|
||||||
223
logs/chat_2026-02-25.log
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
[15:17:16] Agent 正在处理消息...
|
||||||
|
[15:17:21] Agent 回复: 发图来我看看
|
||||||
|
[15:17:56] Agent 正在处理消息...
|
||||||
|
[15:19:02] Agent 回复: 30,拍下我安排
|
||||||
|
[15:19:02] Agent 正在处理消息...
|
||||||
|
[15:19:06] Agent 回复: 可以,30,拍下我安排
|
||||||
|
[15:21:19] Agent 正在处理消息...
|
||||||
|
[15:21:57] Agent 正在处理消息...
|
||||||
|
[15:22:26] Agent 回复: 无需额外回复,流程已完成。
|
||||||
|
[15:22:26] Agent 正在处理消息...
|
||||||
|
[15:22:36] Agent 回复: 40元,拍下我安排
|
||||||
|
[15:28:22] Agent 正在处理消息...
|
||||||
|
[15:28:32] Agent 回复: 发图来我看看
|
||||||
|
[15:34:13] Agent 正在处理消息...
|
||||||
|
[15:34:16] Agent 回复: 发图来我看看
|
||||||
|
[15:36:29] Agent 正在处理消息...
|
||||||
|
[15:36:32] Agent 回复: 在呢
|
||||||
|
[15:36:49] Agent 正在处理消息...
|
||||||
|
[15:36:58] Agent 回复: 30,拍下发你
|
||||||
|
[15:37:15] Agent 正在处理消息...
|
||||||
|
[15:37:21] Agent 回复: 拍下发你,不好退
|
||||||
|
[15:38:22] Agent 正在处理消息...
|
||||||
|
[15:38:24] Agent 回复: 30,拍下发你
|
||||||
|
[15:39:14] Agent 正在处理消息...
|
||||||
|
[15:39:16] Agent 回复: 拍下发你,不好退
|
||||||
|
[17:20:40] Agent 正在处理消息...
|
||||||
|
[17:20:42] Agent 回复: 在呢
|
||||||
|
[17:21:04] Agent 正在处理消息...
|
||||||
|
[17:21:23] Agent 回复: 已经完成,不需要额外回复了。
|
||||||
|
[17:21:23] Agent 正在处理消息...
|
||||||
|
[17:21:26] Agent 回复: 拍下发你,不满意退款
|
||||||
|
[17:21:42] Agent 正在处理消息...
|
||||||
|
[17:21:44] Agent 回复: 30,拍下吧
|
||||||
|
[17:26:08] Agent 正在处理消息...
|
||||||
|
[17:26:12] Agent 回复: 在呢,啥事
|
||||||
|
[17:26:17] Agent 正在处理消息...
|
||||||
|
[17:26:23] Agent 回复: 发图来看看
|
||||||
|
[17:26:41] Agent 正在处理消息...
|
||||||
|
[17:26:58] Agent 回复: 25,拍下发你
|
||||||
|
[17:38:48] Agent 正在处理消息...
|
||||||
|
[17:38:53] Agent 回复: 在呢,发图我看看。
|
||||||
|
[17:38:53] Agent 正在处理消息...
|
||||||
|
[17:38:55] Agent 回复: 已收到您的订单,付款后我马上安排哈
|
||||||
|
[17:38:55] Agent 正在处理消息...
|
||||||
|
[17:38:58] Agent 回复: 图发过来我看看。
|
||||||
|
[17:38:58] Agent 正在处理消息...
|
||||||
|
[17:39:01] Agent 回复: 已经收到付款啦,把需要的图发我吧。
|
||||||
|
[17:57:48] Agent 正在处理消息...
|
||||||
|
[17:57:52] Agent 处理失败: name 'asyncio' is not defined
|
||||||
|
[17:59:06] Agent 正在处理消息...
|
||||||
|
[17:59:09] Agent 回复: 在呢,啥事
|
||||||
|
[17:59:23] Agent 正在处理消息...
|
||||||
|
[17:59:31] Agent 回复: 发图来看看
|
||||||
|
[17:59:31] Agent 正在处理消息...
|
||||||
|
[17:59:35] Agent 回复: 发图来看看
|
||||||
|
[18:01:52] Agent 正在处理消息...
|
||||||
|
[18:02:05] Agent 回复含无效内容,已拦截: 已经完成回复,不需要额外操作。
|
||||||
|
[18:05:02] Agent 正在处理消息...
|
||||||
|
[18:05:05] Agent 回复: 在呢
|
||||||
|
[18:05:20] Agent 正在处理消息...
|
||||||
|
[18:05:37] Agent 回复含无效内容,已拦截: 不需要额外回复,已完成报价和记录。
|
||||||
|
[18:08:56] Agent 正在处理消息...
|
||||||
|
[18:09:02] Agent 回复: 发图来我看看
|
||||||
|
[18:09:02] Agent 正在处理消息...
|
||||||
|
[18:09:05] Agent 回复: 已收到您的订单,付款后我马上安排哈
|
||||||
|
[18:09:52] Agent 正在处理消息...
|
||||||
|
[18:09:57] Agent 回复: 发图来看看
|
||||||
|
[18:09:57] Agent 正在处理消息...
|
||||||
|
[18:10:02] Agent 回复: 已收到,付款后我马上安排哈
|
||||||
|
[18:10:47] Agent 正在处理消息...
|
||||||
|
[18:10:49] Agent 回复: 发图过来我看看
|
||||||
|
[18:10:50] Agent 正在处理消息...
|
||||||
|
[18:10:53] Agent 回复: 已收到您的订单,付款后我马上安排哈
|
||||||
|
[18:10:55] Agent 正在处理消息...
|
||||||
|
[18:10:58] Agent 回复: 把图发我看看哦
|
||||||
|
[18:10:58] Agent 正在处理消息...
|
||||||
|
[18:11:01] Agent 回复: 收到付款,我马上安排。
|
||||||
|
[18:18:20] Agent 正在处理消息...
|
||||||
|
[18:18:23] Agent 回复: 在呢
|
||||||
|
[18:20:11] Agent 正在处理消息...
|
||||||
|
[18:20:14] Agent 回复: 在呢
|
||||||
|
[18:20:48] Agent 正在处理消息...
|
||||||
|
[18:21:17] Agent 回复: 30,拍下我安排
|
||||||
|
[18:21:17] Agent 正在处理消息...
|
||||||
|
[18:21:20] Agent 回复: 拍下发你,不满意退款
|
||||||
|
[18:22:12] Agent 正在处理消息...
|
||||||
|
[18:22:14] Agent 回复: 发图来我看看
|
||||||
|
[18:22:15] Agent 正在处理消息...
|
||||||
|
[18:22:15] Agent 决定不回复此消息
|
||||||
|
[18:22:30] Agent 正在处理消息...
|
||||||
|
[18:22:33] Agent 回复: 发图来我看看
|
||||||
|
[18:22:33] Agent 正在处理消息...
|
||||||
|
[18:22:35] Agent 回复: 收到款了,马上安排。
|
||||||
|
[18:22:46] Agent 正在处理消息...
|
||||||
|
[18:22:49] Agent 回复: 发图来看看
|
||||||
|
[18:22:54] Agent 正在处理消息...
|
||||||
|
[18:22:56] Agent 回复: 收到了,马上弄
|
||||||
|
[18:30:27] Agent 正在处理消息...
|
||||||
|
[18:30:29] Agent 回复: 在呢
|
||||||
|
[18:31:40] Agent 正在处理消息...
|
||||||
|
[18:31:57] Agent 回复: 30,拍下发你
|
||||||
|
[18:31:57] Agent 正在处理消息...
|
||||||
|
[18:32:00] Agent 回复: 拍下我安排
|
||||||
|
[18:32:16] Agent 正在处理消息...
|
||||||
|
[18:32:18] Agent 回复: 发图来我看看
|
||||||
|
[18:32:19] Agent 正在处理消息...
|
||||||
|
[18:32:19] Agent 决定不回复此消息
|
||||||
|
[18:32:20] Agent 正在处理消息...
|
||||||
|
[18:32:23] Agent 回复: 图发我看看
|
||||||
|
[18:32:24] Agent 正在处理消息...
|
||||||
|
[18:32:26] Agent 回复: 收到了,马上安排
|
||||||
|
[18:35:54] Agent 正在处理消息...
|
||||||
|
[18:35:56] Agent 回复: 在呢
|
||||||
|
[18:39:30] Agent 正在处理消息...
|
||||||
|
[18:39:33] Agent 回复: 在呢
|
||||||
|
[18:39:45] Agent 正在处理消息...
|
||||||
|
[18:40:00] Agent 回复含无效内容,已拦截: 已经完成,无需额外回复。 ← 命中pattern: ['无需', '无需额外', '已经完成']
|
||||||
|
[18:40:18] Agent 正在处理消息...
|
||||||
|
[18:40:24] Agent 回复: 发图来看看
|
||||||
|
[18:40:24] Agent 正在处理消息...
|
||||||
|
[18:40:24] Agent 决定不回复此消息
|
||||||
|
[18:40:24] Agent 正在处理消息...
|
||||||
|
[18:40:26] Agent 回复: 发图来看看
|
||||||
|
[18:40:26] Agent 正在处理消息...
|
||||||
|
[18:40:29] Agent 回复: 收到了,马上发你
|
||||||
|
[18:45:45] Agent 正在处理消息...
|
||||||
|
[18:45:47] Agent 回复: 发图来我看看
|
||||||
|
[18:46:02] Agent 正在处理消息...
|
||||||
|
[18:46:04] Agent 回复: 图发我看看
|
||||||
|
[18:46:14] Agent 正在处理消息...
|
||||||
|
[18:46:14] Agent 决定不回复此消息
|
||||||
|
[18:46:48] Agent 正在处理消息...
|
||||||
|
[18:47:00] Agent 回复: 25,拍下发你
|
||||||
|
[19:02:22] Agent 正在处理消息...
|
||||||
|
[19:02:25] Agent 回复: 在呢
|
||||||
|
[19:02:38] Agent 正在处理消息...
|
||||||
|
[19:02:54] Agent 回复: 30,拍下发你
|
||||||
|
[19:02:54] Agent 正在处理消息...
|
||||||
|
[19:03:02] Agent 回复: 拍下我马上安排
|
||||||
|
[19:03:02] Agent 正在处理消息...
|
||||||
|
[19:03:06] Agent 回复: 拍下我安排,好了发你。
|
||||||
|
[19:03:31] Agent 正在处理消息...
|
||||||
|
[19:03:31] Agent 决定不回复此消息
|
||||||
|
[19:03:37] Agent 正在处理消息...
|
||||||
|
[19:03:39] Agent 回复: 收到,马上给你安排。
|
||||||
|
[19:06:46] Agent 正在处理消息...
|
||||||
|
[19:06:46] Agent 决定不回复此消息
|
||||||
|
[19:07:13] Agent 正在处理消息...
|
||||||
|
[19:07:13] Agent 决定不回复此消息
|
||||||
|
[19:15:06] Agent 正在处理消息...
|
||||||
|
[19:15:11] Agent 回复: 没什么,拍下我尽快发你哈。
|
||||||
|
[19:15:26] Agent 正在处理消息...
|
||||||
|
[19:15:26] Agent 决定不回复此消息
|
||||||
|
[19:17:17] Agent 正在处理消息...
|
||||||
|
[19:17:24] Agent 回复: 在呢
|
||||||
|
[19:17:41] Agent 正在处理消息...
|
||||||
|
[19:18:00] Agent 回复: 30,拍下我安排
|
||||||
|
[19:18:00] Agent 正在处理消息...
|
||||||
|
[19:18:10] Agent 回复: 拍下我马上发你
|
||||||
|
[19:18:51] Agent 正在处理消息...
|
||||||
|
[19:18:51] Agent 决定不回复此消息
|
||||||
|
[19:19:41] Agent 正在处理消息...
|
||||||
|
[19:19:41] Agent 决定不回复此消息
|
||||||
|
[19:19:59] Agent 正在处理消息...
|
||||||
|
[19:19:59] Agent 决定不回复此消息
|
||||||
|
[19:21:44] Agent 正在处理消息...
|
||||||
|
[19:21:48] Agent 回复: 在做了,快了
|
||||||
|
[19:22:08] Agent 正在处理消息...
|
||||||
|
[19:22:22] Agent 回复: 满意,拍下我尽快发你。
|
||||||
|
[19:22:50] Agent 正在处理消息...
|
||||||
|
[19:22:55] Agent 回复: 拍下吧,我马上安排。
|
||||||
|
[19:29:37] Agent 正在处理消息...
|
||||||
|
[19:29:44] Agent 回复: 在呢,发图来看看
|
||||||
|
[19:30:30] Agent 正在处理消息...
|
||||||
|
[19:30:30] Agent 决定不回复此消息
|
||||||
|
[19:30:41] Agent 正在处理消息...
|
||||||
|
[19:30:41] Agent 决定不回复此消息
|
||||||
|
[19:31:37] Agent 正在处理消息...
|
||||||
|
[19:31:37] Agent 决定不回复此消息
|
||||||
|
[19:31:47] Agent 正在处理消息...
|
||||||
|
[19:31:47] Agent 决定不回复此消息
|
||||||
|
[19:32:07] Agent 正在处理消息...
|
||||||
|
[19:32:25] Agent 回复: 25,拍下我安排
|
||||||
|
[19:33:03] Agent 正在处理消息...
|
||||||
|
[19:33:03] Agent 决定不回复此消息
|
||||||
|
[19:33:07] Agent 正在处理消息...
|
||||||
|
[19:33:10] Agent 回复: 收到了,马上安排
|
||||||
|
[19:33:32] Agent 正在处理消息...
|
||||||
|
[19:33:34] Agent 回复: 发图来我看看
|
||||||
|
[19:33:40] Agent 正在处理消息...
|
||||||
|
[19:33:40] [Workflow] AI 通知已发送: 图发你了,先看下效果,没问题把邮箱发我我给你发过来。
|
||||||
|
[19:33:42] Agent 回复: 把你现在的图发我看看
|
||||||
|
[19:33:48] Agent 正在处理消息...
|
||||||
|
[22:02:37] Agent 正在处理消息...
|
||||||
|
[22:02:55] Agent 回复: 17,拍下我安排
|
||||||
|
[22:03:12] Agent 正在处理消息...
|
||||||
|
[22:03:49] Agent 回复: 两张50,拍下
|
||||||
|
[22:03:53] Agent 正在处理消息...
|
||||||
|
[22:03:55] Agent 回复: 拍下后发你确认,不满意退款。
|
||||||
|
[22:09:51] Agent 正在处理消息...
|
||||||
|
[22:44:15] Agent 正在处理消息...
|
||||||
|
[22:44:18] Agent 回复: 在呢
|
||||||
|
[23:17:53] Agent 正在处理消息...
|
||||||
|
[23:17:56] Agent 回复: 发图来我看看
|
||||||
|
[23:18:21] Agent 正在处理消息...
|
||||||
|
[23:19:00] Agent 回复含无效内容,已拦截: 不需要额外操作,等待客户回复即可。 ← 命中pattern: ['不需要额外']
|
||||||
|
[23:19:18] Agent 正在处理消息...
|
||||||
|
[23:19:53] Agent 回复: 20,拍下我安排
|
||||||
|
[23:20:30] Agent 正在处理消息...
|
||||||
|
[23:20:40] Agent 正在处理消息...
|
||||||
|
[23:20:44] Agent 正在处理消息...
|
||||||
|
[23:20:47] Agent 回复: 什么问题,你说清楚点
|
||||||
|
[23:21:03] Agent 正在处理消息...
|
||||||
|
[23:21:08] Agent 正在处理消息...
|
||||||
|
[23:21:12] Agent 回复: 20,拍下我发你
|
||||||
|
[23:21:43] Agent 正在处理消息...
|
||||||
|
[23:21:49] Agent 回复: 15,真的最低了,拍下吧
|
||||||
|
[00:16:18] Agent 正在处理消息...
|
||||||
|
[00:16:43] Agent 回复含无效内容,已拦截: 已经完成报价和记录,无需额外回复。 ← 命中pattern: ['无需', '无需额外', '已经完成']
|
||||||
|
[00:16:47] Agent 正在处理消息...
|
||||||
|
[00:16:47] Agent 决定不回复此消息
|
||||||
|
[00:18:45] Agent 正在处理消息...
|
||||||
|
[00:18:45] Agent 决定不回复此消息
|
||||||
131
logs/chat_2026-02-26.log
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
[09:51:23] Agent 正在处理消息...
|
||||||
|
[09:51:29] Agent 回复: 基本都可以,你发图来看看。
|
||||||
|
[09:51:38] Agent 正在处理消息...
|
||||||
|
[09:51:46] Agent 回复: 你发图过来先看看。
|
||||||
|
[09:52:01] Agent 正在处理消息...
|
||||||
|
[10:41:14] Agent 正在处理消息...
|
||||||
|
[10:41:20] Agent 回复: 在呢,发图吧,做多张有优惠。
|
||||||
|
[10:44:58] Agent 正在处理消息...
|
||||||
|
[10:45:02] Agent 回复: 店家呀,你要找图直接发图就好啦。
|
||||||
|
[10:45:25] Agent 正在处理消息...
|
||||||
|
[10:45:29] Agent 回复: 稍等,我查一下给你重发哈。
|
||||||
|
[10:51:25] Agent 正在处理消息...
|
||||||
|
[10:51:28] Agent 回复: 发图来我看看哈
|
||||||
|
[10:51:34] Agent 正在处理消息...
|
||||||
|
[10:52:11] Agent 回复: 25,拍下发你,人脸可能会有轻微变化,不满意可退。
|
||||||
|
[10:53:49] Agent 正在处理消息...
|
||||||
|
[10:53:56] Agent 回复: 收到啦,麻烦把要处理的图发我一下哈。
|
||||||
|
[10:54:39] Agent 正在处理消息...
|
||||||
|
[10:54:39] Agent 决定不回复此消息
|
||||||
|
[10:54:47] Agent 正在处理消息...
|
||||||
|
[10:54:50] Agent 回复: 是高清优化难免的,整体效果会好很多,拍下给你安排。
|
||||||
|
[10:55:37] Agent 正在处理消息...
|
||||||
|
[10:55:39] Agent 回复: 半小时内就能好,拍下安排哈。
|
||||||
|
[10:55:48] Agent 正在处理消息...
|
||||||
|
[10:55:55] Agent 回复: 给你便宜5块,20,拍下吧。
|
||||||
|
[10:56:13] [Workflow] AI 通知已发送: 图发你预览了,看下效果,没问题把邮箱发我我给你发原图。
|
||||||
|
[10:57:13] Agent 正在处理消息...
|
||||||
|
[10:57:13] Agent 决定不回复此消息
|
||||||
|
[10:58:06] Agent 正在处理消息...
|
||||||
|
[10:58:10] Agent 回复: 这已经是最低啦,要做我就给你安排了。
|
||||||
|
[11:00:20] Agent 正在处理消息...
|
||||||
|
[11:00:50] Agent 回复: 25,拍下发你。
|
||||||
|
[11:00:54] Agent 正在处理消息...
|
||||||
|
[11:01:01] Agent 回复: 已安排,好了发你。
|
||||||
|
[11:01:55] Agent 正在处理消息...
|
||||||
|
[11:01:59] Agent 回复: 收到了,马上安排,好了发你。
|
||||||
|
[11:02:11] [Workflow] AI 通知已发送: 图发好了,你看下效果,没问题把邮箱发我给你发过去。
|
||||||
|
[11:52:54] Agent 正在处理消息...
|
||||||
|
[11:53:00] Agent 回复: 得重新续费pro套餐了,直接拍店里对应的续费套餐就行。
|
||||||
|
[13:06:48] Agent 正在处理消息...
|
||||||
|
[13:06:52] Agent 回复: 快了,好了发你。
|
||||||
|
[13:07:30] Agent 正在处理消息...
|
||||||
|
[13:07:35] Agent 回复: 可以找,你发相关信息过来看看。
|
||||||
|
[13:07:50] Agent 正在处理消息...
|
||||||
|
[13:08:19] Agent 回复: 这张25,拍下发你,人脸会有轻微变化,介意的话可以考虑下哦。
|
||||||
|
[13:08:19] Agent 正在处理消息...
|
||||||
|
[13:08:23] Agent 回复: 都发过来吧,我一起看看给你算打包价。
|
||||||
|
[13:08:52] Agent 正在处理消息...
|
||||||
|
[13:09:18] Agent 回复: 这类不做哦。
|
||||||
|
[13:09:40] Agent 正在处理消息...
|
||||||
|
[13:09:44] Agent 回复: 放心拍,不满意包退。
|
||||||
|
[13:10:05] Agent 正在处理消息...
|
||||||
|
[13:10:08] Agent 回复: 那拍第一张就行,25,拍下发你,不满意退。
|
||||||
|
[14:43:25] Agent 正在处理消息...
|
||||||
|
[14:43:32] Agent 回复: 看到订单了,请问具体是遇到什么问题了呢?
|
||||||
|
[14:44:00] Agent 正在处理消息...
|
||||||
|
[14:44:33] Agent 回复: 15,拍下发你。
|
||||||
|
[14:46:07] Agent 正在处理消息...
|
||||||
|
[14:46:11] Agent 回复: pro权限到期了需要重新续费哦,当前pro月套餐是50元,直接拍下续费就可以继续使用了。
|
||||||
|
[14:56:57] Agent 正在处理消息...
|
||||||
|
[14:57:39] Agent 回复: 25,拍下发你。
|
||||||
|
[14:57:39] Agent 正在处理消息...
|
||||||
|
[14:57:51] Agent 回复: 放心拍,拍下就发,不满意退。
|
||||||
|
[15:34:46] Agent 正在处理消息...
|
||||||
|
[15:35:07] Agent 回复: 25,拍下发你。
|
||||||
|
[17:28:45] Agent 正在处理消息...
|
||||||
|
[17:28:51] Agent 回复: 1块做不了哦,最低10块,要做发图看看。
|
||||||
|
[18:02:42] Agent 正在处理消息...
|
||||||
|
[18:02:42] Agent 决定不回复此消息
|
||||||
|
[18:02:48] Agent 正在处理消息...
|
||||||
|
[18:02:53] Agent 回复: 麻烦把需要处理的图片发我一下哈。
|
||||||
|
[18:03:32] Agent 正在处理消息...
|
||||||
|
[18:03:39] Agent 正在处理消息...
|
||||||
|
[18:03:42] Agent 回复: 快了,马上发你哈。
|
||||||
|
[18:39:55] Agent 正在处理消息...
|
||||||
|
[18:39:55] Agent 决定不回复此消息
|
||||||
|
[18:41:08] Agent 正在处理消息...
|
||||||
|
[18:41:15] Agent 回复: 你把需要的图片发我一下哈。
|
||||||
|
[18:41:24] Agent 正在处理消息...
|
||||||
|
[18:41:29] Agent 回复: 稍等我看看哈。
|
||||||
|
[18:49:34] Agent 正在处理消息...
|
||||||
|
[18:49:41] Agent 回复: 没事,客人要了直接拍就行,还是这个价。
|
||||||
|
[18:49:46] Agent 正在处理消息...
|
||||||
|
[18:50:12] Agent 回复: 这张25,拍下发你。
|
||||||
|
[18:50:12] Agent 正在处理消息...
|
||||||
|
[18:50:21] Agent 回复: 两张一共45,拍下一起发。
|
||||||
|
[19:00:10] Agent 正在处理消息...
|
||||||
|
[19:00:31] Agent 回复: 15,拍下吧
|
||||||
|
[19:00:47] Agent 正在处理消息...
|
||||||
|
[19:01:16] Agent 回复: 这张25,两张一共40,一起拍了吧
|
||||||
|
[19:01:33] Agent 正在处理消息...
|
||||||
|
[19:01:39] Agent 回复: 35,两块一起拍了吧
|
||||||
|
[19:01:49] Agent 正在处理消息...
|
||||||
|
[19:01:52] Agent 回复: 你拍下我改价,改好了付款就行。
|
||||||
|
[19:02:04] Agent 正在处理消息...
|
||||||
|
[19:02:04] Agent 决定不回复此消息
|
||||||
|
[19:02:19] Agent 正在处理消息...
|
||||||
|
[19:02:28] Agent 回复: 收到了,马上弄好发你。
|
||||||
|
[19:03:50] [Workflow] AI 通知已发送: 图发你了,看下效果没问题把邮箱发我,我发你文件。
|
||||||
|
[19:05:22] Agent 正在处理消息...
|
||||||
|
[19:05:25] Agent 回复: 把图发过来吧,我这边好马上开始弄。
|
||||||
|
[19:11:19] Agent 正在处理消息...
|
||||||
|
[19:11:22] Agent 回复: 放心拍,弄好都给你处理干净,不满意退。
|
||||||
|
[19:14:54] Agent 正在处理消息...
|
||||||
|
[19:15:00] Workflow 回复: 好,你说一下哪里要改,或者发图告诉我
|
||||||
|
[19:15:15] Agent 正在处理消息...
|
||||||
|
[19:15:18] Agent 回复: 发图来我看看。
|
||||||
|
[19:15:19] Agent 正在处理消息...
|
||||||
|
[19:15:24] Agent 回复: 已经处理好了,预览图发你了,把邮箱给我,我发你原图文件。
|
||||||
|
[19:15:31] Agent 正在处理消息...
|
||||||
|
[19:15:34] Agent 回复: 你把邮箱地址发我就行,我发你原图文件哈。
|
||||||
|
[19:15:50] Agent 正在处理消息...
|
||||||
|
[19:16:20] Agent 回复: 这张25,拍下就行。
|
||||||
|
[19:16:47] Agent 正在处理消息...
|
||||||
|
[19:16:54] Agent 回复: 好的,尺寸改好了,拍下一起发。
|
||||||
|
[19:17:03] Agent 正在处理消息...
|
||||||
|
[19:17:07] Agent 回复: 知道了,会按这个要求帮你弄好,放心拍就行。
|
||||||
|
[19:18:16] Agent 正在处理消息...
|
||||||
|
[19:18:25] Agent 正在处理消息...
|
||||||
|
[19:18:30] Agent 回复: 给你算40,这已经很优惠了,拍下吧。
|
||||||
|
[19:19:31] Agent 正在处理消息...
|
||||||
|
[19:19:31] Agent 决定不回复此消息
|
||||||
|
[19:19:37] Agent 正在处理消息...
|
||||||
|
[19:19:44] Agent 回复: 已经收到款了,我马上安排,弄好发给你。
|
||||||
|
[19:19:53] Agent 正在处理消息...
|
||||||
|
[19:19:56] Agent 回复: 放心哈,我正在处理,很快就能弄好发你。
|
||||||
|
[19:21:56] [Workflow] AI 通知已发送: 已经处理好啦,你先看看效果,没问题把邮箱发我,我发你原图哦。
|
||||||
|
[19:22:59] Agent 正在处理消息...
|
||||||
|
[19:22:59] Agent 决定不回复此消息
|
||||||
|
[20:19:01] Agent 正在处理消息...
|
||||||
|
[20:19:06] Agent 回复: 没事,想要了直接拍下就行,不满意包退哈。
|
||||||
136
logs/chat_2026-02-27.log
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
[10:22:10] Agent 正在处理消息...
|
||||||
|
[10:22:36] Agent 回复: 25,拍下吧。
|
||||||
|
[10:25:57] Agent 正在处理消息...
|
||||||
|
[10:26:27] Agent 回复含无效内容,已拦截: 不需要额外回复,等待客户下一步即可。 ← 命中pattern: ['不需要额外']
|
||||||
|
[10:26:27] Agent 正在处理消息...
|
||||||
|
[10:26:54] Agent 回复含无效内容,已拦截: 不需要额外操作,等待客户回应即可。 ← 命中pattern: ['不需要额外']
|
||||||
|
[10:30:21] Agent 正在处理消息...
|
||||||
|
[10:30:25] Agent 回复: 放心拍,不满意退。
|
||||||
|
[10:32:59] Agent 正在处理消息...
|
||||||
|
[10:33:28] Agent 回复: 15,拍下发你。
|
||||||
|
[10:33:28] Agent 正在处理消息...
|
||||||
|
[10:33:32] Agent 回复: 15拍下,马上安排。
|
||||||
|
[10:37:29] Agent 正在处理消息...
|
||||||
|
[10:37:29] Agent 决定不回复此消息
|
||||||
|
[10:37:34] Agent 正在处理消息...
|
||||||
|
[10:37:39] Agent 回复: 收到啦,马上帮你弄,好了发你。
|
||||||
|
[10:37:54] [Workflow] AI 通知已发送: 图已经发你啦,你看下效果,没问题把邮箱发我我发你原文件。
|
||||||
|
[11:10:58] Agent 正在处理消息...
|
||||||
|
[11:11:00] Agent 正在处理消息...
|
||||||
|
[11:11:02] Agent 回复: 在呢
|
||||||
|
[11:11:20] Agent 正在处理消息...
|
||||||
|
[11:11:27] Agent 回复: 这个不接哦,我们只做图片相关的哈
|
||||||
|
[11:11:40] Agent 回复: 15,拍下发你哈。
|
||||||
|
[11:11:40] Agent 正在处理消息...
|
||||||
|
[11:11:46] Agent 正在处理消息...
|
||||||
|
[11:11:47] Agent 回复: 总共20,拍下弄好发你。
|
||||||
|
[11:11:49] Agent 回复: 发图来看看
|
||||||
|
[11:11:58] Agent 正在处理消息...
|
||||||
|
[11:12:01] Agent 回复: 知道了,20搞定,拍下吧。
|
||||||
|
[11:12:23] Agent 正在处理消息...
|
||||||
|
[11:12:26] Agent 回复: 没问题,拍下安排。
|
||||||
|
[11:12:47] Agent 正在处理消息...
|
||||||
|
[11:12:55] Agent 回复: 行吧,10就10,拍下吧。
|
||||||
|
[11:13:19] Agent 正在处理消息...
|
||||||
|
[11:13:22] Agent 回复: 直接拍这个宝贝就行,拍下备注好要求。
|
||||||
|
[11:13:50] Agent 正在处理消息...
|
||||||
|
[11:14:19] Agent 回复: 已经处理好了,等待客户回应就行。
|
||||||
|
[11:14:19] Agent 正在处理消息...
|
||||||
|
[11:14:24] Agent 回复: 25,拍下就能安排了
|
||||||
|
[11:14:37] Agent 正在处理消息...
|
||||||
|
[11:14:42] Agent 回复: 出版信息我们找不到哦,我们只做图片高清处理,要做直接拍下。
|
||||||
|
[11:14:49] Agent 正在处理消息...
|
||||||
|
[11:14:53] Agent 回复: 高清图我们可以安排,拍下试试,不满意退。
|
||||||
|
[11:15:04] Agent 正在处理消息...
|
||||||
|
[11:15:13] Agent 正在处理消息...
|
||||||
|
[11:15:13] Agent 决定不回复此消息
|
||||||
|
[11:22:03] Agent 正在处理消息...
|
||||||
|
[11:22:08] Agent 回复: 直接在这里下单就行,拍下我就弄。
|
||||||
|
[11:23:01] Agent 正在处理消息...
|
||||||
|
[11:23:01] Agent 决定不回复此消息
|
||||||
|
[11:25:09] Agent 正在处理消息...
|
||||||
|
[11:25:15] Agent 回复: 看图片情况呢,你发图我给你说具体价格。
|
||||||
|
[11:26:05] Agent 正在处理消息...
|
||||||
|
[12:32:22] Agent 正在处理消息...
|
||||||
|
[12:32:25] Agent 回复: 发图来我看看
|
||||||
|
[12:39:48] Agent 正在处理消息...
|
||||||
|
[12:40:28] Agent 回复含无效内容,已拦截: 已经回复客户报价了,不需要额外操作。 ← 命中pattern: ['不需要额外']
|
||||||
|
[12:40:28] Agent 正在处理消息...
|
||||||
|
[12:41:01] Agent 回复含无效内容,已拦截: 已经处理完成,不需要额外操作。 ← 命中pattern: ['不需要额外']
|
||||||
|
[12:41:01] Agent 正在处理消息...
|
||||||
|
[12:41:07] Agent 回复: 拍下发你,不满意可退,还有其他图一起发的话有打包优惠。
|
||||||
|
[12:41:20] Agent 正在处理消息...
|
||||||
|
[12:41:23] Agent 回复: 发图来我看看。
|
||||||
|
[12:44:10] Agent 正在处理消息...
|
||||||
|
[12:44:49] Agent 回复含无效内容,已拦截: 已经给客户回复了报价,记录完成,不需要额外操作。 ← 命中pattern: ['不需要额外', '记录完成']
|
||||||
|
[12:44:49] Agent 正在处理消息...
|
||||||
|
[12:44:53] Agent 回复: 可以,拍下发你,不满意退。
|
||||||
|
[12:46:55] Agent 正在处理消息...
|
||||||
|
[12:46:59] Agent 回复: 放心拍,没问题,不满意退哈。
|
||||||
|
[12:49:20] Agent 正在处理消息...
|
||||||
|
[12:49:22] Agent 回复: 快了,好了发你哈
|
||||||
|
[12:55:59] Agent 正在处理消息...
|
||||||
|
[12:56:02] Agent 回复: 发图过来我先看看哈。
|
||||||
|
[12:56:12] Agent 正在处理消息...
|
||||||
|
[12:56:17] Agent 回复: 图发我看看哦,做好给你改到这个尺寸。
|
||||||
|
[12:56:20] Agent 正在处理消息...
|
||||||
|
[12:56:49] Agent 回复含无效内容,已拦截: 不需要额外回复,之前已经发送了报价给客户。 ← 命中pattern: ['不需要额外']
|
||||||
|
[12:57:22] Agent 正在处理消息...
|
||||||
|
[12:57:43] Agent 回复含无效内容,已拦截: 不需要额外回复,已经给出了报价和打包优惠的信息。 ← 命中pattern: ['不需要额外']
|
||||||
|
[12:57:49] Agent 正在处理消息...
|
||||||
|
[12:57:54] Agent 回复: 知道啦,拍下我就开始安排了。
|
||||||
|
[12:58:22] Agent 正在处理消息...
|
||||||
|
[12:58:26] Agent 回复: 直接拍下这个商品就行,今天就能弄好,你上来我发你。
|
||||||
|
[13:01:02] Agent 正在处理消息...
|
||||||
|
[13:01:05] Agent 回复: 稍等哈
|
||||||
|
[13:44:03] Agent 正在处理消息...
|
||||||
|
[13:44:29] Agent 正在处理消息...
|
||||||
|
[13:44:31] Agent 回复: 在呢
|
||||||
|
[13:44:39] Agent 回复: 已经帮你记录好报价啦,等待客户进一步回复即可。
|
||||||
|
[13:44:39] Agent 正在处理消息...
|
||||||
|
[13:44:43] Agent 回复: 还是这张对吧,25拍下就行。
|
||||||
|
[13:47:49] Agent 正在处理消息...
|
||||||
|
[13:47:54] Agent 回复: 稍等,我找一下马上发你
|
||||||
|
[13:49:10] Agent 正在处理消息...
|
||||||
|
[13:49:13] Agent 回复: 马上好
|
||||||
|
[13:50:23] Agent 正在处理消息...
|
||||||
|
[13:50:39] Agent 回复: 25,拍下发你。
|
||||||
|
[13:50:39] Agent 正在处理消息...
|
||||||
|
[13:50:59] Agent 回复: 两张打包50,拍下吧。
|
||||||
|
[14:36:40] Agent 正在处理消息...
|
||||||
|
[14:37:11] Agent 回复含无效内容,已拦截: 已经处理完成,无需额外回复。 ← 命中pattern: ['无需', '无需额外']
|
||||||
|
[14:37:45] Agent 正在处理消息...
|
||||||
|
[14:37:54] Agent 正在处理消息...
|
||||||
|
[14:37:57] Agent 回复: 没问题,拍下安排。
|
||||||
|
[15:19:46] Agent 正在处理消息...
|
||||||
|
[15:20:14] Agent 回复含无效内容,已拦截: 已经完成回复,无需额外内容。 ← 命中pattern: ['无需', '无需额外', '已经完成']
|
||||||
|
[15:20:15] Agent 正在处理消息...
|
||||||
|
[15:20:17] Agent 回复: 好的,拍下安排哈。
|
||||||
|
[15:27:19] Agent 正在处理消息...
|
||||||
|
[15:27:46] Agent 回复: 25,拍下发你。
|
||||||
|
[15:31:52] Agent 正在处理消息...
|
||||||
|
[15:31:57] Agent 回复: 在呢,有需要发图看看哈。
|
||||||
|
[15:32:07] Agent 正在处理消息...
|
||||||
|
[15:32:39] Agent 回复: 这张25,拍下哈。
|
||||||
|
[15:32:46] Agent 正在处理消息...
|
||||||
|
[15:32:56] Agent 回复: 没问题,可以给到这个要求,拍下安排吧。
|
||||||
|
[15:33:07] Agent 正在处理消息...
|
||||||
|
[15:33:10] Agent 回复: 放心拍,发你高清版,不满意退哈。
|
||||||
|
[15:33:36] Agent 正在处理消息...
|
||||||
|
[15:33:40] Agent 回复: 都是拍下发哦,不满意直接退,放心哈。
|
||||||
|
[15:34:24] Agent 正在处理消息...
|
||||||
|
[15:34:28] Agent 回复: 拍下后不满意直接退哈,没风险的。
|
||||||
|
[15:35:04] Agent 正在处理消息...
|
||||||
|
[15:35:09] Agent 回复: 我们这边都是先拍下再安排哦,不满意直接退款,没损失的。
|
||||||
|
[15:35:51] Agent 正在处理消息...
|
||||||
|
[15:35:55] Agent 回复: 放心哈,达不到你要的效果直接退,绝对不扯皮。
|
||||||
|
[15:36:47] Agent 正在处理消息...
|
||||||
|
[15:36:51] Agent 回复: 那给你算20,拍下吧,不满意直接退。
|
||||||
|
[15:44:23] Agent 正在处理消息...
|
||||||
|
[15:44:26] Agent 回复: 好的,等你消息。
|
||||||
|
[15:48:56] Agent 正在处理消息...
|
||||||
|
[15:49:32] Agent 回复: 这张20,两张一起算35,拍下吧。
|
||||||
|
[15:49:33] Agent 正在处理消息...
|
||||||
|
[15:49:37] Agent 回复: 那再给你优惠5块,两张一共30就好,这个价格已经很划算了哦。
|
||||||
|
[15:55:18] Agent 正在处理消息...
|
||||||
|
[15:55:22] Agent 回复: 好的,你把QQ号码发给我,我加你哈。
|
||||||
0
mail/__init__.py
Normal file
BIN
mail/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
mail/__pycache__/email_receiver.cpython-310.pyc
Normal file
BIN
mail/__pycache__/email_sender.cpython-310.pyc
Normal file
331
mail/email_receiver.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
邮件接收模块 - 监控收件箱,客户发图询价/下单自动处理
|
||||||
|
|
||||||
|
流程:
|
||||||
|
客户发邮件(含图片附件)→ 自动分析图片复杂度 → 回复报价
|
||||||
|
客户回复"拍了"/"确认" → 创建处理任务 → Gemini 作图 → 发结果
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import imaplib
|
||||||
|
import email
|
||||||
|
import email.header
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from email.header import decode_header
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 支持的图片格式
|
||||||
|
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_str(value: str) -> str:
|
||||||
|
"""解码邮件头部字段(处理中文编码)"""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
parts = decode_header(value)
|
||||||
|
result = []
|
||||||
|
for part, charset in parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
try:
|
||||||
|
result.append(part.decode(charset or "utf-8", errors="replace"))
|
||||||
|
except Exception:
|
||||||
|
result.append(part.decode("utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
result.append(part)
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailReceiver:
|
||||||
|
"""IMAP 邮件接收器,轮询新邮件并自动处理图片询价"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
imap_host: str = "imap.qq.com",
|
||||||
|
imap_port: int = 993,
|
||||||
|
username: str = "",
|
||||||
|
password: str = "",
|
||||||
|
poll_interval: int = 30,
|
||||||
|
):
|
||||||
|
self.imap_host = imap_host
|
||||||
|
self.imap_port = imap_port
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.poll_interval = poll_interval
|
||||||
|
self._running = False
|
||||||
|
self._send_reply = None # 注入的回复函数
|
||||||
|
|
||||||
|
def register_reply_callback(self, callback):
|
||||||
|
"""注入回复函数(直接用 email_sender 回复)"""
|
||||||
|
self._send_reply = callback
|
||||||
|
|
||||||
|
# ========== 主循环 ==========
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""启动轮询(作为后台任务运行)"""
|
||||||
|
self._running = True
|
||||||
|
logger.info(f"[EmailReceiver] 启动,每 {self.poll_interval}s 检查一次收件箱")
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._check_inbox()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EmailReceiver] 轮询异常: {e}")
|
||||||
|
await asyncio.sleep(self.poll_interval)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# ========== 收件箱检查 ==========
|
||||||
|
|
||||||
|
async def _check_inbox(self):
|
||||||
|
"""连接 IMAP,检查未读邮件"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, self._check_inbox_sync)
|
||||||
|
|
||||||
|
def _check_inbox_sync(self):
|
||||||
|
"""同步版收件箱检查(在线程池里跑,避免阻塞事件循环)"""
|
||||||
|
try:
|
||||||
|
conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
|
||||||
|
conn.login(self.username, self.password)
|
||||||
|
conn.select("INBOX")
|
||||||
|
|
||||||
|
# 搜索未读邮件
|
||||||
|
_, msg_ids = conn.search(None, "UNSEEN")
|
||||||
|
ids = msg_ids[0].split()
|
||||||
|
if not ids:
|
||||||
|
conn.logout()
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"[EmailReceiver] 发现 {len(ids)} 封未读邮件")
|
||||||
|
|
||||||
|
for msg_id in ids:
|
||||||
|
try:
|
||||||
|
_, data = conn.fetch(msg_id, "(RFC822)")
|
||||||
|
raw = data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw)
|
||||||
|
self._process_email_sync(msg)
|
||||||
|
# 标记为已读
|
||||||
|
conn.store(msg_id, "+FLAGS", "\\Seen")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EmailReceiver] 处理邮件 {msg_id} 失败: {e}")
|
||||||
|
|
||||||
|
conn.logout()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EmailReceiver] IMAP 连接失败: {e}")
|
||||||
|
|
||||||
|
# ========== 邮件处理 ==========
|
||||||
|
|
||||||
|
def _process_email_sync(self, msg):
|
||||||
|
"""处理单封邮件:提取发件人、附件图片,触发分析和回复"""
|
||||||
|
sender = _decode_str(msg.get("From", ""))
|
||||||
|
subject = _decode_str(msg.get("Subject", "(无主题)"))
|
||||||
|
|
||||||
|
# 提取发件人邮箱地址
|
||||||
|
sender_email = self._extract_email_addr(sender)
|
||||||
|
if not sender_email:
|
||||||
|
logger.warning(f"[EmailReceiver] 无法解析发件人地址: {sender}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"[EmailReceiver] 处理邮件 | 来自: {sender_email} | 主题: {subject}")
|
||||||
|
|
||||||
|
# 提取正文
|
||||||
|
body_text = self._extract_body(msg)
|
||||||
|
|
||||||
|
# 提取图片附件
|
||||||
|
image_paths = self._extract_images(msg)
|
||||||
|
|
||||||
|
# 异步触发处理(把同步上下文切回事件循环)
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(
|
||||||
|
self._handle_email(sender_email, subject, body_text, image_paths)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
# 清理临时图片
|
||||||
|
for p in image_paths:
|
||||||
|
try:
|
||||||
|
os.remove(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _handle_email(
|
||||||
|
self,
|
||||||
|
sender_email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
image_paths: list,
|
||||||
|
):
|
||||||
|
"""根据邮件内容决定如何处理"""
|
||||||
|
body_lower = (body or "").lower()
|
||||||
|
|
||||||
|
# ① 有图片附件 → 分析图片,回复报价
|
||||||
|
if image_paths:
|
||||||
|
await self._handle_image_inquiry(sender_email, subject, image_paths)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ② 纯文字邮件 → 引导发图
|
||||||
|
await self._reply_email(
|
||||||
|
to=sender_email,
|
||||||
|
subject=f"Re: {subject}",
|
||||||
|
body=self._html(
|
||||||
|
"您好!收到您的邮件。<br><br>"
|
||||||
|
"请将您需要处理的图片作为<b>附件</b>发送过来,我们会尽快为您报价。<br><br>"
|
||||||
|
"支持格式:JPG、PNG、WEBP 等常见图片格式。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_image_inquiry(
|
||||||
|
self, sender_email: str, subject: str, image_paths: list
|
||||||
|
):
|
||||||
|
"""分析图片,回复报价"""
|
||||||
|
from image.image_analyzer import image_analyzer
|
||||||
|
|
||||||
|
quotes = []
|
||||||
|
for idx, img_path in enumerate(image_paths, 1):
|
||||||
|
try:
|
||||||
|
# image_analyzer 支持本地路径
|
||||||
|
result = await image_analyzer.analyze(img_path)
|
||||||
|
price = result.get("price_suggest", 30)
|
||||||
|
reason = result.get("reason", "")
|
||||||
|
label = {
|
||||||
|
"simple": "画面简洁",
|
||||||
|
"normal": "一般复杂度",
|
||||||
|
"complex": "细节较多",
|
||||||
|
"hard": "非常复杂",
|
||||||
|
}.get(result.get("complexity", ""), "")
|
||||||
|
quotes.append(
|
||||||
|
f"图片{idx}:{label},建议报价 <b>{price} 元</b>"
|
||||||
|
+ (f"({reason})" if reason else "")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EmailReceiver] 图片分析失败: {e}")
|
||||||
|
quotes.append(f"图片{idx}:分析失败,建议报价 30 元")
|
||||||
|
|
||||||
|
# 多图打包优惠
|
||||||
|
n = len(image_paths)
|
||||||
|
if n >= 5:
|
||||||
|
tip = f"<br><br>📦 您共发来 <b>{n} 张</b>图片,支持打包优惠,欢迎咨询。"
|
||||||
|
elif n >= 3:
|
||||||
|
tip = f"<br><br>📦 您共发来 <b>{n} 张</b>图片,3张以上可享9折优惠。"
|
||||||
|
else:
|
||||||
|
tip = ""
|
||||||
|
|
||||||
|
quote_html = "<br>".join(quotes)
|
||||||
|
body = self._html(
|
||||||
|
f"您好!感谢您发来图片,已为您完成分析:<br><br>"
|
||||||
|
f"{quote_html}{tip}<br><br>"
|
||||||
|
f"如需处理,请直接在淘宝店铺下单,付款后我们会尽快为您完成制作并发回。<br>"
|
||||||
|
f"如有疑问欢迎回复此邮件。"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._reply_email(
|
||||||
|
to=sender_email,
|
||||||
|
subject=f"Re: {subject}" if subject else "您的图片报价",
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
logger.info(f"[EmailReceiver] 已向 {sender_email} 回复报价")
|
||||||
|
|
||||||
|
# ========== 工具方法 ==========
|
||||||
|
|
||||||
|
async def _reply_email(self, to: str, subject: str, body: str):
|
||||||
|
"""发送回复邮件"""
|
||||||
|
try:
|
||||||
|
from mail.email_sender import email_sender
|
||||||
|
result = email_sender.send(to_email=to, subject=subject, body=body)
|
||||||
|
if not result.get("success"):
|
||||||
|
logger.error(f"[EmailReceiver] 回复发送失败: {result.get('message')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EmailReceiver] 回复异常: {e}")
|
||||||
|
|
||||||
|
def _extract_email_addr(self, from_field: str) -> Optional[str]:
|
||||||
|
"""从 From 字段提取邮箱地址"""
|
||||||
|
import re
|
||||||
|
m = re.search(r'[\w\.\+\-]+@[\w\.\-]+\.\w+', from_field)
|
||||||
|
return m.group(0) if m else None
|
||||||
|
|
||||||
|
def _extract_body(self, msg) -> str:
|
||||||
|
"""提取邮件纯文本正文"""
|
||||||
|
body = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
ct = part.get_content_type()
|
||||||
|
if ct == "text/plain":
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
try:
|
||||||
|
body += part.get_payload(decode=True).decode(charset, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
charset = msg.get_content_charset() or "utf-8"
|
||||||
|
try:
|
||||||
|
body = msg.get_payload(decode=True).decode(charset, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return body.strip()
|
||||||
|
|
||||||
|
def _extract_images(self, msg) -> list:
|
||||||
|
"""提取邮件中的图片附件,保存到临时文件,返回路径列表"""
|
||||||
|
paths = []
|
||||||
|
for part in msg.walk():
|
||||||
|
content_disposition = part.get("Content-Disposition", "")
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
|
||||||
|
is_attachment = "attachment" in content_disposition
|
||||||
|
is_image_type = content_type.startswith("image/")
|
||||||
|
|
||||||
|
filename = part.get_filename()
|
||||||
|
if filename:
|
||||||
|
filename = _decode_str(filename)
|
||||||
|
|
||||||
|
# 判断是否是图片
|
||||||
|
if not (is_image_type or (filename and any(
|
||||||
|
filename.lower().endswith(ext) for ext in IMAGE_EXTS
|
||||||
|
))):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = part.get_payload(decode=True)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
suffix = ".jpg"
|
||||||
|
if filename:
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
if ext in IMAGE_EXTS:
|
||||||
|
suffix = ext
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=suffix, prefix="email_img_")
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
paths.append(tmp_path)
|
||||||
|
logger.info(f"[EmailReceiver] 提取图片附件: {filename} → {tmp_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[EmailReceiver] 提取附件失败: {e}")
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _html(content: str) -> str:
|
||||||
|
return f"""
|
||||||
|
<html><body style="font-family:Arial,sans-serif;font-size:14px;color:#333">
|
||||||
|
{content}
|
||||||
|
<br><br>
|
||||||
|
<hr style="border:none;border-top:1px solid #eee">
|
||||||
|
<p style="color:#999;font-size:12px">修图客服 · 自动回复</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 全局实例(从 .env 读取配置)==========
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
email_receiver = EmailReceiver(
|
||||||
|
imap_host="imap.qq.com",
|
||||||
|
imap_port=993,
|
||||||
|
username=os.getenv("SMTP_USER", ""),
|
||||||
|
password=os.getenv("SMTP_PASSWORD", ""),
|
||||||
|
poll_interval=int(os.getenv("EMAIL_POLL_INTERVAL", "30")),
|
||||||
|
)
|
||||||
112
mail/email_sender.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""邮件发送模块"""
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.image import MIMEImage
|
||||||
|
from email.header import Header
|
||||||
|
from typing import Optional, List
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSender:
|
||||||
|
"""邮件发送"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.smtp_host = os.getenv("SMTP_HOST", "")
|
||||||
|
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||||
|
self.smtp_user = os.getenv("SMTP_USER", "")
|
||||||
|
self.smtp_password = os.getenv("SMTP_PASSWORD", "")
|
||||||
|
self.sender_name = os.getenv("SENDER_NAME", "修图客服")
|
||||||
|
|
||||||
|
def send(
|
||||||
|
self,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
images: Optional[List[str]] = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
发送邮件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_email: 收件人邮箱
|
||||||
|
subject: 邮件主题
|
||||||
|
body: 邮件正文
|
||||||
|
images: 图片路径列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"success": bool, "message": str}
|
||||||
|
"""
|
||||||
|
if not self.smtp_host or not self.smtp_user:
|
||||||
|
return {"success": False, "message": "未配置邮件SMTP"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 创建邮件
|
||||||
|
msg = MIMEMultipart('related')
|
||||||
|
msg['From'] = f"{Header(self.sender_name, 'utf-8').encode()} <{self.smtp_user}>"
|
||||||
|
msg['To'] = to_email
|
||||||
|
msg['Subject'] = subject
|
||||||
|
|
||||||
|
# 添加正文
|
||||||
|
msg.attach(MIMEText(body, 'html', 'utf-8'))
|
||||||
|
|
||||||
|
# 添加图片
|
||||||
|
if images:
|
||||||
|
for idx, img_path in enumerate(images):
|
||||||
|
if os.path.exists(img_path):
|
||||||
|
with open(img_path, 'rb') as f:
|
||||||
|
img = MIMEImage(f.read())
|
||||||
|
img.add_header('Content-ID', f'<image{idx}>')
|
||||||
|
msg.attach(img)
|
||||||
|
|
||||||
|
# 发送邮件(失败时重试 1 次)
|
||||||
|
import time
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
|
||||||
|
server.starttls()
|
||||||
|
server.login(self.smtp_user, self.smtp_password)
|
||||||
|
server.sendmail(self.smtp_user, to_email, msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
return {"success": True, "message": "发送成功"}
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt == 0:
|
||||||
|
time.sleep(2)
|
||||||
|
return {"success": False, "message": f"发送失败: {str(last_err)}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "message": f"发送失败: {str(e)}"}
|
||||||
|
|
||||||
|
def send_completed_work(
|
||||||
|
self,
|
||||||
|
to_email: str,
|
||||||
|
customer_name: str,
|
||||||
|
image_description: str,
|
||||||
|
result_images: List[str]
|
||||||
|
) -> dict:
|
||||||
|
"""发送完成的作品"""
|
||||||
|
subject = f"您的修图作品已完成 - {image_description}"
|
||||||
|
|
||||||
|
body = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>您好 {customer_name},您的修图作品已完成!</h2>
|
||||||
|
<p>感谢您选择我们的服务。以下是您处理后的图片:</p>
|
||||||
|
<p><b>处理内容:</b> {image_description}</p>
|
||||||
|
<br>
|
||||||
|
<p>如有任何问题,请随时联系我们。</p>
|
||||||
|
<br>
|
||||||
|
<p>祝您生活愉快!</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.send(to_email, subject, body, result_images)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
email_sender = EmailSender()
|
||||||
12
requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
websockets>=12.0
|
||||||
|
pydantic-ai>=0.0.20
|
||||||
|
pydantic>=2.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
openai>=1.0.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
aiofiles>=23.0.0
|
||||||
|
httpx>=0.25.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
opencv-python>=4.8.0
|
||||||
BIN
results/20260225211854.jpg
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
results/debug_7debc0124b0441da9945feaeceef93b1.jpg
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg
Normal file
|
After Width: | Height: | Size: 855 KiB |
BIN
results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg
Normal file
|
After Width: | Height: | Size: 810 KiB |
BIN
results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg
Normal file
|
After Width: | Height: | Size: 883 KiB |
BIN
results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
results/result_90eaf777934445af81abbd60fe4778c5.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
25
run.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
项目入口 - 启动 WebSocket 客服客户端
|
||||||
|
用法:
|
||||||
|
python run.py # 正常启动(含 AI Agent)
|
||||||
|
python run.py --no-agent # 仅基础回复,不启用 AI
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 确保项目根目录在 sys.path 首位
|
||||||
|
_root = Path(__file__).resolve().parent
|
||||||
|
if str(_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_root))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from core.websocket_client import QingjianAPIClient
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
enable_agent = "--no-agent" not in sys.argv
|
||||||
|
client = QingjianAPIClient(enable_agent=enable_agent)
|
||||||
|
try:
|
||||||
|
asyncio.run(client.run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n已停止")
|
||||||
BIN
scripts/__pycache__/chat_ui.cpython-310.pyc
Normal file
371
scripts/chat_log_viewer.py
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
"""
|
||||||
|
聊天记录查看器
|
||||||
|
用法:
|
||||||
|
python scripts/chat_log_viewer.py # 列出所有客户
|
||||||
|
python scripts/chat_log_viewer.py <客户ID> # 查看某客户全部对话
|
||||||
|
python scripts/chat_log_viewer.py -s <关键词> # 全局搜索
|
||||||
|
python scripts/chat_log_viewer.py -t <客户ID> # 只看今天
|
||||||
|
python scripts/chat_log_viewer.py -l # 实时监听最新消息(10条/刷新)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 强制 UTF-8 输出(Windows 终端需要)
|
||||||
|
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
||||||
|
import io
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
from db import chat_log_db as db
|
||||||
|
|
||||||
|
# ========== ANSI 颜色 ==========
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
RESET = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
GREEN = "\033[32m"
|
||||||
|
CYAN = "\033[36m"
|
||||||
|
YELLOW = "\033[33m"
|
||||||
|
BLUE = "\033[34m"
|
||||||
|
MAGENTA= "\033[35m"
|
||||||
|
RED = "\033[31m"
|
||||||
|
WHITE = "\033[97m"
|
||||||
|
BG_DARK= "\033[48;5;236m"
|
||||||
|
|
||||||
|
|
||||||
|
def clear():
|
||||||
|
os.system("cls" if os.name == "nt" else "clear")
|
||||||
|
|
||||||
|
|
||||||
|
def header(text: str):
|
||||||
|
width = 60
|
||||||
|
print(f"\n{BOLD}{CYAN}{'─' * width}{RESET}")
|
||||||
|
print(f"{BOLD}{CYAN} {text}{RESET}")
|
||||||
|
print(f"{BOLD}{CYAN}{'─' * width}{RESET}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_time(ts: str) -> str:
|
||||||
|
"""缩短时间戳显示"""
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
if ts.startswith(today):
|
||||||
|
return ts[11:16] # 只显示 HH:MM
|
||||||
|
return ts[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def platform_badge(platform: str) -> str:
|
||||||
|
badges = {
|
||||||
|
"AliWorkbench": f"{YELLOW}[淘宝]{RESET}",
|
||||||
|
"taobao": f"{YELLOW}[淘宝]{RESET}",
|
||||||
|
"pinduoduo": f"{RED}[拼多多]{RESET}",
|
||||||
|
"jd": f"{RED}[京东]{RESET}",
|
||||||
|
"wechat": f"{GREEN}[微信]{RESET}",
|
||||||
|
"email": f"{BLUE}[邮件]{RESET}",
|
||||||
|
}
|
||||||
|
return badges.get(platform, f"{DIM}[{platform}]{RESET}" if platform else "")
|
||||||
|
|
||||||
|
|
||||||
|
def print_bubble(direction: str, message: str, ts: str):
|
||||||
|
"""打印聊天气泡"""
|
||||||
|
time_str = fmt_time(ts)
|
||||||
|
lines = []
|
||||||
|
if "#*#" in (message or ""):
|
||||||
|
parts = [p.strip() for p in message.split("#*#") if p.strip()]
|
||||||
|
if parts:
|
||||||
|
lines = parts
|
||||||
|
if not lines:
|
||||||
|
lines = (message or "").split("\n")
|
||||||
|
|
||||||
|
if direction == "in": # 客户来消息 → 左对齐
|
||||||
|
print(f" {DIM}{time_str}{RESET} {WHITE}买家{RESET}")
|
||||||
|
for line in lines:
|
||||||
|
print(f" {BG_DARK} {line} {RESET}")
|
||||||
|
else: # 客服回复 → 右对齐(缩进)
|
||||||
|
print(f" {DIM}{time_str}{RESET} {GREEN}客服{RESET}")
|
||||||
|
for line in lines:
|
||||||
|
print(f" {GREEN}> {line}{RESET}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_customers():
|
||||||
|
"""列出所有客户"""
|
||||||
|
customers = db.get_customers(limit=100)
|
||||||
|
if not customers:
|
||||||
|
print(f"{YELLOW}暂无聊天记录。{RESET}")
|
||||||
|
return
|
||||||
|
|
||||||
|
header(f"客户列表 共 {len(customers)} 人")
|
||||||
|
print(f" {'#':<4} {'客户ID':<24} {'姓名':<12} {'平台':<10} {'消息数':>6} {'最后活跃'}")
|
||||||
|
print(f" {'─'*4} {'─'*24} {'─'*12} {'─'*10} {'─'*6} {'─'*16}")
|
||||||
|
for i, c in enumerate(customers, 1):
|
||||||
|
badge = platform_badge(c.get("platform", ""))
|
||||||
|
name = (c.get("customer_name") or "")[:10]
|
||||||
|
cid = c["customer_id"]
|
||||||
|
total = c["total_msgs"]
|
||||||
|
last = c.get("last_time", "")[:16]
|
||||||
|
print(f" {i:<4} {CYAN}{cid:<24}{RESET} {name:<12} {badge:<18} {total:>6}条 {DIM}{last}{RESET}")
|
||||||
|
|
||||||
|
print(f"\n{DIM}用法:python chat_log_viewer.py <客户ID>{RESET}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_show_conversation(customer_id: str, today_only: bool = False):
|
||||||
|
"""显示某客户对话"""
|
||||||
|
if today_only:
|
||||||
|
messages = db.get_conversation_today(customer_id)
|
||||||
|
title = f"今日对话 {customer_id}"
|
||||||
|
else:
|
||||||
|
messages = db.get_conversation(customer_id, limit=300)
|
||||||
|
title = f"对话记录 {customer_id}"
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
print(f"{YELLOW}该客户暂无记录:{customer_id}{RESET}")
|
||||||
|
return
|
||||||
|
|
||||||
|
header(f"{title} ({len(messages)} 条)")
|
||||||
|
|
||||||
|
last_date = ""
|
||||||
|
for m in messages:
|
||||||
|
ts = m.get("timestamp", "")
|
||||||
|
date = ts[:10]
|
||||||
|
if date != last_date:
|
||||||
|
print(f" {DIM}{'─'*20} {date} {'─'*20}{RESET}")
|
||||||
|
last_date = date
|
||||||
|
print_bubble(m["direction"], m["message"], ts)
|
||||||
|
|
||||||
|
print(f"{DIM} ── 以上共 {len(messages)} 条 ──{RESET}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_search(keyword: str, customer_id: str = None):
|
||||||
|
"""搜索关键词"""
|
||||||
|
results = db.search_messages(keyword, customer_id=customer_id, limit=50)
|
||||||
|
title = f"搜索 [{keyword}]"
|
||||||
|
if customer_id:
|
||||||
|
title += f" 客户:{customer_id}"
|
||||||
|
header(f"{title} 共 {len(results)} 条")
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
print(f"{YELLOW}未找到包含 [{keyword}] 的消息。{RESET}")
|
||||||
|
return
|
||||||
|
|
||||||
|
last_cid = ""
|
||||||
|
for r in results:
|
||||||
|
cid = r["customer_id"]
|
||||||
|
if cid != last_cid:
|
||||||
|
print(f" {CYAN}{cid}{RESET} {r.get('customer_name','')}")
|
||||||
|
last_cid = cid
|
||||||
|
direction = "买家" if r["direction"] == "in" else "客服"
|
||||||
|
color = WHITE if r["direction"] == "in" else GREEN
|
||||||
|
# 高亮关键词
|
||||||
|
msg = r["message"].replace(keyword, f"{RED}{BOLD}{keyword}{RESET}{color}")
|
||||||
|
print(f" {DIM}{r['timestamp'][:16]}{RESET} {color}[{direction}] {msg}{RESET}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_live(refresh: int = 3):
|
||||||
|
"""实时监听最新消息"""
|
||||||
|
header("实时消息监听 Ctrl+C 退出")
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(db._DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT id, customer_id, customer_name, direction, message, timestamp
|
||||||
|
FROM chat_logs
|
||||||
|
ORDER BY id DESC LIMIT 20
|
||||||
|
""").fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
new_rows = [dict(r) for r in rows if r["id"] not in seen_ids]
|
||||||
|
if new_rows:
|
||||||
|
new_rows.reverse()
|
||||||
|
for r in new_rows:
|
||||||
|
seen_ids.add(r["id"])
|
||||||
|
cid = r["customer_id"]
|
||||||
|
name = r.get("customer_name") or ""
|
||||||
|
label = f"{CYAN}{cid}{RESET}" + (f" {DIM}({name}){RESET}" if name else "")
|
||||||
|
print(f"\n{label}")
|
||||||
|
print_bubble(r["direction"], r["message"], r["timestamp"])
|
||||||
|
else:
|
||||||
|
print(f"\r {DIM}等待新消息... {datetime.now().strftime('%H:%M:%S')}{RESET}", end="", flush=True)
|
||||||
|
|
||||||
|
time.sleep(refresh)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n{DIM}已退出监听。{RESET}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_urls(msg: str) -> list:
|
||||||
|
if not msg:
|
||||||
|
return []
|
||||||
|
parts = [p.strip() for p in msg.split("#*#") if p.strip()]
|
||||||
|
urls = []
|
||||||
|
for p in parts:
|
||||||
|
if p.startswith("http://") or p.startswith("https://"):
|
||||||
|
urls.append(p)
|
||||||
|
if not urls and ("http://" in msg or "https://" in msg):
|
||||||
|
import re as _re
|
||||||
|
tokens = _re.findall(r'(https?://\S+)', msg)
|
||||||
|
for t in tokens:
|
||||||
|
tl = t.lower()
|
||||||
|
if any(ext in tl for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]):
|
||||||
|
urls.append(t)
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def _msg_refers_images(msg: str) -> bool:
|
||||||
|
if not msg:
|
||||||
|
return False
|
||||||
|
refs = ("图一", "图二", "第一张", "第二张", "这张", "那张", "上面那张", "下面那张", "刚才那张", "上一张", "下一张")
|
||||||
|
return any(r in msg for r in refs)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts: str):
|
||||||
|
try:
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
return _dt.fromisoformat(ts.replace("Z",""))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_conversation(messages: list) -> list:
|
||||||
|
issues = []
|
||||||
|
n = len(messages)
|
||||||
|
for i, m in enumerate(messages):
|
||||||
|
msg = m.get("message") or ""
|
||||||
|
dir = m.get("direction")
|
||||||
|
ts = _parse_ts(m.get("timestamp",""))
|
||||||
|
# 图片后未及时回复
|
||||||
|
if dir == "in" and _extract_urls(msg):
|
||||||
|
replied = False
|
||||||
|
delay_ok = True
|
||||||
|
for j in range(i+1, min(i+6, n)):
|
||||||
|
mj = messages[j]
|
||||||
|
if mj.get("direction") == "out":
|
||||||
|
replied = True
|
||||||
|
tsj = _parse_ts(mj.get("timestamp",""))
|
||||||
|
if ts and tsj and (tsj - ts).total_seconds() > 180:
|
||||||
|
delay_ok = False
|
||||||
|
break
|
||||||
|
if not replied:
|
||||||
|
issues.append("图片消息后未回复")
|
||||||
|
elif not delay_ok:
|
||||||
|
issues.append("图片消息后回复延迟超过3分钟")
|
||||||
|
# 引用图片但找不到历史图片
|
||||||
|
if dir == "in" and _msg_refers_images(msg):
|
||||||
|
has_prev_img = False
|
||||||
|
for k in range(max(0, i-10), i):
|
||||||
|
if messages[k].get("direction") == "in" and _extract_urls(messages[k].get("message","")):
|
||||||
|
has_prev_img = True
|
||||||
|
break
|
||||||
|
if not has_prev_img:
|
||||||
|
issues.append("引用图片但历史中未找到对应图片")
|
||||||
|
# 订单后未确认/引导
|
||||||
|
if dir == "in" and ("买家已付款" in msg or "[系统订单信息]" in msg):
|
||||||
|
confirmed = False
|
||||||
|
for j in range(i+1, min(i+6, n)):
|
||||||
|
if messages[j].get("direction") == "out":
|
||||||
|
confirmed = True
|
||||||
|
break
|
||||||
|
if not confirmed:
|
||||||
|
issues.append("订单消息后未进行确认或引导付款")
|
||||||
|
# 合成需求未报价格
|
||||||
|
if dir == "in" and any(k in msg for k in ("抓到", "放到", "合成", "融合", "嵌到", "替换", "P到", "抠出来放到")):
|
||||||
|
priced = False
|
||||||
|
for j in range(i+1, min(i+6, n)):
|
||||||
|
mj = messages[j]
|
||||||
|
if mj.get("direction") == "out":
|
||||||
|
rm = mj.get("message","")
|
||||||
|
if "元" in rm:
|
||||||
|
priced = True
|
||||||
|
break
|
||||||
|
if not priced:
|
||||||
|
issues.append("客户提出合成需求但未给出价格")
|
||||||
|
# 去重
|
||||||
|
dedup = []
|
||||||
|
seen = set()
|
||||||
|
for it in issues:
|
||||||
|
if it not in seen:
|
||||||
|
seen.add(it)
|
||||||
|
dedup.append(it)
|
||||||
|
return dedup
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_analyze_all():
|
||||||
|
customers = db.get_customers(limit=200)
|
||||||
|
if not customers:
|
||||||
|
print(f"{YELLOW}暂无聊天记录。{RESET}")
|
||||||
|
return
|
||||||
|
header("聊天记录上下文分析")
|
||||||
|
total_issues = 0
|
||||||
|
for c in customers:
|
||||||
|
cid = c["customer_id"]
|
||||||
|
msgs = db.get_conversation(cid, limit=500)
|
||||||
|
issues = analyze_conversation(msgs)
|
||||||
|
if issues:
|
||||||
|
total_issues += len(issues)
|
||||||
|
print(f"{CYAN}{cid}{RESET} {c.get('customer_name','')}")
|
||||||
|
for s in issues:
|
||||||
|
print(f" - {RED}{s}{RESET}")
|
||||||
|
print()
|
||||||
|
if total_issues == 0:
|
||||||
|
print(f"{GREEN}未发现明显异常。{RESET}")
|
||||||
|
else:
|
||||||
|
print(f"{YELLOW}共发现 {total_issues} 项问题(按客户汇总)。{RESET}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_help():
|
||||||
|
print(f"""
|
||||||
|
{BOLD}聊天记录查看器{RESET}
|
||||||
|
|
||||||
|
{CYAN}python chat_log_viewer.py{RESET} 列出所有客户
|
||||||
|
{CYAN}python chat_log_viewer.py <客户ID>{RESET} 查看该客户全部对话
|
||||||
|
{CYAN}python chat_log_viewer.py -t <客户ID>{RESET} 只看今天的对话
|
||||||
|
{CYAN}python chat_log_viewer.py -s <关键词>{RESET} 全局搜索
|
||||||
|
{CYAN}python chat_log_viewer.py -l{RESET} 实时监听新消息
|
||||||
|
{CYAN}python chat_log_viewer.py -a{RESET} 分析上下文,输出异常项
|
||||||
|
{CYAN}python chat_log_viewer.py -h{RESET} 显示帮助
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = sys.argv[1:]
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
cmd_list_customers()
|
||||||
|
|
||||||
|
elif args[0] in ("-h", "--help"):
|
||||||
|
print_help()
|
||||||
|
|
||||||
|
elif args[0] == "-s":
|
||||||
|
keyword = args[1] if len(args) > 1 else ""
|
||||||
|
if not keyword:
|
||||||
|
print(f"{RED}请提供搜索关键词:python chat_log_viewer.py -s <关键词>{RESET}")
|
||||||
|
else:
|
||||||
|
cmd_search(keyword)
|
||||||
|
|
||||||
|
elif args[0] == "-t":
|
||||||
|
cid = args[1] if len(args) > 1 else ""
|
||||||
|
if not cid:
|
||||||
|
print(f"{RED}请提供客户ID:python chat_log_viewer.py -t <客户ID>{RESET}")
|
||||||
|
else:
|
||||||
|
cmd_show_conversation(cid, today_only=True)
|
||||||
|
|
||||||
|
elif args[0] == "-l":
|
||||||
|
cmd_live()
|
||||||
|
|
||||||
|
elif args[0] == "-a":
|
||||||
|
cmd_analyze_all()
|
||||||
|
|
||||||
|
else:
|
||||||
|
cmd_show_conversation(args[0])
|
||||||
520
scripts/chat_ui.py
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
聊天记录 Web UI
|
||||||
|
运行: python scripts/chat_ui.py
|
||||||
|
访问: http://localhost:5678
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, render_template_string, request
|
||||||
|
import asyncio
|
||||||
|
from core.pydantic_ai_agent import CustomerServiceAgent, AgentDeps
|
||||||
|
from db import chat_log_db as db
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
pricing_agent = None
|
||||||
|
try:
|
||||||
|
pricing_agent = CustomerServiceAgent()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ChatUI] 初始化报价Agent失败: {e}")
|
||||||
|
|
||||||
|
HTML = r"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>聊天记录</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* ── 顶栏 ── */
|
||||||
|
.topbar {
|
||||||
|
background: #16213e; border-bottom: 1px solid #0f3460;
|
||||||
|
padding: 12px 20px; display: flex; align-items: center; gap: 16px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.topbar h1 { font-size: 16px; color: #4cc9f0; font-weight: 600; letter-spacing: 1px; }
|
||||||
|
.search-box {
|
||||||
|
flex: 1; max-width: 320px;
|
||||||
|
background: #0f3460; border: 1px solid #1a5276;
|
||||||
|
border-radius: 20px; padding: 6px 14px;
|
||||||
|
color: #e0e0e0; font-size: 13px; outline: none;
|
||||||
|
}
|
||||||
|
.search-box::placeholder { color: #6b7a99; }
|
||||||
|
.live-badge {
|
||||||
|
margin-left: auto; font-size: 11px; background: #0d7377;
|
||||||
|
color: #14ffec; padding: 3px 10px; border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 主体 ── */
|
||||||
|
.main { display: flex; flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ── 左侧客户列表 ── */
|
||||||
|
.sidebar {
|
||||||
|
width: 280px; background: #16213e;
|
||||||
|
border-right: 1px solid #0f3460;
|
||||||
|
display: flex; flex-direction: column; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 10px 14px; font-size: 12px; color: #6b7a99;
|
||||||
|
border-bottom: 1px solid #0f3460; flex-shrink: 0;
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
}
|
||||||
|
.customer-list { overflow-y: auto; flex: 1; }
|
||||||
|
.customer-item {
|
||||||
|
padding: 12px 14px; cursor: pointer; border-bottom: 1px solid #0f3460;
|
||||||
|
transition: background .15s; position: relative;
|
||||||
|
}
|
||||||
|
.customer-item:hover { background: #1e3a5f; }
|
||||||
|
.customer-item.active { background: #0f3460; border-left: 3px solid #4cc9f0; }
|
||||||
|
.customer-item .name { font-size: 13px; font-weight: 500; color: #cce; }
|
||||||
|
.customer-item .cid { font-size: 11px; color: #6b7a99; margin-top: 2px; }
|
||||||
|
.customer-item .meta { font-size: 11px; color: #8899aa; margin-top: 4px;
|
||||||
|
display: flex; justify-content: space-between; }
|
||||||
|
.badge-plat {
|
||||||
|
font-size: 10px; padding: 1px 6px; border-radius: 8px;
|
||||||
|
background: #1a3a5c; color: #4cc9f0;
|
||||||
|
}
|
||||||
|
.badge-plat.ali { background: #3d1a00; color: #ff9f43; }
|
||||||
|
.badge-plat.email { background: #0a2e1a; color: #55efc4; }
|
||||||
|
|
||||||
|
/* ── 右侧对话区 ── */
|
||||||
|
.chat-panel {
|
||||||
|
flex: 1; display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.chat-header {
|
||||||
|
padding: 12px 20px; background: #16213e;
|
||||||
|
border-bottom: 1px solid #0f3460; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.chat-header .cname { font-size: 15px; font-weight: 600; color: #e0e0e0; }
|
||||||
|
.chat-header .cid { font-size: 12px; color: #6b7a99; }
|
||||||
|
.chat-header .stats { margin-left: auto; font-size: 12px; color: #6b7a99; }
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1; overflow-y: auto; padding: 20px;
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
.day-divider {
|
||||||
|
text-align: center; font-size: 11px; color: #6b7a99;
|
||||||
|
position: relative; margin: 8px 0;
|
||||||
|
}
|
||||||
|
.day-divider::before, .day-divider::after {
|
||||||
|
content: ""; position: absolute; top: 50%;
|
||||||
|
width: 38%; height: 1px; background: #0f3460;
|
||||||
|
}
|
||||||
|
.day-divider::before { left: 0; }
|
||||||
|
.day-divider::after { right: 0; }
|
||||||
|
|
||||||
|
/* 消息气泡 */
|
||||||
|
.msg-row { display: flex; align-items: flex-end; gap: 8px; max-width: 72%; }
|
||||||
|
.msg-row.in { align-self: flex-start; }
|
||||||
|
.msg-row.out { align-self: flex-end; flex-direction: row-reverse; }
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 34px; height: 34px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 13px; font-weight: 600; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.avatar.buyer { background: #2d4a7a; color: #90caf9; }
|
||||||
|
.avatar.seller { background: #1a6644; color: #a8e6cf; }
|
||||||
|
|
||||||
|
.bubble-wrap { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.msg-row.out .bubble-wrap { align-items: flex-end; }
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
padding: 9px 13px; border-radius: 16px;
|
||||||
|
font-size: 13px; line-height: 1.55; word-break: break-word;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.bubble.in { background: #1e3a5f; color: #dce8f8; border-bottom-left-radius: 4px; }
|
||||||
|
.bubble.out { background: #1a6644; color: #d4f5e7; border-bottom-right-radius: 4px; }
|
||||||
|
.bubble img { max-width: 200px; border-radius: 8px; display: block; margin-top: 4px; }
|
||||||
|
|
||||||
|
.msg-time { font-size: 10px; color: #6b7a99; padding: 0 4px; }
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-state {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; color: #6b7a99; gap: 10px;
|
||||||
|
}
|
||||||
|
.empty-state .icon { font-size: 48px; opacity: .3; }
|
||||||
|
.empty-state p { font-size: 14px; }
|
||||||
|
|
||||||
|
/* 搜索结果覆盖层 */
|
||||||
|
#search-overlay {
|
||||||
|
display: none; position: absolute; top: 52px; left: 0; right: 0; bottom: 0;
|
||||||
|
background: #1a1a2e; z-index: 10; overflow-y: auto; padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.search-hit {
|
||||||
|
padding: 10px 14px; margin-bottom: 8px;
|
||||||
|
background: #16213e; border-radius: 10px; cursor: pointer;
|
||||||
|
border-left: 3px solid #4cc9f0;
|
||||||
|
}
|
||||||
|
.search-hit:hover { background: #1e3a5f; }
|
||||||
|
.search-hit .hit-cid { font-size: 11px; color: #4cc9f0; }
|
||||||
|
.search-hit .hit-msg { font-size: 13px; color: #e0e0e0; margin-top: 4px; }
|
||||||
|
.search-hit .hit-time { font-size: 11px; color: #6b7a99; margin-top: 3px; }
|
||||||
|
mark { background: transparent; color: #f9ca24; font-weight: 600; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<h1>💬 聊天记录</h1>
|
||||||
|
<input id="searchInput" class="search-box" placeholder="搜索消息内容..." autocomplete="off">
|
||||||
|
<span class="live-badge" id="liveBadge">● 实时</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main" style="position:relative;">
|
||||||
|
<!-- 搜索覆盖层 -->
|
||||||
|
<div id="search-overlay"></div>
|
||||||
|
|
||||||
|
<!-- 左侧客户列表 -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<span id="customerCount">客户</span>
|
||||||
|
<span id="lastRefresh"></span>
|
||||||
|
</div>
|
||||||
|
<div class="customer-list" id="customerList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧对话 -->
|
||||||
|
<div class="chat-panel" id="chatPanel">
|
||||||
|
<div class="empty-state" id="emptyState">
|
||||||
|
<div class="icon">💬</div>
|
||||||
|
<p>选择一位客户查看对话记录</p>
|
||||||
|
</div>
|
||||||
|
<div id="chatHeader" class="chat-header" style="display:none;">
|
||||||
|
<div>
|
||||||
|
<div class="cname" id="headerName"></div>
|
||||||
|
<div class="cid" id="headerId"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stats" id="headerStats"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-messages" id="chatMessages" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentCid = null;
|
||||||
|
let autoRefresh = null;
|
||||||
|
let allCustomers = [];
|
||||||
|
|
||||||
|
// ── 时间格式化 ──
|
||||||
|
function fmtTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
const today = new Date().toISOString().slice(0,10);
|
||||||
|
return ts.startsWith(today) ? ts.slice(11,16) : ts.slice(5,16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 平台徽章 ──
|
||||||
|
function platBadge(p) {
|
||||||
|
const map = {
|
||||||
|
AliWorkbench: ['ali','淘宝'],
|
||||||
|
taobao: ['ali','淘宝'],
|
||||||
|
pinduoduo: ['','拼多多'],
|
||||||
|
jd: ['','京东'],
|
||||||
|
email: ['email','邮件'],
|
||||||
|
};
|
||||||
|
const [cls, label] = map[p] || ['', p || ''];
|
||||||
|
return label ? `<span class="badge-plat ${cls}">${label}</span>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 加载客户列表 ──
|
||||||
|
async function loadCustomers() {
|
||||||
|
const r = await fetch('/api/customers');
|
||||||
|
allCustomers = await r.json();
|
||||||
|
renderCustomers(allCustomers);
|
||||||
|
document.getElementById('customerCount').textContent = `客户 ${allCustomers.length} 人`;
|
||||||
|
document.getElementById('lastRefresh').textContent = new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCustomers(list) {
|
||||||
|
const el = document.getElementById('customerList');
|
||||||
|
el.innerHTML = list.map(c => {
|
||||||
|
const active = c.customer_id === currentCid ? 'active' : '';
|
||||||
|
const name = c.customer_name || c.customer_id.slice(-8);
|
||||||
|
return `<div class="customer-item ${active}" onclick="openChat('${c.customer_id}','${(c.customer_name||'').replace(/'/g,"\\'")}','${c.platform||''}',${c.total_msgs},${c.recv},${c.sent})">
|
||||||
|
<div class="name">${name} ${platBadge(c.platform)}</div>
|
||||||
|
<div class="cid">${c.customer_id}</div>
|
||||||
|
<div class="meta"><span>${c.total_msgs} 条消息</span><span>${fmtTime(c.last_time)}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 打开对话 ──
|
||||||
|
async function openChat(cid, name, platform, total, recv, sent) {
|
||||||
|
currentCid = cid;
|
||||||
|
renderCustomers(allCustomers);
|
||||||
|
|
||||||
|
document.getElementById('emptyState').style.display = 'none';
|
||||||
|
document.getElementById('chatHeader').style.display = 'flex';
|
||||||
|
document.getElementById('chatMessages').style.display = 'flex';
|
||||||
|
document.getElementById('headerName').textContent = name || cid;
|
||||||
|
document.getElementById('headerId').textContent = cid;
|
||||||
|
document.getElementById('headerStats').textContent = `共 ${total} 条 收 ${recv} 发 ${sent}`;
|
||||||
|
|
||||||
|
await loadConversation(cid);
|
||||||
|
if (autoRefresh) clearInterval(autoRefresh);
|
||||||
|
autoRefresh = setInterval(() => loadConversation(cid), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 加载对话 ──
|
||||||
|
async function loadConversation(cid) {
|
||||||
|
const r = await fetch(`/api/conversation/${encodeURIComponent(cid)}`);
|
||||||
|
const msgs = await r.json();
|
||||||
|
renderMessages(msgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages(msgs) {
|
||||||
|
const el = document.getElementById('chatMessages');
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
|
||||||
|
|
||||||
|
let lastDate = '';
|
||||||
|
const html = msgs.map(m => {
|
||||||
|
const date = (m.timestamp || '').slice(0,10);
|
||||||
|
let divider = '';
|
||||||
|
if (date && date !== lastDate) { divider = `<div class="day-divider">${date}</div>`; lastDate = date; }
|
||||||
|
|
||||||
|
const dir = m.direction;
|
||||||
|
const avatarChar = dir === 'in' ? '买' : '客';
|
||||||
|
const avatarCls = dir === 'in' ? 'buyer' : 'seller';
|
||||||
|
const content = renderMsgContent(m.message, m.msg_type);
|
||||||
|
|
||||||
|
return `${divider}
|
||||||
|
<div class="msg-row ${dir}">
|
||||||
|
<div class="avatar ${avatarCls}">${avatarChar}</div>
|
||||||
|
<div class="bubble-wrap">
|
||||||
|
<div class="bubble ${dir}">${content}</div>
|
||||||
|
<div class="msg-time">${fmtTime(m.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMsgContent(msg, msgType) {
|
||||||
|
if (!msg) return '';
|
||||||
|
const urlRegGlobal = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/gi;
|
||||||
|
const urlRegSingle = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/i;
|
||||||
|
const parts = msg.split('#*#').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (parts.length > 1) {
|
||||||
|
const segs = parts.map(p => {
|
||||||
|
const m = p.match(urlRegSingle);
|
||||||
|
if (m) {
|
||||||
|
const url = m[0];
|
||||||
|
return `<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`;
|
||||||
|
}
|
||||||
|
const esc = p.replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
return esc;
|
||||||
|
});
|
||||||
|
return segs.join('<br>');
|
||||||
|
}
|
||||||
|
const escaped = msg.replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
return escaped.replace(urlRegGlobal, (url) =>
|
||||||
|
`<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 搜索 ──
|
||||||
|
let searchTimer = null;
|
||||||
|
document.getElementById('searchInput').addEventListener('input', function() {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
const kw = this.value.trim();
|
||||||
|
if (!kw) { closeSearch(); return; }
|
||||||
|
searchTimer = setTimeout(() => doSearch(kw), 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function doSearch(kw) {
|
||||||
|
const r = await fetch(`/api/search?q=${encodeURIComponent(kw)}`);
|
||||||
|
const results = await r.json();
|
||||||
|
const overlay = document.getElementById('search-overlay');
|
||||||
|
overlay.style.display = 'block';
|
||||||
|
|
||||||
|
if (!results.length) {
|
||||||
|
overlay.innerHTML = `<p style="color:#6b7a99;text-align:center;margin-top:60px;">未找到匹配消息</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hi = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const re = new RegExp(hi, 'gi');
|
||||||
|
overlay.innerHTML = results.map(r => {
|
||||||
|
const dir = r.direction === 'in' ? '买家' : '客服';
|
||||||
|
const msg = r.message.replace(/</g,'<').replace(re, m => `<mark>${m}</mark>`);
|
||||||
|
return `<div class="search-hit" onclick="closeSearch(); openChat('${r.customer_id}','','','','','')">
|
||||||
|
<div class="hit-cid">${r.customer_id} ${r.customer_name||''} · ${dir}</div>
|
||||||
|
<div class="hit-msg">${msg}</div>
|
||||||
|
<div class="hit-time">${(r.timestamp||'').slice(0,16)}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
document.getElementById('search-overlay').style.display = 'none';
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 初始化 ──
|
||||||
|
loadCustomers();
|
||||||
|
setInterval(loadCustomers, 10000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
PRICING_HTML = r"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI 报价测试</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: #1a1a2e; color: #e0e0e0; padding: 20px; }
|
||||||
|
.card { background:#16213e; border:1px solid #0f3460; border-radius:12px; padding:16px; max-width:880px; margin:0 auto; }
|
||||||
|
.title { font-size:16px; color:#4cc9f0; margin-bottom:12px; }
|
||||||
|
.row { display:flex; gap:12px; margin-bottom:10px; }
|
||||||
|
.row .col { flex:1; }
|
||||||
|
.input { width:100%; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:10px 12px; color:#e0e0e0; font-size:13px; outline:none; }
|
||||||
|
.input::placeholder { color:#6b7a99; }
|
||||||
|
.btn { background:#0d7377; color:#14ffec; border:none; border-radius:10px; padding:10px 16px; cursor:pointer; font-size:13px; }
|
||||||
|
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
|
.result { margin-top:14px; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:12px; font-size:13px; white-space:pre-wrap; }
|
||||||
|
.tip { font-size:12px; color:#6b7a99; margin-top:6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="title">🧪 AI 报价测试</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<input id="cid" class="input" placeholder="客户ID,如 tb7518056865:小林">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<input id="acc" class="input" placeholder="店铺ID(可留空)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<textarea id="msg" class="input" rows="4" placeholder="输入消息文本或图片URL(多张用 #*# 分隔)。示例:这两张有原图吗#*#https://...jpg#*#https://...png"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" id="runBtn" onclick="runPricing()">测试报价</button>
|
||||||
|
</div>
|
||||||
|
<div id="result" class="result" style="display:none;"></div>
|
||||||
|
<div class="tip">提示:含图片URL时,Agent会自动调用图片分析并结合复杂度、尺寸、人脸与风险给出建议价;文本砍价低于最近图片底线会被礼貌拒绝。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function runPricing() {
|
||||||
|
const cid = document.getElementById('cid').value.trim();
|
||||||
|
const acc = document.getElementById('acc').value.trim();
|
||||||
|
const msg = document.getElementById('msg').value.trim();
|
||||||
|
const btn = document.getElementById('runBtn');
|
||||||
|
const res = document.getElementById('result');
|
||||||
|
if (!cid || !msg) { alert('请填写客户ID与消息'); return; }
|
||||||
|
btn.disabled = true; res.style.display = 'none'; res.textContent = '';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/pricing/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ from_id: cid, acc_id: acc, msg })
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
res.style.display = 'block';
|
||||||
|
res.textContent = data.error ? ('错误:'+data.error) : (
|
||||||
|
`回复:${data.reply}\n\n【调试】目标Agent:${data.agent}\n最低价:${data.floor}\n应答:${data.should_reply?'是':'否'}`
|
||||||
|
);
|
||||||
|
} catch(e) {
|
||||||
|
res.style.display = 'block';
|
||||||
|
res.textContent = '请求失败:'+e;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template_string(HTML)
|
||||||
|
|
||||||
|
@app.route("/pricing")
|
||||||
|
def pricing_index():
|
||||||
|
return render_template_string(PRICING_HTML)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/customers")
|
||||||
|
def api_customers():
|
||||||
|
return jsonify(db.get_customers(limit=200))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/conversation/<customer_id>")
|
||||||
|
def api_conversation(customer_id):
|
||||||
|
return jsonify(db.get_conversation(customer_id, limit=500))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/search")
|
||||||
|
def api_search():
|
||||||
|
kw = request.args.get("q", "").strip()
|
||||||
|
if not kw:
|
||||||
|
return jsonify([])
|
||||||
|
return jsonify(db.search_messages(kw, limit=60))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("聊天记录 UI 启动中...")
|
||||||
|
print("访问 → http://localhost:5678")
|
||||||
|
app.run(host="0.0.0.0", port=5678, debug=False)
|
||||||
|
|
||||||
|
@app.route("/api/pricing/run", methods=["POST"])
|
||||||
|
def api_pricing_run():
|
||||||
|
global pricing_agent
|
||||||
|
if pricing_agent is None:
|
||||||
|
return jsonify({"error":"报价Agent未初始化"})
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
from_id = (data.get("from_id") or "").strip()
|
||||||
|
acc_id = (data.get("acc_id") or "").strip()
|
||||||
|
msg = (data.get("msg") or "").strip()
|
||||||
|
if not from_id or not msg:
|
||||||
|
return jsonify({"error":"缺少参数 from_id 或 msg"})
|
||||||
|
# 构造提示词:直接使用用户输入,保持与正式场景一致
|
||||||
|
user_prompt = msg
|
||||||
|
deps = AgentDeps(
|
||||||
|
msg_id="pricing-test",
|
||||||
|
acc_id=acc_id or "TEST_SHOP",
|
||||||
|
from_id=from_id,
|
||||||
|
platform="taobao"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# 强制使用报价Agent
|
||||||
|
result = asyncio.run(pricing_agent.agent_pricing.run(user_prompt, deps=deps, message_history=[]))
|
||||||
|
# 读取底线
|
||||||
|
try:
|
||||||
|
from config.config import MIN_PRICE_FLOOR
|
||||||
|
st = pricing_agent._get_conversation_state(from_id)
|
||||||
|
floor = st.last_min_price if isinstance(st.last_min_price,int) and st.last_min_price>0 else MIN_PRICE_FLOOR
|
||||||
|
except Exception:
|
||||||
|
floor = None
|
||||||
|
return jsonify({
|
||||||
|
"reply": result.output,
|
||||||
|
"should_reply": True,
|
||||||
|
"agent": "pricing",
|
||||||
|
"floor": floor
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)})
|
||||||
45
scripts/init_designer_roster.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
初始化设计师派单数据(SQLite)
|
||||||
|
|
||||||
|
同一设计师在不同店铺对应不同 group_id。
|
||||||
|
用法:
|
||||||
|
python scripts/init_designer_roster.py
|
||||||
|
# 按提示添加设计师和店铺分组,或直接修改下方示例后运行
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from db.designer_roster_db import add_designer, set_designer_shop, list_designers, update_online
|
||||||
|
|
||||||
|
|
||||||
|
def init_example():
|
||||||
|
"""示例:添加设计师,同一人在不同店铺不同分组"""
|
||||||
|
# 设计师A:在 小威哥1216 用分组 20252916034,在 另一店铺 用 12345678
|
||||||
|
aid = add_designer("设计师A", "user_a")
|
||||||
|
set_designer_shop(aid, "小威哥1216", "20252916034")
|
||||||
|
set_designer_shop(aid, "另一店铺", "12345678")
|
||||||
|
|
||||||
|
# 设计师B:只在 小威哥1216
|
||||||
|
bid = add_designer("设计师B", "user_b")
|
||||||
|
set_designer_shop(bid, "小威哥1216", "99998888")
|
||||||
|
|
||||||
|
# 可选:手动标记上线(否则等企微群解析)
|
||||||
|
update_online("user_a", True)
|
||||||
|
update_online("user_b", True)
|
||||||
|
|
||||||
|
print("示例数据已写入")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "example":
|
||||||
|
init_example()
|
||||||
|
elif len(sys.argv) > 1 and sys.argv[1] == "list":
|
||||||
|
for d in list_designers():
|
||||||
|
print(f"{d['name']} ({d['wechat_user_id']}) 在线={d['is_online']}")
|
||||||
|
for shop, gid in d["shops"].items():
|
||||||
|
print(f" - {shop} -> {gid}")
|
||||||
|
else:
|
||||||
|
print("用法: python scripts/init_designer_roster.py example # 写入示例")
|
||||||
|
print(" python scripts/init_designer_roster.py list # 查看当前数据")
|
||||||