This commit is contained in:
2026-02-27 16:03:04 +08:00
commit 5aedf1665d
137 changed files with 17604 additions and 0 deletions

34
.env Normal file
View 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
View 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
View 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 QwenRunningHub 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 会返回友好提示

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

12
archive/README.md Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,24 @@

────────────────────────────────────────────────────────────
 对话记录 test_user_001 4 条)
────────────────────────────────────────────────────────────
──────────────────── 2026-02-25 ────────────────────
17:44 买家
 你好,想问下图片处理多少钱 
17:44 客服
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

Binary file not shown.

9
config/.api_cost.json Normal file
View File

@@ -0,0 +1,9 @@
{
"daily": {
"2026-02-26": 1.4850000000000005,
"2026-02-27": 1.3200000000000007
},
"monthly": {
"2026-02": 2.8050000000000015
}
}

View 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` 静态配置,并发送企微「谁在线啊」提醒

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

@@ -0,0 +1 @@
# -*- coding: utf-8 -*-

Binary file not shown.

Binary file not shown.

49
config/config.py Normal file
View 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
View 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 加关键词→类型。"
}

View File

@@ -0,0 +1,4 @@
{
"default": "20252916034",
"小威哥1216": "20252916034"
}

0
core/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1797
core/pydantic_ai_agent.py Normal file

File diff suppressed because it is too large Load Diff

1305
core/websocket_client.py Normal file

File diff suppressed because it is too large Load Diff

616
core/workflow.py Normal file
View 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

File diff suppressed because it is too large Load Diff

91
customer_db/schema.json Normal file
View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

216
db/chat_log_db.py Normal file
View 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

Binary file not shown.

729
db/customer_db.py Normal file
View 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
View 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]

Binary file not shown.

159
db/designer_roster_db.py Normal file
View 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

Binary file not shown.

0
image/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

593
image/image_analyzer.py Normal file
View 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}"
}
# 使用火山引擎官方 SDKAsyncOpenAI + /responses 接口)
client = AsyncOpenAI(
base_url=self.base_url,
api_key=self.api_key,
)
response = await client.responses.create(
model=self.vision_model,
input=[
{
"role": "user",
"content": [
image_item,
{
"type": "input_text",
"text": ANALYSIS_PROMPT
}
]
}
]
)
content = response.output_text
elapsed = time.monotonic() - start
print(f"[ImageAnalyzer] 视觉AI响应耗时: {elapsed:.1f}s")
await self._wait_remaining(elapsed)
result = self._parse_result(content)
result["elapsed"] = elapsed
# 计算尺寸与类型加价
try:
w, h = await self._get_image_size(image_path)
mp = round((w * h) / 1_000_000, 2) if w and h else 0.0
result["width"] = w
result["height"] = h
result["megapixels"] = mp
# 归一化类型
subj = (result.get("subject") or "").lower()
ptype = (result.get("proc_type") or "").lower()
ratio = result.get("aspect_ratio") or "1:1"
category = "general"
# 初步判断
if ("壁纸" in subj) or ("wallpaper" in subj) or ratio in ("9:16", "16:9"):
category = "wallpaper"
elif ("" in subj) or ("" in subj) or ("印花" in subj) or ("fabric" in subj) or ("cloth" in subj) or ("服装" in subj) or ("印花" in ptype):
category = "clothing"
elif ("logo" in subj) or ("logo" in ptype):
category = "logo"
elif ("海报" in subj) or ("poster" in subj):
category = "poster"
elif ("人像" in subj) or ("人物" in subj) or ("portrait" in subj):
category = "portrait"
elif ("产品" in subj) or ("product" in subj):
category = "product"
elif ("老照片" in subj) or ("old photo" in subj):
category = "old_photo"
# 可印花/印刷物体扩展
keywords = subj + " " + ptype
if any(k in keywords for k in ["装饰画", "挂画", "油画", "canvas", "painting"]):
category = "decor_painting"
elif any(k in keywords for k in ["窗帘", "curtain"]):
category = "curtain"
elif any(k in keywords for k in ["地垫", "脚垫", "地毯", "", "mat", "rug"]):
category = "floor_mat"
elif any(k in keywords for k in ["广告牌", "喷绘", "展架", "灯箱", "banner", "billboard"]):
category = "billboard"
elif any(k in keywords for k in ["毯子", "毛毯", "blanket"]):
category = "blanket"
elif any(k in keywords for k in ["桌布", "台布", "tablecloth", "桌旗"]):
category = "tablecloth"
elif any(k in keywords for k in ["书本", "书籍", "封面", "book", "book cover"]):
category = "book"
elif any(k in keywords for k in ["鼠标垫", "mouse pad", "mousepad"]):
category = "mouse_pad"
elif any(k in keywords for k in ["头像", "个人头像", "个人照", "profile", "avatar"]):
category = "avatar"
result["category"] = category
surcharge = 0
size_note = ""
# 按类别设定尺寸要求与加价阈值(单位:百万像素)
if category == "wallpaper":
if h and h < 1920:
size_note = "壁纸高度低于1920px清晰度可能不足"
if mp > 8:
surcharge = 10
elif mp > 3:
surcharge = 5
elif category == "clothing":
if (w and w < 1024) or (h and h < 1024):
size_note = "印花源图边长低于1024px放大后细节可能不足"
if mp > 6:
surcharge = 10
elif mp > 2:
surcharge = 5
elif category in ("poster", "portrait", "product"):
if mp > 12:
surcharge = 10
elif mp > 6:
surcharge = 5
elif category == "logo":
if mp > 6:
surcharge = 5
elif category == "decor_painting":
if (w and w < 1500) or (h and h < 1500):
size_note = "装饰画边长低于1500px打印放大可能不够清晰"
if mp > 12:
surcharge = 10
elif mp > 6:
surcharge = 5
elif category == "curtain":
if (w and w < 1500):
size_note = "窗帘宽度低于1500px印花放大可能不够清晰"
if mp > 16:
surcharge = 10
elif mp > 8:
surcharge = 5
elif category == "floor_mat":
if mp > 12:
surcharge = 10
elif mp > 6:
surcharge = 5
elif category == "billboard":
if (w and w < 2000) or (h and h < 1000):
size_note = "广告牌尺寸较小,建议更高分辨率以保证喷绘清晰"
if mp > 20:
surcharge = 10
elif mp > 10:
surcharge = 5
elif category == "blanket":
if mp > 16:
surcharge = 10
elif mp > 8:
surcharge = 5
elif category == "tablecloth":
if mp > 12:
surcharge = 10
elif mp > 6:
surcharge = 5
elif category == "book":
if (w and w < 800):
size_note = "书本封面宽度低于800px印刷细节可能不足"
if mp > 6:
surcharge = 5
elif category == "mouse_pad":
if (w and w < 1000):
size_note = "鼠标垫源图宽度低于1000px细节可能不足"
if mp > 4:
surcharge = 5
elif category == "avatar":
if (w and w < 800) or (h and h < 800):
size_note = "头像边长低于800px清晰度可能不足"
if mp > 6:
surcharge = 5
else:
if mp > 8:
surcharge = 10
elif mp > 4:
surcharge = 5
# 应用加价保持5的整数倍与 10-30 区间
base = result.get("price_suggest", 20)
adjusted = base + surcharge
adjusted = max(10, min(30, adjusted))
adjusted = round(adjusted / 5) * 5
# 同步范围
result["price_suggest"] = adjusted
result["price_max"] = max(result["price_max"], adjusted)
result["size_surcharge"] = surcharge
result["size_note"] = size_note
except Exception as e:
print(f"[ImageAnalyzer] 尺寸与类型加价计算失败: {e}")
# 写入缓存
if cache_key:
self._analysis_cache[cache_key] = (dict(result), time.monotonic())
# 简单清理:缓存超过 50 条时删最旧的
if len(self._analysis_cache) > 50:
oldest = min(self._analysis_cache.items(), key=lambda x: x[1][1])
del self._analysis_cache[oldest[0]]
return result
except asyncio.TimeoutError:
elapsed = time.monotonic() - start
print(f"[ImageAnalyzer] 请求超时 ({elapsed:.1f}s)")
return self._fallback("请求超时")
except Exception as e:
elapsed = time.monotonic() - start
print(f"[ImageAnalyzer] 分析失败: {e}")
await self._wait_remaining(elapsed)
return self._fallback(str(e))
async def _wait_remaining(self, elapsed: float):
"""补足最短等待时间"""
remaining = self.MIN_WAIT_SECONDS - elapsed
if remaining > 0:
await asyncio.sleep(remaining)
def _parse_line(self, content: str, *keys: str) -> str:
"""从多行文本中提取指定字段值,支持中英文冒号"""
for line in content.strip().split("\n"):
line = line.strip()
for key in keys:
if line.startswith(key):
return line.split(":", 1)[-1].split("", 1)[-1].strip()
return ""
def _parse_result(self, content: str) -> dict:
"""解析模型返回的结果"""
p = self._parse_line
# 复杂度
complexity_raw = p(content, "复杂度:", "复杂度:").lower()
complexity = complexity_raw if complexity_raw in self.PRICE_MAP else "normal"
sensitive = p(content, "敏感内容:", "敏感内容:").lower().strip()
flatness = p(content, "平整度:", "平整度:").lower().strip() # flat|mild|rough
has_text = p(content, "含文字:", "含文字:").lower().strip()
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
View 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
View 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
View 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
View 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
View 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. 二值化 + approxPolyDPepsilon 从小到大尝试)
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)
# ── 策略1approxPolyDPepsilon 逐步放大直到得到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
# ── 策略3minAreaRect 四角(兜底)─────────────────────────
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
View 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
View 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
View 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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

331
mail/email_receiver.py Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

25
run.py Normal file
View 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已停止")

Binary file not shown.

371
scripts/chat_log_viewer.py Normal file
View 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}请提供客户IDpython 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
View 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,'&lt;').replace(/>/g,'&gt;');
return esc;
});
return segs.join('<br>');
}
const escaped = msg.replace(/</g,'&lt;').replace(/>/g,'&gt;');
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,'&lt;').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)})

View 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 # 查看当前数据")

0
services/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

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