commit 5aedf1665d1cb8e88a9c4baa6789ceb6d2dfac39 Author: jimi <1847930177@qq.com> Date: Fri Feb 27 16:03:04 2026 +0800 init diff --git a/.env b/.env new file mode 100644 index 0000000..114fb23 --- /dev/null +++ b/.env @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f2bebb --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc574f7 --- /dev/null +++ b/README.md @@ -0,0 +1,346 @@ +# 电商客服 AI 自动回复系统 + +## 项目概述 + +基于 PydanticAI 的淘宝修图店客服系统,支持自动回复、智能报价、转接人工、客户画像等功能。 +付款后自动触发图片处理流水线,完成后通过邮件将结果发送给客户。 + +--- + +## 最近更新 + +| 更新项 | 说明 | +|--------|------| +| **消息处理非阻塞** | 图片分析、Agent 回复改为后台任务,接收循环不阻塞,可同时处理多客户 | +| **同客户串行** | 按客户加锁,保证「发图→这个高清」等顺序,避免误判 | +| **并发限流** | agent_reply 最多 8 个并发,防止 API 打满 | +| **图片分析缓存** | 同一 URL 5 分钟内复用结果,节省视觉 API 调用 | +| **报价维度** | 平整度、含文字(小字加价)、含人脸、阴影,越平整越便宜 | +| **项目结构** | 主文件归类到 `core/`、`db/`、`image/`、`services/`、`mail/`、`utils/` 子目录 | +| **配置中心** | `config/config.py` 统一路径、常量,支持 `LOG_MAX_BYTES`、`IMAGE_QUEUE_*` 等 | +| **转接分组** | `config/transfer_groups.json` 店铺→分组映射 | +| **设计师派单** | SQLite 存储,转人工时按需查询在线,轮询派单 | +| **健康检查** | 定时检测轻简连接,断线企微告警 | +| **日志轮转** | 按 10MB 切分,保留 7 份 | +| **图片队列** | 并发限制 2,高并发时排队 | +| **邮件重试** | 发送失败自动重试 1 次 | +| **Web 启动器** | `scripts/launcher_ui.py` 酷炫控制台,http://localhost:5679 | +| **矢量化/美图 Tool** | `vectorize_to_eps_tool`、`meitu_enhance_tool` | +| **客服对话增强** | 语义匹配、多轮记忆、个性化、主动预测 | +| **单元测试** | `tests/test_config.py`、`test_image_queue.py`、`test_health_check.py` 等 | + +--- + +## 快速开始 + +```bash +# 1. 安装依赖 +pip install -r requirements.txt + +# 2. 配置环境变量 +cp .env.example .env +# 编辑 .env 填入 API Key、邮件等 + +# 3. 启动(需轻简软件已运行在 ws://127.0.0.1:9528) +python run.py +``` + +--- + +## 功能列表 + +| 功能 | 说明 | +|------|------| +| 自动回复 | 收到消息自动回复,防抖合并连续短消息,后台处理不阻塞接收 | +| 售前分流 | 自动识别售前/售后阶段 | +| 智能报价 | 图片分析后按复杂度报价(10-30元,5的整数倍),平整度/文字/人脸/阴影影响价格 | +| 压价应对 | 只让价一次,记录历史让价次数 | +| 转接人工 | 退款/投诉/情绪激动自动转接 | +| 订单识别 | 自动识别系统订单消息,付款后触发作图 | +| 付款检测 | 催单时核查付款状态,未付款不误导客户 | +| 图片处理 | 透视矫正 + Qwen高清增强五步流水线 | +| 质检重试 | 视觉AI质检,不合格自动重试(最多2次)| +| 颜色匹配 | 类PS「匹配颜色」算法,修正AI处理后色差 | +| 边框裁切 | 自动检测任意颜色背景边并裁切 | +| 客户画像 | 自动提取邮箱/电话/微信/性格/价格敏感度 | +| 企微通知 | API异常/质检失败/订单金额异常推送企微 | +| SKILL.md | 支持技能文档动态加载 | +| 矢量化 | 图片转 EPS 矢量文件(独立 Tool)| +| 美图增强 | 画质增强(极速/标准/增强/HDR/人像)| +| Web 启动器 | 酷炫控制台一键启停客服机器人 | + +--- + +## 项目结构 + +``` +D:\Terminator\ +├── run.py # 项目入口(启动客服机器人) +├── core/ # 核心逻辑 +│ ├── websocket_client.py # WebSocket 客户端(主程序,含防抖) +│ ├── pydantic_ai_agent.py# AI Agent 核心(含报价/风险/订单逻辑) +│ └── workflow.py # 工作流(付款触发 → 作图 → 发邮件) +├── db/ # 数据层 +│ ├── customer_db.py # 客户画像数据库(SQLite) +│ ├── chat_log_db.py # 聊天记录数据库 +│ └── designer_roster_db.py # 设计师派单(同一人不同店铺不同分组,轮询) +├── image/ # 图片处理 +│ ├── image_analyzer.py # 图片分析(复杂度/风险/Gemini提示词) +│ ├── image_processor.py # 图片处理主模块(下载→透视→增强→质检) +│ ├── image_tools.py # 独立图片工具(去背景/透视/增强/裁边等) +│ ├── image_qa.py # 视觉AI质检(对比原图和结果,0-100分) +│ └── perspective_fix.py # 透视矫正五步流水线(独立可运行) +├── services/ # 外部服务 +│ ├── service_gemini.py # Gemini API(去背景/增强) +│ ├── service_qwen.py # Qwen RunningHub API(高清增强) +│ ├── service_meitu.py # 美图 API(画质增强) +│ └── service_vectorizer.py # 矢量化服务(转 EPS) +├── mail/ # 邮件(避免与标准库 email 冲突) +│ ├── email_sender.py # 邮件发送(SMTP) +│ └── email_receiver.py # 邮件接收(IMAP 轮询) +├── utils/ +│ ├── daily_summary.py # 日报推送 +│ ├── service_base.py # 服务基类(矢量化等) +│ ├── intent_analyzer.py # 语义匹配(意图/情绪) +│ ├── image_queue.py # 图片处理队列 +│ ├── health_check.py # 健康检查 +│ └── designer_roster.py # 设计师在线(转人工时按需查询) +├── config/ +│ ├── config.py # 配置中心(路径、常量) +│ └── transfer_groups.json # 店铺 acc_id → 转接分组 group_id 映射 +├── scripts/ # 可执行脚本 +│ ├── launcher_ui.py # Web 控制台(一键启停客服机器人) +│ ├── init_designer_roster.py # 设计师派单数据初始化 +│ ├── chat_ui.py # 聊天记录 Web 查看器 +│ └── chat_log_viewer.py # 聊天记录 CLI 查看器 +├── tests/ +│ ├── test_process.py # 图片处理流程测试 +│ ├── test_config.py # 配置中心测试 +│ ├── test_image_queue.py # 图片队列测试 +│ └── test_health_check.py# 健康检查测试 +├── archive/ # 归档(未用/旧版文件) +├── logs/ # 日志 +├── results/ # 处理结果图片 +└── .env # 环境配置 +``` + +--- + +## 图片处理流水线(perspective_fix.py) + +客户付款后自动运行,共五步: + +``` +原图 + │ + ▼ Step 1 Gemini 去背景 → 纯白/纯色背景 + │ ┗ 自动检测白色覆盖率,< 20% 则换强化提示词重试 + │ + ▼ Step 2 OpenCV 轮廓检测 + 透视矫正 + │ ┗ 三种策略:approxPolyDP → 凸包极值 → minAreaRect + │ ┗ 自动检测 Gemini 旋转问题并纠正方向 + │ + ▼ Step 3 Qwen(RunningHub ComfyUI)高清增强 + │ ┗ 失败时降级到 Gemini 简化提示词兜底 + │ + ▼ Step 4 豆包视觉 AI 决策后处理 + │ ┣ 颜色匹配(需要时):LAB色彩空间 Reinhard 算法 + │ │ ┗ 按颜色差异程度自动调整强度(明显80% / 轻微55%) + │ ┗ 背景边裁切(需要时):自适应背景色检测,支持任意颜色边框 + │ ┗ 从四角采样背景色,逐行/列扫描(非硬编码白色) + │ + ▼ 输出最终图片(results/ 目录) +``` + +### 独立运行 +```bash +python -m image.perspective_fix <图片路径或URL> [--debug] [--skip-step1] [--skip-step3] +``` + +--- + +## 环境配置 (.env) + +```env +# 火山引擎豆包 API +OPENAI_API_KEY=你的API Key +OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +OPENAI_MODEL=doubao-seed-2-0-lite-260215 +VISION_MODEL=doubao-seed-2-0-mini-260215 + +# 邮件SMTP +SMTP_HOST=smtp.qq.com +SMTP_PORT=587 +SMTP_USER=your_email@qq.com +SMTP_PASSWORD=your_smtp_password +SENDER_NAME=修图客服 + +# 企业微信群机器人 Webhook +WECHAT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=你的key + +# 质检配置 +QA_PASS_SCORE=70 # 质检合格分(0-100) +PROCESS_MAX_RETRIES=2 # 最大重试次数 + +# 日报 +SUMMARY_EMAIL= # 接收日报的邮箱,留空不发 +SUMMARY_HOUR=23 +SUMMARY_MINUTE=50 + +# 可选:语义匹配(embedding 意图/情绪,不配置则用关键词) +# EMBEDDING_MODEL=text-embedding-3-small + +# 可选:美图画质增强(Tool 调用时需) +# MEITU_API_URL=http://your-meitu-api:port + +# 可选:矢量化服务(Tool 调用时需) +# 矢量化服务 base_url 在 service_vectorizer.py 中默认配置 + +# 日志轮转(默认 10MB 切分,保留 7 份) +# LOG_MAX_BYTES=10 +# LOG_BACKUP_COUNT=7 + +# 图片队列(默认并发 2,队列上限 20) +# IMAGE_QUEUE_MAX_CONCURRENT=2 +# IMAGE_QUEUE_MAX_SIZE=20 + +# 健康检查(默认 60 秒) +# HEALTH_CHECK_INTERVAL=60 +``` + +--- + +## 运行方式 + +```bash +# 启动主程序(客服机器人) +python run.py + +# 不启用 AI(仅监听消息) +python run.py --no-agent + +# Web 控制台(酷炫界面一键启停,含 Agent 开关) +python scripts/launcher_ui.py +# 访问 http://localhost:5679 + +# 单独测试图片处理流水线 +python -m image.perspective_fix results/your_image.jpg --debug + +# 测试完整流程(含付款触发) +python tests/test_process.py + +# 运行单元测试 +python tests/test_config.py +python tests/test_image_queue.py +python tests/test_health_check.py + +# 聊天记录 Web UI +python scripts/chat_ui.py +# 访问 http://localhost:5678 + +# 聊天记录 CLI 查看 +python scripts/chat_log_viewer.py # 列出所有客户 +python scripts/chat_log_viewer.py <客户ID> # 查看某客户全部对话 +python scripts/chat_log_viewer.py -s <关键词> # 全局搜索 +python scripts/chat_log_viewer.py -t <客户ID> # 只看今天 +python scripts/chat_log_viewer.py -l # 实时监听最新消息 +``` + +--- + +## 报价逻辑 + +| 复杂度 | 价格区间 | 说明 | +|--------|----------|------| +| 简单 | 10-15 元 | 画面平整、无小字、无人脸、无阴影 | +| 一般 | 15-20 元 | 一般复杂度 | +| 复杂 | 20-25 元 | 细节偏多、有褶皱/小字/人脸/阴影 | +| 困难 | 25-30 元 | 非常复杂 | + +**价格必须为 5 的整数倍**(10/15/20/25/30) + +**报价维度(越平整越便宜):** +- **平整度**:flat 便宜 → mild 中等 → rough 贵 +- **含文字**:大字不加价,小字需精细保留则加价 +- **含人脸**:有人脸加价 +- **阴影**:有明显阴影需处理则加价 + +**风险等级:** +- `none`:直接报价 +- `low`(含人脸):报价 + 风险提示,说明人脸相似度约70-90% +- `high`(严重模糊/需打印/老照片):必须先说明风险再报价 +- `no`(无法处理):告知客户换图,不报价 + +--- + +## 触发转接 + +发送以下关键词自动转接人工: +- `test`(测试) +- `我要退款` / `退货` / `投诉` +- 情绪激动 + +**店铺→分组映射**:不同店铺对应不同客服分组,相同客服在不同店铺的分组 ID 不同。在 `config/transfer_groups.json` 中配置: + +```json +{ + "default": "20252916034", + "店铺A_acc_id": "分组ID1", + "店铺B_acc_id": "分组ID2" +} +``` + +- `default`:未配置店铺时的默认分组 +- 其他 key 为店铺 `acc_id`,value 为该店铺的转接分组 ID + +**设计师派单(可选)**:SQLite 存储,同一设计师不同店铺不同 group_id。`python scripts/init_designer_roster.py example` 初始化。转人工时按需 GET `DESIGNER_ROSTER_API` 同步在线状态,无人在线时发企微「谁在线啊」。 + +--- + +## 客户画像字段 + +从对话自动提取并持久化: + +| 字段 | 内容 | +|------|------| +| 联系方式 | 邮箱、手机、微信 | +| 消费记录 | 订单数、历史报价、最低接受价 | +| 性格标签 | 爽快/纠结/砍价/批量 | +| 图片偏好 | 处理类型、格式偏好、尺寸需求 | +| 最近图片 | URL、Gemini提示词、比例、透视状态 | +| 处理参数 | gemini_prompt、aspect_ratio、perspective | + +数据库:`customer_db/customers.json` + +--- + +## 已实现 + +| 功能 | 说明 | +|------|------| +| **config/config.py** | 配置中心,统一路径与常量 | +| **健康检查** | 定时检测轻简连接,断线时企微告警 | +| **日志轮转** | 按 10MB 切分,保留 7 份 | +| **图片队列** | 并发限制 2,队列上限 20,高并发时排队 | +| **单元测试** | `tests/test_*.py` | +| **客服对话增强** | 语义匹配、多轮记忆、个性化、主动预测 | + +### 客服对话增强 + +| 能力 | 说明 | +|------|------| +| **语义匹配** | 配置 `EMBEDDING_MODEL` 后用 embedding 识别意图/情绪,否则关键词 | +| **多轮记忆** | 重启后从数据库加载近期对话,补充上下文 | +| **个性化** | 按性格(爽快/砍价/纠结)调整语气,按价格敏感度调整报价策略 | +| **主动预测** | 批量潜力客户主动推打包价,老客爽快直接推成交 | + +--- + +## 注意事项 + +1. 需要轻简软件运行在 `ws://127.0.0.1:9528` +2. 转接格式:`话术|[转移会话],分组{group_id},无原因`,分组 ID 由 `config/transfer_groups.json` 按店铺映射 +3. Gemini 使用西风代理接口,需配置对应 API Key +4. Qwen 高清增强使用 RunningHub ComfyUI 工作流,需配置 `api_key`(service_qwen.py) +5. 图片处理结果保存在 `results/` 目录(可通过 `RESULT_IMAGE_DIR` 环境变量修改) +6. 美图、矢量化 Tool 需对应服务可用;缺失依赖(如 aiofiles)时 Tool 会返回友好提示 diff --git a/__pycache__/chat_log_db.cpython-310.pyc b/__pycache__/chat_log_db.cpython-310.pyc new file mode 100644 index 0000000..927e770 Binary files /dev/null and b/__pycache__/chat_log_db.cpython-310.pyc differ diff --git a/__pycache__/customer_db.cpython-310.pyc b/__pycache__/customer_db.cpython-310.pyc new file mode 100644 index 0000000..8dfe83f Binary files /dev/null and b/__pycache__/customer_db.cpython-310.pyc differ diff --git a/__pycache__/daily_summary.cpython-310.pyc b/__pycache__/daily_summary.cpython-310.pyc new file mode 100644 index 0000000..b8ecae2 Binary files /dev/null and b/__pycache__/daily_summary.cpython-310.pyc differ diff --git a/__pycache__/email_receiver.cpython-310.pyc b/__pycache__/email_receiver.cpython-310.pyc new file mode 100644 index 0000000..8104f57 Binary files /dev/null and b/__pycache__/email_receiver.cpython-310.pyc differ diff --git a/__pycache__/email_sender.cpython-310.pyc b/__pycache__/email_sender.cpython-310.pyc new file mode 100644 index 0000000..4813bd2 Binary files /dev/null and b/__pycache__/email_sender.cpython-310.pyc differ diff --git a/__pycache__/image_analyzer.cpython-310.pyc b/__pycache__/image_analyzer.cpython-310.pyc new file mode 100644 index 0000000..c7887a5 Binary files /dev/null and b/__pycache__/image_analyzer.cpython-310.pyc differ diff --git a/__pycache__/image_processor.cpython-310.pyc b/__pycache__/image_processor.cpython-310.pyc new file mode 100644 index 0000000..3222338 Binary files /dev/null and b/__pycache__/image_processor.cpython-310.pyc differ diff --git a/__pycache__/image_qa.cpython-310.pyc b/__pycache__/image_qa.cpython-310.pyc new file mode 100644 index 0000000..2c447a2 Binary files /dev/null and b/__pycache__/image_qa.cpython-310.pyc differ diff --git a/__pycache__/image_tools.cpython-310.pyc b/__pycache__/image_tools.cpython-310.pyc new file mode 100644 index 0000000..3a673e8 Binary files /dev/null and b/__pycache__/image_tools.cpython-310.pyc differ diff --git a/__pycache__/perspective_fix.cpython-310.pyc b/__pycache__/perspective_fix.cpython-310.pyc new file mode 100644 index 0000000..4216c51 Binary files /dev/null and b/__pycache__/perspective_fix.cpython-310.pyc differ diff --git a/__pycache__/pydantic_ai_agent.cpython-310.pyc b/__pycache__/pydantic_ai_agent.cpython-310.pyc new file mode 100644 index 0000000..944455b Binary files /dev/null and b/__pycache__/pydantic_ai_agent.cpython-310.pyc differ diff --git a/__pycache__/service_gemini.cpython-310.pyc b/__pycache__/service_gemini.cpython-310.pyc new file mode 100644 index 0000000..1e3f7d4 Binary files /dev/null and b/__pycache__/service_gemini.cpython-310.pyc differ diff --git a/__pycache__/service_qwen.cpython-310.pyc b/__pycache__/service_qwen.cpython-310.pyc new file mode 100644 index 0000000..5563e6e Binary files /dev/null and b/__pycache__/service_qwen.cpython-310.pyc differ diff --git a/__pycache__/workflow.cpython-310.pyc b/__pycache__/workflow.cpython-310.pyc new file mode 100644 index 0000000..fc7a31a Binary files /dev/null and b/__pycache__/workflow.cpython-310.pyc differ diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..212da0a --- /dev/null +++ b/archive/README.md @@ -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 | 临时输出 | diff --git a/archive/test_battle.py b/archive/test_battle.py new file mode 100644 index 0000000..2c29db2 --- /dev/null +++ b/archive/test_battle.py @@ -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 diff --git a/archive/test_import.py b/archive/test_import.py new file mode 100644 index 0000000..ecb4230 --- /dev/null +++ b/archive/test_import.py @@ -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() diff --git a/archive/view_chats.py b/archive/view_chats.py new file mode 100644 index 0000000..f7e7acb --- /dev/null +++ b/archive/view_chats.py @@ -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() diff --git a/archive/viewer_out.txt b/archive/viewer_out.txt new file mode 100644 index 0000000..934faea --- /dev/null +++ b/archive/viewer_out.txt @@ -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 + 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 diff --git a/chat_log_db/chats.db b/chat_log_db/chats.db new file mode 100644 index 0000000..c7881a1 Binary files /dev/null and b/chat_log_db/chats.db differ diff --git a/config/.api_cost.json b/config/.api_cost.json new file mode 100644 index 0000000..94ab296 --- /dev/null +++ b/config/.api_cost.json @@ -0,0 +1,9 @@ +{ + "daily": { + "2026-02-26": 1.4850000000000005, + "2026-02-27": 1.3200000000000007 + }, + "monthly": { + "2026-02": 2.8050000000000015 + } +} \ No newline at end of file diff --git a/config/DESIGNER_ROSTER_API.md b/config/DESIGNER_ROSTER_API.md new file mode 100644 index 0000000..744eab6 --- /dev/null +++ b/config/DESIGNER_ROSTER_API.md @@ -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` 静态配置,并发送企微「谁在线啊」提醒 diff --git a/config/DESIGNER_ROSTER_需求-给另一台AI.md b/config/DESIGNER_ROSTER_需求-给另一台AI.md new file mode 100644 index 0000000..0f917e4 --- /dev/null +++ b/config/DESIGNER_ROSTER_需求-给另一台AI.md @@ -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` 同步本地后派单。无人在线时发企微「谁在线啊」。 diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..5fb77c7 --- /dev/null +++ b/config/README.md @@ -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` 同步。无人在线时发企微「谁在线啊」。 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..40a96af --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/config/__pycache__/__init__.cpython-310.pyc b/config/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..0e2270a Binary files /dev/null and b/config/__pycache__/__init__.cpython-310.pyc differ diff --git a/config/__pycache__/config.cpython-310.pyc b/config/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000..317a117 Binary files /dev/null and b/config/__pycache__/config.cpython-310.pyc differ diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..e8fb4f1 --- /dev/null +++ b/config/config.py @@ -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")) diff --git a/config/shop_prompts.json b/config/shop_prompts.json new file mode 100644 index 0000000..1406c29 --- /dev/null +++ b/config/shop_prompts.json @@ -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 加关键词→类型。" +} diff --git a/config/transfer_groups.json b/config/transfer_groups.json new file mode 100644 index 0000000..530b8b8 --- /dev/null +++ b/config/transfer_groups.json @@ -0,0 +1,4 @@ +{ + "default": "20252916034", + "小威哥1216": "20252916034" +} diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/__pycache__/__init__.cpython-310.pyc b/core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..735cef9 Binary files /dev/null and b/core/__pycache__/__init__.cpython-310.pyc differ diff --git a/core/__pycache__/pydantic_ai_agent.cpython-310.pyc b/core/__pycache__/pydantic_ai_agent.cpython-310.pyc new file mode 100644 index 0000000..cc88b6a Binary files /dev/null and b/core/__pycache__/pydantic_ai_agent.cpython-310.pyc differ diff --git a/core/__pycache__/websocket_client.cpython-310.pyc b/core/__pycache__/websocket_client.cpython-310.pyc new file mode 100644 index 0000000..cbcd6fe Binary files /dev/null and b/core/__pycache__/websocket_client.cpython-310.pyc differ diff --git a/core/__pycache__/workflow.cpython-310.pyc b/core/__pycache__/workflow.cpython-310.pyc new file mode 100644 index 0000000..96c91fa Binary files /dev/null and b/core/__pycache__/workflow.cpython-310.pyc differ diff --git a/core/pydantic_ai_agent.py b/core/pydantic_ai_agent.py new file mode 100644 index 0000000..ab3a71b --- /dev/null +++ b/core/pydantic_ai_agent.py @@ -0,0 +1,1797 @@ +"""PydanticAI Agent 模块 + +架构:单 Agent + 多 Tool 模式 +- Agent 负责对话逻辑和决策 +- Tool 负责具体能力:看图/查客户/转接 +- AI 自主决定何时调用哪个工具,时序自然,不需要外部协调 +""" +import os +import glob +import asyncio +from typing import Optional, Dict +from datetime import datetime +from pydantic import BaseModel +from pydantic_ai import Agent, RunContext +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider +from dotenv import load_dotenv + +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, tag: str = "通知"): + """发送企业微信 markdown 通知,任何异常都发""" + if not _WECHAT_WEBHOOK: + print(f"[{tag}] 未配置 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"[{tag}] 企业微信推送成功 ✓") + else: + print(f"[{tag}] 企业微信推送失败: {data}") + except Exception as e: + print(f"[{tag}] 企业微信发送异常: {e}") + + +async def _notify_wechat_overdue(): + """API 欠费时发企业微信通知""" + await _notify_wechat( + "⚠️ **火山引擎 API 欠费**,客服AI已停止响应,请立即充值!\n" + "地址:https://console.volcengine.com/ark" + ) + + +# ========== 转接常量 ========== +TRANSFER_MESSAGE = "话术|[转移会话],分组20252916034,无原因" + + +# ========== 数据模型 ========== + + +class CustomerMessage(BaseModel): + """客户消息模型""" + msg_id: str + acc_id: str + msg: str + from_id: str + from_name: str + cy_id: str + acc_type: str + msg_type: int + cy_name: str + goods_name: Optional[str] = None + goods_order: Optional[str] = None + + +class ConversationState(BaseModel): + """对话状态""" + customer_id: str + stage: str = "售前" # 售前/售后 + last_price: Optional[int] = None # 最后报价 + last_min_price: Optional[int] = None # 最近图片的最低价 + last_order_id: Optional[str] = None # 订单号 + order_status: Optional[str] = None # 订单状态 + discount_count: int = 0 # 让价次数 + image_count: int = 0 # 图片数量 + last_update: str = "" + last_reply_at: Optional[datetime] = None # 最后一次回复客户的时间 + + +class AgentDeps(BaseModel): + """Agent 依赖项 - 用于传递上下文""" + msg_id: str + acc_id: str + from_id: str + platform: str + + +class AgentResponse(BaseModel): + """Agent 回复模型""" + reply: str + should_reply: bool = True + need_transfer: bool = False # 是否需要转人工 + transfer_msg: str = "" # 转接消息 + + +def _get_shop_type(acc_id: str = "", goods_name: str = "") -> str: + """根据 acc_id 或 goods_name 判断店铺类型,返回 gemini_api / find_image / default""" + try: + from config.config import CONFIG_DIR + import json + cfg_path = CONFIG_DIR / "shop_prompts.json" + if not cfg_path.exists(): + return "find_image" + with open(cfg_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + shops = cfg.get("shops", {}) + goods_kw = cfg.get("goods_keywords", {}) + type_hints = cfg.get("type_hints", {}) + # 优先按 acc_id + if acc_id and acc_id in shops: + return shops[acc_id].get("type", "find_image") + # 按商品名关键词 + goods_lower = (goods_name or "").lower() + for kw, stype in goods_kw.items(): + if kw in goods_lower: + return stype + except Exception: + pass + return "find_image" + + +def load_skill_md(skills_dir: str = "skills") -> str: + """加载 skills 目录下的所有 SKILL.md 文件内容""" + skill_contents = [] + skill_files = glob.glob(os.path.join(skills_dir, "**/SKILL.md"), recursive=True) + for skill_file in skill_files: + try: + with open(skill_file, 'r', encoding='utf-8') as f: + content = f.read() + skill_contents.append(content) + except Exception as e: + print(f"警告: 读取 {skill_file} 失败: {e}") + + return "\n\n".join(skill_contents) + + +class CustomerServiceAgent: + """客服 Agent - 支持 SKILL.md + 工作流""" + + def __init__(self, skills_dir: str = "skills"): + self.api_key = os.getenv("OPENAI_API_KEY") + self.base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + self.model_name = os.getenv("OPENAI_MODEL", "gpt-4o-mini") + + if not self.api_key: + raise ValueError("请设置 OPENAI_API_KEY 环境变量") + + # 对话状态管理 + self.conversations: Dict[str, ConversationState] = {} + # 多轮对话历史(PydanticAI ModelMessage 列表,按客户ID存储) + self.message_histories: Dict[str, list] = {} + + # 加载 skills 内容 + self.skills_content = load_skill_md(skills_dir) + + # 创建 OpenAI 模型 + model = OpenAIChatModel( + model_name=self.model_name, + provider=OpenAIProvider( + api_key=self.api_key, + base_url=self.base_url + ) + ) + + self.agent = Agent( + model=model, + deps_type=AgentDeps, + system_prompt=self._get_system_prompt() + ) + self.agent_after_sale = Agent( + model=model, + deps_type=AgentDeps, + system_prompt=self._get_after_sale_prompt() + ) + self.agent_pricing = Agent( + model=model, + deps_type=AgentDeps, + system_prompt=self._get_pricing_prompt() + ) + self.agent_processing = Agent( + model=model, + deps_type=AgentDeps, + system_prompt=self._get_processing_prompt() + ) + self.agent_similar = Agent( + model=model, + deps_type=AgentDeps, + system_prompt=self._get_similar_prompt() + ) + self.agent_order = Agent( + model=model, + deps_type=AgentDeps, + system_prompt=self._get_order_prompt() + ) + self.agent_risk = Agent( + model=model, + deps_type=AgentDeps, + system_prompt=self._get_risk_prompt() + ) + + # 注册工具 + self._register_tools() + + def _register_tools(self): + """注册所有 Tool,让 Agent 可以主动调用""" + + @self.agent.tool + async def analyze_image(ctx: RunContext[AgentDeps], image_url: str) -> str: + """ + 分析客户发来的图片复杂度,用于报价。 + 收到图片URL时调用此工具,返回复杂度和建议报价。 + """ + try: + from image.image_analyzer import image_analyzer + result = await image_analyzer.analyze(image_url) + complexity_label = { + "simple": "简单(画面干净)", + "normal": "一般复杂度", + "complex": "细节偏多", + "hard": "非常复杂", + }.get(result["complexity"], result["complexity"]) + # 持久化图片URL和复杂度,重启后仍能记住这张图 + try: + from db.customer_db import db + db.update_last_image( + ctx.deps.from_id, + image_url, + complexity=result["complexity"], + gemini_prompt=result.get("gemini_prompt", ""), + aspect_ratio=result.get("aspect_ratio", "1:1"), + perspective=result.get("perspective", "no"), + ) + except Exception: + pass + + # 存图片类型到客户画像 + try: + from db.customer_db import db as _db + if result.get("subject"): + _db.add_image_type(ctx.deps.from_id, result["subject"]) + except Exception: + pass + + # 在 workflow 里创建待处理任务(付款后自动触发 Gemini) + try: + from core.workflow import workflow + await workflow.image_analysis_result( + customer_id=ctx.deps.from_id, + image_url=image_url, + complexity=result["complexity"], + acc_id=ctx.deps.acc_id, + acc_type=ctx.deps.platform, + gemini_prompt=result.get("gemini_prompt", ""), + aspect_ratio=result.get("aspect_ratio", "1:1"), + perspective=result.get("perspective", "no"), + proc_type=result.get("proc_type", ""), + subject=result.get("subject", ""), + quality=result.get("quality", ""), + ) + print(f"[Agent] Workflow 任务已创建 | 客户: {ctx.deps.from_id} | 比例: {result.get('aspect_ratio')} | 透视: {result.get('perspective')} | 图片: {image_url[:60]}...") + except Exception as e: + print(f"[Agent] Workflow 任务创建失败: {e}") + + # 组装给 AI 的分析报告 + risk = result.get("risk", "none") + has_face = result.get("has_face", "no") + feasibility = result.get("feasibility", "yes") + note = result.get("note", "") + + lines = [ + f"图片主体:{result['subject'] or '未识别'}", + f"处理类型:{result['proc_type'] or '高清修复'}", + f"原图质量:{result['quality'] or '未知'}", + f"图片类型:{result.get('category', '') or '通用'}", + f"图片尺寸:{(result.get('width') or 0)}x{(result.get('height') or 0)}({result.get('megapixels', 0.0)}MP)", + f"含人脸:{'是' if has_face == 'yes' else '否'}", + f"复杂度:{complexity_label}", + f"原因:{result['reason']}", + ] + if result.get("size_surcharge"): + lines.append(f"尺寸加价:+{result['size_surcharge']}元") + if result.get("size_note"): + lines.append(f"尺寸提示:{result['size_note']}") + try: + st = self._get_conversation_state(ctx.deps.from_id) + if isinstance(result.get("price_min"), (int, float)): + st.last_min_price = int(result.get("price_min") or 0) + try: + from db.customer_db import db as _db + _db.update_last_min_price(ctx.deps.from_id, st.last_min_price) + except Exception: + pass + except Exception: + pass + + # 根据可做性和风险等级给 AI 不同的行动指引 + if feasibility == "no": + if "敏感" in (note or ""): + lines.append("【拒绝】图片含敏感/黄色/擦边内容,不接单。") + lines.append("→ 直接拒绝,不说「发图来看看」,自然回复如:这类不做/不接。") + else: + lines.append("【无法处理】此图无法处理(纯黑/纯白/完全损坏/要找原始RAW文件)。") + lines.append("→ 告知客户无法处理,建议换图或说明原因,不要报价。") + elif risk == "high": + lines.append(f"【高风险】此图处理风险高:{note or 'AI修复后效果不能保证与原图一致'}") + lines.append(f"建议报价:{result['price_suggest']}元") + lines.append("→ 先自然说明风险(人脸/效果可能不完美),再报价,满意再拍。话术自然。") + elif risk == "low": + lines.append(f"【低风险-含人脸】修复后人脸相似度约70-90%,效果不稳定。") + lines.append(f"建议报价:{result['price_suggest']}元") + lines.append(f"→ 报价时自然加一句风险提示(人脸可能有轻微变化、满意再付等)") + else: + # 无风险,正常报价 + lines.append(f"建议报价:{result['price_suggest']}元(区间 {result['price_min']}-{result['price_max']}元)") + if feasibility == "partial": + lines.append(f"⚠️ 此图有一定难度:{note or '效果可能不完美'},回复时可加「效果不满意退款」") + if note and note not in ("无", ""): + lines.append(f"提示:{note}") + lines.append(f"【立刻回复客户报价 {result['price_suggest']} 元,话术自然多变】") + + return "\n".join(lines) + except Exception as e: + return f"图片分析失败: {e},请根据经验判断报价" + + @self.agent.tool + async def get_customer_info(ctx: RunContext[AgentDeps], customer_id: str) -> str: + """ + 查询客户历史信息:消费记录、性格标签、报价历史等。 + 对话开始时或需要了解客户背景时调用。 + """ + try: + from db.customer_db import db + return db.get_profile_text(customer_id) + except Exception as e: + return f"查询失败: {e}" + + @self.agent.tool + async def transfer_to_human(ctx: RunContext[AgentDeps]) -> str: + """ + 转接人工客服。 + 遇到退款/投诉/情绪激动/复杂售后时调用。 + """ + return "TRANSFER_REQUESTED" + + @self.agent.tool + async def save_customer_note( + ctx: RunContext[AgentDeps], + customer_id: str, + note: str + ) -> str: + """ + 记录客户关键信息到画像(邮箱/微信/特殊需求等)。 + 客户提供联系方式或重要信息时调用。 + """ + try: + from db.customer_db import db + db.add_note(customer_id, note) + return "已记录" + except Exception as e: + return f"记录失败: {e}" + + @self.agent.tool + async def update_contact_info( + ctx: RunContext[AgentDeps], + customer_id: str, + contact_type: str, + value: str + ) -> str: + """ + 更新客户联系方式。 + 当客户说出邮箱/手机/微信时调用,比正则提取更准确。 + + contact_type 枚举值: + email - 邮箱 + phone - 手机号 + wechat - 微信号 + """ + try: + from db.customer_db import db + if contact_type == "email": + db.update_email(customer_id, value) + elif contact_type == "phone": + db.update_phone(customer_id, value) + elif contact_type == "wechat": + db.update_wechat(customer_id, value) + else: + return f"未知联系方式类型: {contact_type}" + return f"已保存 {contact_type}: {value}" + except Exception as e: + return f"保存失败: {e}" + + @self.agent.tool + async def record_quote( + ctx: RunContext[AgentDeps], + customer_id: str, + price: int, + description: str = "" + ) -> str: + """ + 记录本次报价到客户画像,用于后续对话保持价格一致。 + 每次给客户报价后调用。 + + Args: + customer_id: 客户ID + price: 报价金额(元) + description: 报价描述,如"单图处理"/"三图打包" + """ + try: + from db.customer_db import db + db.update_last_price(customer_id, price) + if description: + db.add_note(customer_id, f"报价 {price}元({description})") + # 同步到内存状态 + state = self.conversations.get(customer_id) + if state: + state.last_price = price + return f"已记录报价 {price}元" + except Exception as e: + return f"记录失败: {e}" + + @self.agent.tool + async def process_image_gemini(ctx: RunContext[AgentDeps], customer_id: str = "") -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不自动作图" + except Exception: + return "现在处理模块暂时暂停,先不自动作图" + """ + 触发 Gemini 作图处理。客户付款后或说「安排一下」「处理一下」时调用。 + 会从客户档案读取上次发图的 URL 和处理参数(提示词、比例、透视),启动 Gemini 流程。 + 处理完成后会自动发图给客户。 + """ + cid = customer_id or ctx.deps.from_id + try: + from core.workflow import workflow + ok = await workflow.trigger_processing_on_payment( + customer_id=cid, + acc_id=ctx.deps.acc_id, + acc_type=ctx.deps.platform, + ) + if ok: + return "已安排,稍后发你" + return "该客户暂无待处理图片,请先发图" + except Exception as e: + return f"触发作图失败: {e},请稍后重试或转人工" + + @self.agent_pricing.tool + async def analyze_image_pricing(ctx: RunContext[AgentDeps], image_url: str) -> str: + try: + from image.image_analyzer import image_analyzer + result = await image_analyzer.analyze(image_url) + p = result.get("price_suggest", 20) + try: + st = self._get_conversation_state(ctx.deps.from_id) + if isinstance(result.get("price_min"), (int, float)): + st.last_min_price = int(result.get("price_min") or 0) + try: + from db.customer_db import db as _db + _db.update_last_min_price(ctx.deps.from_id, st.last_min_price) + except Exception: + pass + except Exception: + pass + return f"建议报价:{p}元" + except Exception as e: + return f"图片分析失败: {e}" + + @self.agent_pricing.tool + async def record_quote_pricing( + ctx: RunContext[AgentDeps], + customer_id: str, + price: int, + description: str = "" + ) -> str: + try: + from db.customer_db import db + db.update_last_price(customer_id, price) + return "ok" + except Exception as e: + return f"记录失败: {e}" + + @self.agent_processing.tool + async def process_image_gemini_run(ctx: RunContext[AgentDeps], customer_id: str = "") -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停" + except Exception: + return "现在处理模块暂时暂停" + cid = customer_id or ctx.deps.from_id + try: + from core.workflow import workflow + ok = await workflow.trigger_processing_on_payment( + customer_id=cid, + acc_id=ctx.deps.acc_id, + acc_type=ctx.deps.platform, + ) + if ok: + return "已安排" + return "暂无待处理图片" + except Exception as e: + return f"触发作图失败: {e}" + + @self.agent_similar.tool + async def recommend_similar(ctx: RunContext[AgentDeps], hint: str = "") -> str: + try: + return "有类似款,拍下我发你参考图。" + except Exception as e: + return f"推荐失败: {e}" + + @self.agent_order.tool + async def handle_order(ctx: RunContext[AgentDeps], raw_msg: str = "") -> str: + try: + info = self._parse_order_info(raw_msg or "") + paid_kw = ["等待发货", "已付款", "付款成功", "买家已付款"] + if any(k in (info.get("pay_status", "") or "") for k in paid_kw) or any(k in (info.get("order_status", "") or "") for k in paid_kw): + return "已安排,稍后发你" + return "" + except Exception: + return "" + + @self.agent_risk.tool + async def risk_filter(ctx: RunContext[AgentDeps], text: str = "") -> str: + return "这类不接哦,抱歉哈。" + + @self.agent.tool + async def remove_background(ctx: RunContext[AgentDeps], image_url: str) -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不处理图片" + except Exception: + return "现在处理模块暂时暂停,先不处理图片" + """【独立工具】去背景,输出白底图。客户只要去背景时调用。""" + try: + from image.image_tools import remove_background as _rb + r = await _rb(image_url) + if r["success"]: + return f"去背景完成,已保存。自然回复客户好了发你" + return f"去背景失败:{r['message']}" + except Exception as e: + return f"去背景失败:{e}" + + @self.agent.tool + async def perspective_correct(ctx: RunContext[AgentDeps], image_url: str) -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不处理图片" + except Exception: + return "现在处理模块暂时暂停,先不处理图片" + """【独立工具】透视矫正。输入需白底图,输出展平图。""" + try: + from image.image_tools import perspective_correct as _pc + r = await _pc(image_url) + if r["success"]: + return f"透视矫正完成。自然回复客户好了" + return f"透视矫正失败:{r['message']}" + except Exception as e: + return f"透视矫正失败:{e}" + + @self.agent.tool + async def extract_pattern_tool( + ctx: RunContext[AgentDeps], + image_url: str, + prompt: str = "", + aspect_ratio: str = "1:1" + ) -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不处理图片" + except Exception: + return "现在处理模块暂时暂停,先不处理图片" + """【独立工具】印花提取/主处理。按提示词和比例处理。""" + try: + from image.image_tools import extract_pattern + r = await extract_pattern(image_url, prompt=prompt, aspect_ratio=aspect_ratio) + if r["success"]: + return f"提取完成。自然回复客户好了发你" + return f"提取失败:{r['message']}" + except Exception as e: + return f"提取失败:{e}" + + @self.agent.tool + async def enhance_image_tool(ctx: RunContext[AgentDeps], image_url: str) -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不处理图片" + except Exception: + return "现在处理模块暂时暂停,先不处理图片" + """【独立工具】高清增强。客户只要清晰化时调用。""" + try: + from image.image_tools import enhance_image + r = await enhance_image(image_url) + if r["success"]: + return f"高清增强完成。自然回复客户好了" + return f"增强失败:{r['message']}" + except Exception as e: + return f"增强失败:{e}" + + @self.agent.tool + async def color_match_tool( + ctx: RunContext[AgentDeps], + orig_url: str, + result_url: str, + strength: float = 0.75 + ) -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不处理图片" + except Exception: + return "现在处理模块暂时暂停,先不处理图片" + """【独立工具】颜色匹配。将 result 色调匹配到 orig。""" + try: + from image.image_tools import color_match_images + r = await color_match_images(orig_url, result_url, strength=strength) + if r["success"]: + return f"颜色匹配完成" + return f"颜色匹配失败:{r['message']}" + except Exception as e: + return f"颜色匹配失败:{e}" + + @self.agent.tool + async def trim_border_tool(ctx: RunContext[AgentDeps], image_url: str) -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不处理图片" + except Exception: + return "现在处理模块暂时暂停,先不处理图片" + """【独立工具】裁切四周背景边(白/黄/米等)。""" + try: + from image.image_tools import trim_border + r = await trim_border(image_url) + if r["success"]: + return f"裁边完成" + return f"裁边失败:{r['message']}" + except Exception as e: + return f"裁边失败:{e}" + + @self.agent.tool + async def vectorize_to_eps_tool(ctx: RunContext[AgentDeps], image_url: str) -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不处理图片" + except Exception: + return "现在处理模块暂时暂停,先不处理图片" + """【独立工具】矢量化 - 将图片转为 EPS 矢量文件。客户要做矢量图、转 EPS、转 AI 格式时调用。""" + try: + from image.image_tools import vectorize_to_eps + r = await vectorize_to_eps(image_url) + if r["success"]: + return f"矢量化完成,已生成 EPS 文件。自然回复客户好了发你" + return f"矢量化失败:{r['message']}" + except Exception as e: + return f"矢量化失败:{e}" + + @self.agent.tool + async def meitu_enhance_tool( + ctx: RunContext[AgentDeps], + image_url: str, + mode: str = "standard" + ) -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不处理图片" + except Exception: + return "现在处理模块暂时暂停,先不处理图片" + """ + 【独立工具】美图画质增强。客户要画质增强、清晰化、美图处理时调用。 + + Args: + image_url: 图片 URL 或本地路径 + mode: 处理模式。crystal(极速重绘) standard(标准) enhance(增强) hdr(HDR) portrait(人像优化) + """ + try: + from image.image_tools import meitu_enhance + r = await meitu_enhance(image_url, mode=mode) + if r["success"]: + return f"画质增强完成。自然回复客户好了发你" + return f"画质增强失败:{r['message']}" + except Exception as e: + return f"画质增强失败:{e}" + + @self.agent.tool + async def resize_image( + ctx: RunContext[AgentDeps], + image_url: str, + width: int, + height: int = 0 + ) -> str: + try: + from config.config import IMAGE_MODULE_ENABLED + if not IMAGE_MODULE_ENABLED: + return "现在处理模块暂时暂停,先不处理图片" + except Exception: + return "现在处理模块暂时暂停,先不处理图片" + """ + 改图片尺寸。客户说「改成1920x1080」「弄成横图」「改下尺寸」时调用。 + + Args: + image_url: 图片URL(客户刚发的图,或从对话中获取) + width: 目标宽度(像素),如 1920 + height: 目标高度(0=按宽度等比缩放),如 1080 + + 常用尺寸:1920x1080(横屏) 1080x1920(竖屏) 2000x2000(方图) + """ + try: + from image.image_processor import image_processor + result = await image_processor.resize(image_url, width, height) + if result["success"]: + return f"改尺寸完成:{width}x{height},已保存。自然回复客户改好了" + else: + return f"改尺寸失败:{result['message']},告知客户稍后重试" + except Exception as e: + return f"改尺寸失败:{e}" + + @self.agent.tool + async def calculate_bulk_price( + ctx: RunContext[AgentDeps], + image_count: int, + complexities: str = "" + ) -> str: + """ + 计算多图打包价格。 + 客户要做多张图时调用,返回建议总价。 + + Args: + image_count: 图片数量 + complexities: 各图复杂度,逗号分隔,如 "normal,complex,simple" + 没有识别结果时留空,按平均价格估算 + """ + if image_count <= 0: + return "图片数量无效" + + # 各复杂度单价(必须为5的整数倍) + unit_price = {"simple": 15, "normal": 20, "complex": 25, "hard": 30} + default_unit = 20 # 没有识别结果时的默认单价 + + if complexities: + levels = [c.strip() for c in complexities.split(",")] + total = sum(unit_price.get(lv, default_unit) for lv in levels) + else: + total = image_count * default_unit + + # 打包优惠:3张以上9折,5张以上8折,价格必须为5的整数倍 + if image_count >= 5: + discounted = round(total * 0.8 / 5) * 5 + tip = f"({image_count}张8折优惠)" + elif image_count >= 3: + discounted = round(total * 0.9 / 5) * 5 + tip = f"({image_count}张9折优惠)" + else: + discounted = round(total / 5) * 5 + tip = "" + + return f"建议打包报价:{discounted}元{tip}(原价{total}元)" + + # 对话状态超过多少小时后重置(避免昨天的售后状态影响今天) + CONVERSATION_TIMEOUT_HOURS = 12 + + def _get_conversation_state(self, customer_id: str) -> ConversationState: + """获取或创建对话状态,超时自动重置""" + now = datetime.now() + + if customer_id in self.conversations: + state = self.conversations[customer_id] + # 超过 12 小时没有消息,重置阶段和压价次数 + if state.last_update: + try: + last = datetime.fromisoformat(state.last_update) + hours = (now - last).total_seconds() / 3600 + if hours > self.CONVERSATION_TIMEOUT_HOURS: + state.stage = "售前" + state.discount_count = 0 + # 同时清理对话历史,避免发送过期上下文 + self.message_histories.pop(customer_id, None) + except Exception: + pass + else: + self.conversations[customer_id] = ConversationState( + customer_id=customer_id, + last_update=now.isoformat() + ) + + # 定期清理长期不活跃客户(超过 7 天) + self._cleanup_inactive(now) + + return self.conversations[customer_id] + + def _cleanup_inactive(self, now: datetime): + """清理超过 7 天没有消息的对话状态,释放内存""" + # 每 100 次调用清理一次,避免每次都遍历 + if len(self.conversations) % 100 != 0: + return + expired = [ + cid for cid, state in self.conversations.items() + if state.last_update and + (now - datetime.fromisoformat(state.last_update)).days > 7 + ] + for cid in expired: + self.conversations.pop(cid, None) + self.message_histories.pop(cid, None) + + def _detect_stage(self, message: str) -> str: + """检测售前/售后""" + # 系统订单通知不属于售后,单独处理 + if "系统订单信息" in message: + return "订单通知" + + after_sale_keywords = ["已下单", "已付款", "催一下", "发文件", "要修改", "不满意", "退款", "退货"] + for keyword in after_sale_keywords: + if keyword in message: + return "售后" + return "售前" + + def _get_system_prompt(self) -> str: + base_prompt = """你是淘宝找原图店的客服,对外说帮客户找原图/高清图,实际是帮客户把模糊图处理成高清版,擅长快速成交。 + +核心原则:快、准、狠。**回复要像真人聊天,自然多变,禁止套模板、背台词。** + +【你拥有的工具,按需调用】 +- analyze_image(url):收到图片必须调用,分析复杂度获取报价依据 +- process_image_gemini(customer_id):客户付款或说「安排/处理」时调用,走完整流程 +- remove_background(image_url):只要去背景时单独调用 +- perspective_correct(image_url):只要透视矫正时调用(需白底图) +- extract_pattern_tool(image_url, prompt, aspect_ratio):只要印花提取时调用 +- enhance_image_tool(image_url):只要高清增强时调用 +- color_match_tool(orig_url, result_url, strength):颜色匹配 +- trim_border_tool(image_url):裁切四周背景边 +- resize_image(image_url, width, height):改尺寸,height=0则等比缩放 +- get_customer_info(customer_id):老客户来时调用,了解历史消费和性格 +- transfer_to_human():退款/投诉/情绪激动时调用 +- update_contact_info(customer_id, type, value):客户说出邮箱/手机/微信时调用,type填"email"/"phone"/"wechat" +- record_quote(customer_id, price, description):每次报价后调用,记录报价保持一致 +- calculate_bulk_price(count, complexities):客户要做多张图时调用,获取打包价 +- save_customer_note(customer_id, note):记录其他重要信息 + +【报价规则】 +- 价格必须为5的整数倍(10/15/20/25/30),禁止报12、17、23等 +- 客户只是文字询价,没发图 → 自然引导发图,不报价 +- 收到图片 → 立刻调用 analyze_image() → 工具返回结果后【必须】立刻回复客户报价 +- 报价和推成交的话术要自然多变,跟着客户语气走,不要每次都一样 +- analyze_image 工具调用完成后,你的下一句话一定是报价,不能是内部说明 +- 报价后立刻推成交,不等客户反应 + +【压价规则】 +- 客户说「贵」「有点贵」「算了」「便宜点」→ 直接让价一次,禁止追问「什么问题」「说清楚点」 +- 只让价一次,话术自然变化 +- 第二次压价:表达最低了即可,换着说 + +【转接规则】 +- 退款/退货/投诉/情绪激动/test → 调用 transfer_to_human() +- 调用后只回复"转接",不加其他内容 + +【售后规则】 +- 催进度:自然回复在做了/快了/马上好之类 +- 要修改:自然问哪里要改 + +【禁忌】 +- 没看到图不报价 +- 不说"不行/不可以" +- 不解释技术细节 +- 不给价格区间 +- 回复不超过2句话 +- 绝对禁止输出任何内部独白或状态说明,包括但不限于:"无需回复""已完成""已经完成""不需要回复""流程结束""操作完成""任务完成""记录完成""报价已记录"等 +- 每次必须输出真实的、发给客户看的回复文字,哪怕只有一句话""" + + if self.skills_content: + base_prompt += f"\n\n=== 技能文档 ===\n{self.skills_content}" + + return base_prompt + + def _get_after_sale_prompt(self) -> str: + return """你是淘宝客服的售后助手,负责售后阶段的自然沟通与处理进度反馈。 +核心:简洁、自然、不解释技术细节、尽量不调用报价相关工具。 +规则: +- 已付款客户优先:确认安排、说明进度、承诺时间点 +- 修改需求:礼貌询问具体改哪里,尽量一句话 +- 催进度:自然回复在做了/快了/马上好,给预计时间 +- 投诉/情绪激动/退款:转人工 +- 输出不超过2句话,不说内部状态""" + + def _get_pricing_prompt(self) -> str: + try: + from config.config import MIN_PRICE_FLOOR + floor = MIN_PRICE_FLOOR + except Exception: + floor = 15 + return f"""你是淘宝客服的报价助手,负责在客户明确提到价格/询价时快速给出自然报价并推动成交。 +规则: +- 收到图片或历史有图片依据时尽量结合复杂度给出单价,价格为5的整数倍 +- 没有图片时引导发图,不给价格区间 +- 报价后紧跟一句推动成交,话术自然不重复 +- 最低价不低于{floor}元,客户出价低于底线时礼貌拒绝(不好意思) +- 输出不超过2句话""" + + def _get_processing_prompt(self) -> str: + return """你是淘宝客服的处理助手,负责在客户说安排/处理/开始做或已付款的场景下进行处理安排与进度反馈。 +规则: +- 已付款或明确要求开始时,确认安排并给预计时间点 +- 可调用处理流程工具 +- 投诉/退款时转人工 +- 输出不超过2句话""" + + def _get_similar_prompt(self) -> str: + return """你是淘宝客服的相似图助手,客户问“有一样的吗/类似的吗/同款吗”时,给出自然回复与参考建议。 +规则: +- 先确认可以找类似款,建议拍后我发参考图 +- 如已知图案/类型,简要说明“同类型都有”,推动成交 +- 输出不超过2句话""" + + def _get_order_prompt(self) -> str: + return """你是淘宝客服的订单助手,负责系统订单通知的处理。 +规则: +- 已付款时自然确认安排;其他状态静默(输出空字符串) +- 输出不超过1句话""" + + def _get_risk_prompt(self) -> str: + return """你是淘宝客服的风控助手,负责敏感/违规内容的前置拦截与替代话术。 +规则: +- 黄色/擦边/涉政等不接单,礼貌拒绝 +- 输出不超过1句话""" + + def _get_customer_profile_context(self, customer_id: str) -> str: + """从数据库读取客户画像,注入给 AI。含个性化语气、报价策略、主动预测。""" + try: + from db.customer_db import db + profile = db.get_customer(customer_id) + + if profile.blacklist: + return f"【⚠️黑名单客户】原因:{profile.blacklist_reason or '已标记'},请转接人工处理,不要自动回复" + + parts = [] + if profile.email: + parts.append(f"邮箱:{profile.email}") + if profile.phone: + parts.append(f"电话:{profile.phone}") + if profile.wechat: + parts.append(f"微信:{profile.wechat}") + if profile.personality: + parts.append(f"性格:{'/'.join(profile.personality)}") + if profile.total_orders > 0: + parts.append(f"历史下单:{profile.total_orders}次") + if profile.vip_custom_price: + parts.append(f"VIP专属价:{profile.vip_custom_price}元(直接用这个价报)") + elif profile.last_price: + parts.append(f"上次报价:{profile.last_price}元") + if profile.lowest_price_accepted: + parts.append(f"最低接受价:{profile.lowest_price_accepted}元") + if profile.discount_given_count: + parts.append(f"历史让价:{profile.discount_given_count}次") + if getattr(profile, "last_quote_no_convert", False): + parts.append("【报价策略】上次报价未成交,本次可适当降低5-10元促成交") + if profile.price_sensitivity: + parts.append(f"价格敏感:{profile.price_sensitivity}") + if profile.decision_speed: + parts.append(f"决策速度:{profile.decision_speed}") + if profile.last_image_url: + parts.append(f"上次发图:{profile.last_image_url}") + if profile.processing_status: + parts.append(f"当前任务:{profile.processing_status}") + if profile.preferred_format: + parts.append(f"格式偏好:{profile.preferred_format}") + if profile.bulk_potential == "有": + parts.append("批量潜力:有(可主动推打包价)") + if profile.upsell_opportunity: + parts.append(f"加购机会:{'/'.join(profile.upsell_opportunity)}") + if profile.last_conversation_summary: + time_str = "" + if profile.last_conversation_time: + try: + t = datetime.fromisoformat(profile.last_conversation_time) + diff = datetime.now() - t + if diff.days > 0: + time_str = f"({diff.days}天前)" + else: + h = diff.seconds // 3600 + time_str = f"({h}小时前)" if h > 0 else "(刚刚)" + except Exception: + pass + parts.append(f"上次对话{time_str}:{profile.last_conversation_summary}") + + # 个性化:语气与报价策略 + hints = [] + if profile.personality: + if "爽快" in profile.personality: + hints.append("回复简洁直接,少废话") + if "砍价" in profile.personality or "砍价狂" in profile.personality: + hints.append("报价时强调性价比,只让价一次") + if "纠结" in profile.personality or "墨迹" in profile.personality: + hints.append("耐心一点,可多给一点说明") + if profile.price_sensitivity == "高": + hints.append("报价时顺带提「满意再拍」降低顾虑") + if profile.decision_speed == "快": + hints.append("重点推快速成交,少铺垫") + if hints: + parts.append(f"【回复风格】{'; '.join(hints)}") + + # 主动预测:批量/加急 + proactive = [] + if profile.bulk_potential == "有" or (profile.total_images_sent or 0) >= 2: + proactive.append("可主动问「要做多张吗,多张有优惠」") + if profile.total_orders > 0 and profile.decision_speed == "快": + proactive.append("老客爽快,直接推成交") + if proactive: + parts.append(f"【主动推荐】{'; '.join(proactive)}") + + if parts: + return "【该客户历史信息】" + " | ".join(parts) + except Exception: + pass + return "" + + def _get_refusal_context_hint(self, customer_id: str, current_msg: str, profile_context: str) -> str: + """ + 检测「刚拒绝某张图 + 客户问能找到吗」场景,注入显式提示,避免前后矛盾。 + 原因:last_conversation_summary 异步更新可能滞后,message_histories 模型可能忽略。 + """ + ask_keywords = ["能找到吗", "可以吗", "有吗", "能做吗", "可以找吗", "可以弄吗"] + if not any(kw in current_msg for kw in ask_keywords): + return "" + refusal_keywords = ["不做", "不接", "拒绝", "不做这类", "这类不做"] + # 检查 profile 摘要(可能因异步更新而滞后) + if any(kw in profile_context for kw in refusal_keywords): + return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。" + # 检查内存历史中最近几条消息(ModelResponse 含客服回复) + history = self.message_histories.get(customer_id, []) + for msg in reversed(history[-6:]): + msg_str = str(msg) + if any(kw in msg_str for kw in refusal_keywords): + return "【重要】上一句客服刚拒绝了某张图,客户问能找到吗时须明确:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,会前后矛盾。" + return "" + + def _get_conversation_context(self, customer_id: str, acc_id: str = "", limit: int = 12, max_len: int = 80) -> str: + """ + 每一次对话都从数据库加载近期对话,压缩后注入 prompt。 + 确保模型看到上下文,同时控制 token 消耗。 + """ + try: + try: + from config.config import CHAT_CONTEXT_LIMIT, CHAT_CONTEXT_TRUNCATE_LEN + limit = CHAT_CONTEXT_LIMIT + max_len = CHAT_CONTEXT_TRUNCATE_LEN + except Exception: + pass + from db.chat_log_db import get_recent_conversation + msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=limit) + if not msgs: + return "" + lines = [] + for m in msgs: + role = "客" if m.get("direction") == "in" else "服" + msg_text = (m.get("message") or "").strip().replace("\n", " ")[:max_len] + if not msg_text: + continue + lines.append(f"{role}:{msg_text}") + if not lines: + return "" + return "【近期】\n" + "\n".join(lines) + "\n\n" + except Exception: + return "" + + def _get_intent_emotion_hint(self, msg: str) -> str: + """语义匹配:意图/情绪识别,注入提示。EMBEDDING_MODEL 未配置时用关键词。""" + try: + from utils.intent_analyzer import detect_intent_embedding, detect_intent_keywords, detect_emotion_embedding + intent = detect_intent_embedding(msg) + if not intent: + intent = detect_intent_keywords(msg) + emotion = detect_emotion_embedding(msg) if os.getenv("EMBEDDING_MODEL") else None + parts = [] + if intent: + parts.append(f"意图:{intent}") + if emotion: + parts.append(f"情绪:{emotion}") + if parts: + return f"【当前消息】{', '.join(parts)}" + except Exception: + pass + return "" + + # 简单打招呼类消息(在近期已回复后无需再回) + _COOLDOWN_PATTERNS = [ + "你好", "您好", "在吗", "在么", "在不在", "有人吗", + "嗯", "嗯嗯", "好", "好的", "好哒", "ok", "OK", "okay", + "谢谢", "谢谢你", "感谢", "收到", "知道了", "明白了", + ] + _COOLDOWN_SECONDS = 5 * 60 # 5 分钟内不重复回复纯打招呼 + + def _in_cooldown(self, state: ConversationState, msg: str) -> bool: + """最近刚回复过 + 消息是纯打招呼 → True 静默""" + if not state.last_reply_at: + return False + elapsed = (datetime.now() - state.last_reply_at).total_seconds() + if elapsed > self._COOLDOWN_SECONDS: + return False + clean = msg.strip().rstrip("!!??。.~~") + return clean in self._COOLDOWN_PATTERNS + + async def process_message(self, message: CustomerMessage) -> AgentResponse: + """处理客户消息并生成回复""" + # 获取或创建对话状态 + state = self._get_conversation_state(message.from_id) + + # 冷却期检测:近期已回复 + 纯打招呼 → 静默 + if self._in_cooldown(state, message.msg): + elapsed = int((datetime.now() - state.last_reply_at).total_seconds()) + print(f"[Agent] 冷却期静默(距上次回复 {elapsed}s):{message.msg!r}") + return AgentResponse(reply="", should_reply=False, need_transfer=False) + + # 检测售前/售后 + new_stage = self._detect_stage(message.msg) + if new_stage != state.stage: + state.stage = new_stage + + state.last_update = datetime.now().isoformat() + + # 订单通知前置处理 + if "系统订单信息" in message.msg or "订单状态" in message.msg: + _, order_block = self._split_customer_text(message.msg) + customer_text, _ = self._split_customer_text(message.msg) + order = self._parse_order_info(order_block or message.msg) + pay_status = order.get("pay_status", "") + order_status = order.get("order_status", "") + + paid_keywords = ["等待发货", "已付款", "付款成功", "买家已付款"] + is_paid = any(kw in pay_status or kw in order_status for kw in paid_keywords) + + if is_paid: + # 订单金额核查:对比报价和实付金额 + asyncio.create_task(self._check_order_amount( + message.from_id, order, message.acc_id + )) + # 成交记录:写入数据库供日报分析 + asyncio.create_task(self._record_deal_success( + message.from_id, message.from_name, message.acc_id, message.acc_type, + order, state + )) + # 已付款:触发 Gemini 作图 + try: + from core.workflow import workflow + asyncio.create_task(workflow.trigger_processing_on_payment( + customer_id=message.from_id, + acc_id=message.acc_id, + acc_type=message.acc_type, + )) + except Exception as e: + print(f"[Agent] 触发作图失败: {e}") + elif not customer_text: + # 非付款 + 没有客户文字 → 直接静默,不调用 AI + print(f"[Agent] 订单通知静默({pay_status or order_status}),跳过回复") + return AgentResponse(reply="", should_reply=False, need_transfer=False) + + # 构建提示词(包含对话状态 + 客户画像) + user_prompt = self._build_prompt(message, state) + + # 注入客户历史画像(个性化语气、报价策略、主动预测) + profile_context = self._get_customer_profile_context(message.from_id) + if profile_context: + user_prompt = profile_context + "\n\n" + user_prompt + + # 前后一致提示:刚拒绝某张图后,客户问「能找到吗」等时,必须区分能做/不能做 + refusal_hint = self._get_refusal_context_hint(message.from_id, message.msg, profile_context or "") + if refusal_hint: + user_prompt = refusal_hint + "\n\n" + user_prompt + + # 每一次对话都注入近期上下文,确保模型看到完整对话 + conv_context = self._get_conversation_context(message.from_id, acc_id=message.acc_id or "") + if conv_context: + user_prompt = conv_context + user_prompt + + # 语义匹配:意图/情绪识别(配置 EMBEDDING_MODEL 时用 embedding,否则关键词) + intent_hint = self._get_intent_emotion_hint(message.msg) + if intent_hint: + user_prompt = intent_hint + "\n\n" + user_prompt + + deps = AgentDeps( + msg_id=message.msg_id, + acc_id=message.acc_id, + from_id=message.from_id, + platform=message.acc_type + ) + + # 取出该客户的历史对话,传给 AI 保持上下文 + history = self.message_histories.get(message.from_id, []) + + print(f"[Agent] ── 发送给AI的提示词 ──\n{user_prompt}\n────────────────────") + + try: + msg_lower = message.msg.lower() + pricing_kw = ["多少钱", "多少一张", "报价", "给个价", "几块", "价位", "能便宜点吗"] + processing_kw = ["安排", "处理一下", "开始做", "做一下", "尽快", "加急", "付款了", "已付款"] + similar_kw = ["有一样的", "有一样吗", "一样的吗", "类似的", "类似的吗", "同款", "相似", "类似吗"] + order_markers = ["[系统订单信息]", "订单状态", "买家已付款"] + risk_kw = ["黄色", "擦边", "色情", "涉黄", "涉政", "政治", "裸", "不雅"] + target_agent = self.agent_after_sale if state.stage == "售后" else self.agent + if any(k in msg_lower for k in risk_kw): + target_agent = self.agent_risk + elif any(k in message.msg for k in order_markers): + target_agent = self.agent_order + if any(k in msg_lower for k in processing_kw): + target_agent = self.agent_processing + elif any(k in msg_lower for k in pricing_kw): + target_agent = self.agent_pricing + elif any(k in msg_lower for k in similar_kw): + target_agent = self.agent_similar + result = await target_agent.run(user_prompt, deps=deps, message_history=history) + # 更新历史,最多保留最近 30 条消息防止 token 超限 + self.message_histories[message.from_id] = result.all_messages()[-30:] + reply_text = result.output + # 拦截超低杀价:客户报价低于底线时,统一礼貌拒绝 + try: + from config.config import MIN_PRICE_FLOOR + import re + offer = None + m = re.search(r'(\d{1,4})\s*(?:元|块|块钱|元钱)\b', message.msg) + if m: + offer = int(m.group(1)) + else: + m2 = re.search(r'(?:能|可以|可否|能否)\s*(\d{1,4})\b', message.msg) + offer = int(m2.group(1)) if m2 else None + st = self._get_conversation_state(message.from_id) + floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR + if offer is not None and offer < floor: + reply_text = "不好意思" + except Exception: + pass + # 降限:若AI在回复中给出小于底线的报价,提升到>=底线且为5的倍数 + try: + from config.config import MIN_PRICE_FLOOR + st = self._get_conversation_state(message.from_id) + floor = st.last_min_price if isinstance(st.last_min_price, int) and st.last_min_price > 0 else MIN_PRICE_FLOOR + def _adjust(text: str) -> str: + import re + def _repl(m): + num = int(m.group(1)) + adj = max(floor, round(num / 5) * 5) + return m.group(0).replace(str(num), str(adj)) + patterns = [ + r'按(\d{1,4})元', + r'报价[::]\s*(\d{1,4})\s*元', + r'(\d{1,4})\s*元一张', + r'打包(\d{1,4})\s*元', + ] + t = text + for p in patterns: + t = re.sub(p, _repl, t) + return t + reply_text = _adjust(reply_text or "") + except Exception: + pass + + # 打印工具调用记录 + for msg in result.new_messages(): + for part in getattr(msg, 'parts', []): + part_type = type(part).__name__ + if 'ToolCall' in part_type: + print(f"[Agent] 工具调用: {getattr(part, 'tool_name', '')}({getattr(part, 'args', '')})") + elif 'ToolReturn' in part_type: + ret = str(getattr(part, 'content', ''))[:120] + print(f"[Agent] 工具返回: {ret}") + + print(f"[Agent] AI原始输出: {repr(reply_text)}") + + except Exception as e: + err_str = str(e) + print(f"[Agent] AI 调用失败: {e},使用兜底回复") + if "AccountOverdueError" in err_str or "overdue" in err_str.lower(): + asyncio.create_task(_notify_wechat_overdue()) + else: + asyncio.create_task(_notify_wechat( + f"⚠️ **AI调用异常**\n" + f"客户:{message.from_id}\n" + f"店铺:{message.acc_id}\n" + f"错误:{err_str[:200]}", + tag="AI异常" + )) + reply_text = None + + # AI 失败兜底:给一个不出错的万能回复 + if not reply_text: + return AgentResponse( + reply="好的稍等,我看一下", + should_reply=True, + need_transfer=False + ) + + # 敏感词过滤:党政/暴力/血腥/黄色 + try: + from utils.content_filter import should_block_reply + blocked, fallback = should_block_reply(reply_text) + if blocked: + print(f"[Agent] 敏感词拦截,使用兜底回复") + reply_text = fallback or "好的,您稍等,我帮您确认一下" + except Exception: + pass + + # 成本统计(可选) + try: + from utils.api_cost_tracker import record + record("openai_chat", count=1) + except Exception: + pass + + # 检测是否报价 + self._detect_price(reply_text, state) + + # 检测压价 + self._detect_discount(message.msg, state) + + # 自动打标签(异步,不阻塞) + asyncio.create_task(self._auto_tag(message, reply_text, state)) + + # 检测是否需要转接(文字触发 或 AI 调用了 transfer_to_human 工具) + need_transfer = False + transfer_msg = "" + transfer_keywords = ["TRANSFER_REQUESTED", "[转移会话]", "转移会话", "转人工", "转接"] + if reply_text and any(kw in reply_text for kw in transfer_keywords): + need_transfer = True + transfer_msg = TRANSFER_MESSAGE + + # 未成交记录:客户表达放弃且已报价过(转人工不记录) + customer_text, _ = self._split_customer_text(message.msg) + no_convert_keywords = ["算了", "不要了", "不做了", "下次再说", "先不弄了"] + if customer_text and state.last_price and state.last_price > 0: + if any(kw in customer_text for kw in no_convert_keywords): + reason = "嫌贵放弃" if any(k in customer_text for k in ["贵", "贵了", "便宜"]) else "放弃" + asyncio.create_task(self._record_deal_fail( + message.from_id, message.from_name, message.acc_id, message.acc_type, reason + )) + + # 需要转接时不把原始回复发给客户 + should_reply = bool(reply_text and reply_text.strip()) and not need_transfer + + # 记录本次回复时间,供冷却期判断 + if should_reply: + state.last_reply_at = datetime.now() + + return AgentResponse(reply=reply_text, should_reply=should_reply, need_transfer=need_transfer, transfer_msg=transfer_msg) + + def _detect_price(self, reply: str, state: ConversationState): + """从回复中提取价格,同步写入客户数据库(价格必须为5的整数倍)""" + import re + numbers = re.findall(r'(\d+)[元]', reply) + if numbers: + price = round(int(numbers[0]) / 5) * 5 # 强制为5的整数倍 + state.last_price = price + # 持久化到客户数据库,重启后仍可读取 + try: + from db.customer_db import db + db.update_last_price(state.customer_id, price) + except Exception: + pass + + async def _check_order_amount(self, customer_id: str, order: dict, acc_id: str): + """核查订单实付金额是否与报价一致,异常时企业微信预警""" + try: + import re + from db.customer_db import db + profile = db.get_customer(customer_id) + quoted = profile.last_price # 上次报价(元) + if not quoted: + return + + # 从订单解析实付金额 + raw_amount = order.get("amount", "") + m = re.search(r'[\d.]+', str(raw_amount)) + if not m: + return + paid = float(m.group()) + + print(f"[Agent] 订单金额核查:报价 {quoted}元 vs 实付 {paid}元(客户 {customer_id})") + + # 实付金额明显低于报价(低于报价的 60%)才预警 + if paid < quoted * 0.6: + msg = ( + f"⚠️ **订单金额异常**\n" + f"店铺:{acc_id}\n" + f"客户:{customer_id}({profile.name or ''})\n" + f"报价:{quoted}元\n" + f"实付:{paid}元\n" + f"差额:{quoted - paid:.1f}元 — 请人工核查" + ) + print(f"[Agent] {msg}") + await _notify_wechat(msg) + except Exception as e: + print(f"[Agent] 订单金额核查失败: {e}") + + async def _record_deal_success( + self, + customer_id: str, + customer_name: str, + acc_id: str, + platform: str, + order: dict, + state: "ConversationState", + ): + """成交时写入数据库,供日报与数据分析""" + try: + import re + from db.deal_outcome_db import record_deal + order_id = order.get("order_id", "") + raw_amount = order.get("amount", "") + m = re.search(r"[\d.]+", str(raw_amount)) + amount = float(m.group()) if m else 0 + reason = "让价后成交" if (state.discount_count or 0) > 0 else "直接成交" + record_deal( + customer_id=customer_id, + outcome="成交", + reason=reason, + customer_name=customer_name or "", + acc_id=acc_id or "", + platform=platform or "", + order_id=order_id, + amount=amount, + discount_given=(state.discount_count or 0) > 0, + ) + # 同步到客户库 + try: + from db.customer_db import db + if order_id: + db.add_order(customer_id, order_id, amount) + db.clear_quote_no_convert(customer_id) + except Exception: + pass + print(f"[Agent] 成交记录: {customer_id} {reason} {amount}元") + except Exception as e: + print(f"[Agent] 成交记录失败: {e}") + + async def _record_deal_fail( + self, + customer_id: str, + customer_name: str, + acc_id: str, + platform: str, + reason: str, + ): + """未成交时写入数据库,供日报与数据分析;标记报价未成交,下次可适当降低""" + try: + from db.deal_outcome_db import record_deal + from db.customer_db import db + record_deal( + customer_id=customer_id, + outcome="未成交", + reason=reason, + customer_name=customer_name or "", + acc_id=acc_id or "", + platform=platform or "", + ) + db.mark_quote_no_convert(customer_id) + print(f"[Agent] 未成交记录: {customer_id} {reason}") + except Exception as e: + print(f"[Agent] 未成交记录失败: {e}") + + async def _auto_tag(self, message: CustomerMessage, reply: str, state: ConversationState): + """自动识别并写入各类标签""" + try: + from db.customer_db import db + cid = message.from_id + msg = message.msg.lower() + + # 批量潜力 + if any(kw in msg for kw in ["还有", "多张", "好几张", "一批", "下次还"]): + db.set_bulk_potential(cid, "有") + db.add_upsell_opportunity(cid, "批量打包") + + # 加购机会:问过PSD/分层 + if any(kw in msg for kw in ["psd", "分层", "源文件"]): + db.add_upsell_opportunity(cid, "分层PSD") + db.update_preferred_format(cid, "psd") + + # 格式偏好 + if "jpg" in msg or "jpeg" in msg: + db.update_preferred_format(cid, "jpg") + if "png" in msg: + db.update_preferred_format(cid, "png") + + # 尺寸/分辨率偏好 + if any(kw in msg for kw in ["分辨率", "dpi", "尺寸", "大图", "印刷"]): + db.update_preferred_size(cid, message.msg[:30]) + + # 决策速度:收到报价后立刻说拍了 → 快 + if any(kw in msg for kw in ["拍了", "下单了", "好的", "行"]) and state.last_price: + db.update_decision_speed(cid, "快") + + # 图片类型识别(简单关键词匹配) + type_keywords = { + "印花": ["印花", "花纹", "图案", "面料", "布料", "纺织"], + "logo": ["logo", "标志", "品牌", "商标"], + "人物": ["人物", "人像", "照片", "脸", "头像"], + "产品": ["产品", "商品", "包装", "实物"], + "老照片": ["老照片", "旧照片", "发黄", "修复"], + } + for img_type, keywords in type_keywords.items(): + if any(kw in message.msg for kw in keywords): + db.add_image_type(cid, img_type) + break + + # 定期自动计算衍生标签 + db.auto_compute_tags(cid) + + except Exception: + pass + + def _detect_discount(self, message: str, state: ConversationState): + """检测压价,并持久化让价记录""" + if any(kw in message for kw in ["贵", "便宜", "太贵", "有点贵"]): + state.discount_count += 1 + if state.last_price: + try: + from db.customer_db import db + db.record_discount(state.customer_id, state.last_price) + except Exception: + pass + # 客户明确给价(如“10元/10块/能10吗”) + import re + m = re.search(r'(\d+)\s*元|\b(\d+)\s*块', message) + offer = None + if m: + offer = int(m.group(1) or m.group(2)) + if offer: + try: + from config.config import MIN_PRICE_FLOOR + if offer < MIN_PRICE_FLOOR: + # 标记本次为超低出价(用于后续拒绝话术提示) + state.last_price = state.last_price or 0 + except Exception: + pass + + def _parse_order_info(self, msg: str) -> dict: + """从系统订单消息中提取所有字段""" + import re + info = {} + + m = re.search(r'订单号[::]\s*(\d+)', msg) + if m: + info['order_id'] = m.group(1) + + # 订单大状态(新订单/交易成功/交易关闭等) + m = re.search(r'订单状态[::]\s*([^\s\[]+)', msg) + if m: + info['order_status'] = m.group(1).strip() + + # 支付细状态(等待买家付款/等待发货/交易完成等) + m = re.search(r'\[状态[::]\s*([^\]]+)\]', msg) + if m: + info['pay_status'] = m.group(1).strip() + + # 金额 + m = re.search(r'金额[::]\s*([\d.]+)元', msg) + if m: + info['amount'] = m.group(1) + + # 数量 + m = re.search(r'数量[::]\s*(\d+)', msg) + if m: + info['quantity'] = m.group(1) + + # 时间(格式:2026-2-24 19:52:52) + m = re.search(r'(\d{4}-\d{1,2}-\d{1,2}\s+\d{1,2}:\d{2}:\d{2})', msg) + if m: + info['order_time'] = m.group(1).strip() + + # 买家备注 + m = re.search(r'买家备注[::]\s*([^\n]+)', msg) + if m and m.group(1).strip(): + info['buyer_note'] = m.group(1).strip() + + return info + + def _get_order_instruction(self, pay_status: str, order_status: str) -> str: + """ + 根据订单状态生成 AI 指令。 + 只有「已付款」才需要回复客户,其他状态一律静默。 + """ + paid_keywords = ["等待发货", "已付款", "付款成功", "买家已付款"] + if any(kw in pay_status or kw in order_status for kw in paid_keywords): + return "【已付款-必须回复】客户已付款,立刻自然回复确认收款并告知马上安排。" + else: + # 所有其他状态(待付款、交易完成、关闭等)静默处理 + return "【仅系统通知-无需回复客户】这是系统订单通知,不需要回复客户任何内容,直接跳过。" + + def _extract_image_url(self, msg: str) -> str: + """从消息中提取图片URL,兼容纯URL和 text#*#url 两种格式""" + import re + if not msg: + return "" + # 处理 "有吗#*#https://..." 格式 + if "#*#" in msg: + parts = msg.split("#*#", 1) + candidate = parts[1].strip() + if candidate.startswith(("http://", "https://")): + return candidate + # 纯URL或URL在任意位置 + m = re.search(r'https?://\S+', msg) + if m: + url = m.group() + image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") + image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com", "suning.com") + if any(ext in url.lower() for ext in image_exts) or any(h in url.lower() for h in image_hosts): + return url + return "" + + def _split_customer_text(self, msg: str) -> tuple: + """ + 把混合消息拆分为(客户真实文字, 系统订单块)。 + 平台有时把客户文字和系统订单通知拼在同一条消息里。 + """ + import re + # 找到系统订单块的起始位置 + order_marker = re.search(r'\[系统订单信息\]|\[系统通知\]', msg) + if order_marker: + customer_text = msg[:order_marker.start()].strip() + order_block = msg[order_marker.start():].strip() + else: + customer_text = msg.strip() + order_block = "" + return customer_text, order_block + + def _build_prompt(self, message: CustomerMessage, state: ConversationState) -> str: + """构建提示词""" + msg_content = message.msg + stage_info = f"【当前阶段】{state.stage}" + + # 拆分:客户文字 vs 系统订单块 + customer_text, order_block = self._split_customer_text(msg_content) + has_order = bool(order_block) + + if has_order: + order = self._parse_order_info(order_block) + if order.get('order_id'): + state.last_order_id = order['order_id'] + stage_info += f"\n【订单号】{order['order_id']}" + if order.get('order_status'): + state.order_status = order['order_status'] + stage_info += f"\n【订单状态】{order['order_status']}" + if order.get('pay_status'): + stage_info += f"\n【支付状态】{order['pay_status']}" + if order.get('amount'): + stage_info += f"\n【订单金额】{order['amount']}元" + if order.get('quantity'): + stage_info += f"\n【数量】{order['quantity']}件" + if order.get('order_time'): + stage_info += f"\n【下单时间】{order['order_time']}" + if order.get('buyer_note'): + stage_info += f"\n【买家备注】{order['buyer_note']}" + + if state.discount_count > 0: + stage_info += f"\n【客户压价次数】{state.discount_count}" + + # 店铺类型:不同店铺不同回复策略 + shop_type = _get_shop_type(message.acc_id or "", message.goods_name or "") + shop_hint = "" + try: + from config.config import CONFIG_DIR + import json + cfg_path = CONFIG_DIR / "shop_prompts.json" + if cfg_path.exists(): + with open(cfg_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + hints = cfg.get("type_hints", {}) + shop_hint = hints.get(shop_type, "") + if not shop_hint and message.acc_id: + sh = cfg.get("shops", {}).get(message.acc_id, {}) + shop_hint = sh.get("hint", "") + except Exception: + pass + + prompt = f"""收到新消息: +{stage_info} + +发送者: {message.from_name} ({message.from_id}) +""" + if message.goods_name: + prompt += f"商品名称: {message.goods_name}\n" + if shop_hint: + prompt += f"\n{shop_hint}\n" + + # ── 优先处理客户真实问题 ── + # ── 判断订单付款状态(供后续逻辑使用)── + order_paid = False + order_unpaid = False + if has_order: + order = self._parse_order_info(order_block) + paid_kws = ["等待发货", "已付款", "付款成功", "买家已付款"] + unpaid_kws = ["等待买家付款", "待付款", "未付款"] + ps = order.get('pay_status', '') + os_ = order.get('order_status', '') + if any(kw in ps or kw in os_ for kw in paid_kws): + order_paid = True + elif any(kw in ps or kw in os_ for kw in unpaid_kws): + order_unpaid = True + + # ── 催单/进度询问关键词 ── + progress_keywords = [ + "安排了吗", "安排好了吗", "好了吗", "做了吗", "做好了吗", + "弄好了吗", "好了没", "做了没", "什么时候好", "多久好", + "进度", "催一下", "快点", "什么时候能好", "做完了吗", + ] + + if customer_text: + prompt += f"\n客户说:{customer_text}\n" + image_url = self._extract_image_url(customer_text) + price_keywords = ["多少钱", "多少", "价格", "几块", "怎么收费", "报个价"] + + # gemini_api 店铺:不触发找图流程,按 API 客服回复 + if shop_type == "gemini_api": + prompt += "\n【Gemini API 店铺】客户问账号/pro/续费/套餐等,按 API 客服自然回复,不要求发图。" + elif image_url: + prompt += f"\n客户发来图片(URL: {image_url})。必须:① 调用 analyze_image('{image_url}') ② 拿到结果后直接回复报价,话术自然多变。分析完必须回复,不能不回复。" + elif any(kw in customer_text for kw in price_keywords): + last_url = self._extract_image_url(msg_content) + if last_url: + prompt += f"\n客户在询问上面那张图的价格,图片URL是 {last_url}。调用 analyze_image('{last_url}') 后直接回复报价,不能不回复。" + else: + prompt += "\n客户在询问价格但未发图,回复「发图来我看看」。" + elif any(kw in customer_text for kw in progress_keywords): + # 客户问进度/催单,必须先核查付款状态 + if order_unpaid: + prompt += "\n⚠️【订单未付款】客户问安排进度,但订单还未付款。自然告知拍下付款后马上安排即可。" + elif order_paid: + prompt += "\n客户催单,订单已付款,自然回复在做了/快了之类。" + else: + prompt += "\n客户催单,查询当前处理状态后自然回复。" + elif any(kw in customer_text for kw in ["贵", "有点贵", "太贵", "算了", "便宜点", "少点", "打折", "贵哦"]): + # 客户嫌贵/要放弃 → 直接让价一次,不问「什么问题」 + prompt += "\n⚠️【客户嫌贵】客户已表达价格顾虑或要放弃。禁止追问「什么问题」「说清楚点」。直接让价一次(如原价20→15)。话术自然,像真人聊天,只让一次。" + elif any(kw in customer_text for kw in ["擦边", "黄色", "色情", "大尺度", "性感图", "露点", "半裸"]): + # 客户问擦边/黄色内容 → 直接拒绝,不说「发图来看看」 + prompt += "\n⚠️【拒绝】客户询问擦边/黄色/敏感内容。直接拒绝,不接单,不说「发图来看看」。自然回复如:这类不做/不接/做不了。" + else: + prompt += "\n根据客户说的内容自然回应,像真人聊天,不要套模板。" + + # ── 附加订单信息(不覆盖客户问题的优先级)── + if has_order: + order = self._parse_order_info(order_block) + order_instruction = self._get_order_instruction( + order.get('pay_status', ''), + order.get('order_status', '') + ) + if customer_text: + if not order_unpaid: + # 未付款情况已在上面明确处理,不重复添加背景 + prompt += f"\n\n【背景参考-订单通知】{order_instruction}" + else: + # 纯系统通知,没有客户文字 + prompt += f"\n\n{order_instruction}" + + if not customer_text and not has_order: + prompt += f"\n消息内容: {msg_content}\n请按工作流规则回复。" + + return prompt + + +async def test_agent(): + """测试 Agent""" + agent = CustomerServiceAgent(skills_dir="skills") + + test_msg = CustomerMessage( + msg_id="123", + acc_id="test_account", + msg="这张图可以做吗?", + from_id="customer001", + from_name="张三", + cy_id="customer001", + acc_type="AliWorkbench", + msg_type=0, + cy_name="张三", + goods_name="专业找图代找高清图片", + goods_order="" + ) + + response = await agent.process_message(test_msg) + print(f"回复内容: {response.reply}") + + +if __name__ == "__main__": + import asyncio + asyncio.run(test_agent()) diff --git a/core/websocket_client.py b/core/websocket_client.py new file mode 100644 index 0000000..5037490 --- /dev/null +++ b/core/websocket_client.py @@ -0,0 +1,1305 @@ +import asyncio +import websockets +import json +import re +import logging +from collections import deque +from datetime import datetime +from pathlib import Path +from typing import Optional + +# ========== 转接分组映射 ========== +def _get_transfer_group(acc_id: str) -> str: + """根据店铺 acc_id 获取转接分组 ID。不同店铺对应不同客服分组。""" + from config.config import CONFIG_DIR + config_path = CONFIG_DIR / "transfer_groups.json" + default_group = "20252916034" + try: + if config_path.exists(): + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + return cfg.get(acc_id, cfg.get("default", default_group)) + except Exception: + pass + return default_group + +# ========== 日志配置(轮转:按大小 10MB,保留 7 份)========== +def setup_logger(): + from logging.handlers import RotatingFileHandler + from config.config import LOG_DIR, LOG_MAX_BYTES, LOG_BACKUP_COUNT + logger = logging.getLogger("cs_agent") + logger.setLevel(logging.INFO) + fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S") + ch = logging.StreamHandler() + ch.setFormatter(fmt) + logger.addHandler(ch) + LOG_DIR.mkdir(exist_ok=True) + today = datetime.now().strftime("%Y-%m-%d") + fh = RotatingFileHandler( + LOG_DIR / f"chat_{today}.log", + maxBytes=LOG_MAX_BYTES, + backupCount=LOG_BACKUP_COUNT, + encoding="utf-8", + ) + fh.setFormatter(fmt) + logger.addHandler(fh) + return logger + +import os +logger = setup_logger() + +from db.chat_log_db import log_message as _chat_log + +# 导入 Agent 模块 +try: + from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage, _get_shop_type + from db.customer_db import db + from core.workflow import workflow + AGENT_AVAILABLE = True +except Exception as e: + AGENT_AVAILABLE = False + workflow = None + _get_shop_type = lambda acc_id, goods_name: "find_image" + import traceback + print(f"警告: Agent 模块导入失败: {e}") + traceback.print_exc() + print("将使用基础回复功能") + + +class QingjianAPIClient: + """轻简API WebSocket客户端""" + + def __init__(self, uri=None, enable_agent: bool = True): + from config.config import QINGJIAN_WS_URI + from config.config import IMAGE_MODULE_ENABLED + from config.config import MESSAGE_DEBOUNCE_SECONDS + self.uri = uri or QINGJIAN_WS_URI + self.websocket = None + self.running = True + self.reply_id = "tb001" # 回复时使用的from_id + self.last_msg = None # 保存最后一条消息 + self.enable_agent = enable_agent and AGENT_AVAILABLE + self.agent = None + self._replied_msg_ids: deque = deque(maxlen=200) # 已回复消息ID,FIFO去重 + + # 消息防抖:同一客户连续发消息时,等待 N 秒后合并处理 + self._DEBOUNCE_SECONDS = MESSAGE_DEBOUNCE_SECONDS if isinstance(MESSAGE_DEBOUNCE_SECONDS, int) else 8 + self._debounce_tasks: dict = {} # customer_key -> asyncio.Task + self._pending_msgs: dict = {} # customer_key -> list[data] + self._image_enabled = IMAGE_MODULE_ENABLED + + # 同客户消息串行:保证「发图→这个高清」等顺序,避免误判 + self._customer_locks: dict = {} # customer_key -> asyncio.Lock + # agent_reply 并发上限,防止 API 打满 + self._agent_semaphore = asyncio.Semaphore(8) + self._pending_images: dict = {} + self._pending_image_tasks: dict = {} + + # 初始化 Agent + if self.enable_agent: + try: + self.agent = CustomerServiceAgent() + print(f"[{self.get_time()}] Agent 初始化成功") + except Exception as e: + print(f"[{self.get_time()}] Agent 初始化失败: {e}") + self.enable_agent = False + + # 注册 workflow 消息发送回调(供图片AI完成后推送消息用) + if workflow: + workflow.register_send_callback(self._workflow_send) + workflow.register_agent_notify_callback(self._workflow_agent_notify) + + async def connect(self): + """连接WebSocket服务器""" + while self.running: + try: + print(f"[{self.get_time()}] 正在连接轻简API {self.uri}...") + async with websockets.connect(self.uri) as websocket: + self.websocket = websocket + from utils.health_check import set_qingjian_connected + set_qingjian_connected(True) + print(f"[{self.get_time()}] 连接成功!") + if self.enable_agent: + print(f"[{self.get_time()}] AI Agent 已启用,将自动处理消息") + print(f"[{self.get_time()}] 等待接收消息...") + + # 持续接收消息 + await self.receive_messages() + + except ConnectionRefusedError: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + print(f"[{self.get_time()}] 连接被拒绝,请检查轻简软件是否已启动") + except websockets.exceptions.InvalidURI: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + print(f"[{self.get_time()}] URI格式错误") + except Exception as e: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + print(f"[{self.get_time()}] 连接错误: {e}") + + # 等待5秒后重连 + if self.running: + print(f"[{self.get_time()}] 5秒后尝试重连...") + await asyncio.sleep(5) + + def _customer_key(self, data: dict) -> str: + """同一店铺+客户 = 同一会话""" + return f"{data.get('acc_id','')}:{data.get('from_id','')}" + + def _get_customer_lock(self, key: str) -> asyncio.Lock: + if key not in self._customer_locks: + self._customer_locks[key] = asyncio.Lock() + return self._customer_locks[key] + + async def _agent_reply_serialized(self, data: dict): + """同客户串行 + 全局并发限制,再执行 agent_reply""" + key = self._customer_key(data) + async with self._get_customer_lock(key): + async with self._agent_semaphore: + await self.agent_reply(data) + + def _fire_and_forget(self, coro): + """后台执行协程,不阻塞接收循环;异常会记录到日志""" + task = asyncio.create_task(coro) + + def _done(t): + if t.cancelled(): + return + exc = t.exception() + if exc: + logger.exception(f"后台任务异常: {exc}") + + task.add_done_callback(_done) + + async def receive_messages(self): + """持续接收消息""" + try: + async for message in self.websocket: + await self.handle_message(message) + + except websockets.exceptions.ConnectionClosed: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + print(f"[{self.get_time()}] 连接已关闭") + except Exception as e: + from utils.health_check import set_qingjian_connected + set_qingjian_connected(False) + print(f"[{self.get_time()}] 接收消息错误: {e}") + + async def handle_message(self, message): + """处理接收到的消息""" + timestamp = self.get_time() + + try: + data = json.loads(message) + + # 保存最后一条消息用于回复 + self.last_msg = data + + # 打印格式化的消息 + print(f"\n{'='*50}") + print(f"[{timestamp}] 收到新消息:") + print(f"{'='*50}") + print(f" 消息ID: {data.get('msg_id', 'N/A')}") + print(f" 账号ID: {self.to_chinese(data.get('acc_id', 'N/A'))}") + print(f" 发送者ID: {self.to_chinese(data.get('from_id', 'N/A'))}") + print(f" 发送者名称: {self.to_chinese(data.get('from_name', 'N/A'))}") + print(f" 会话ID: {self.to_chinese(data.get('cy_id', 'N/A'))}") + print(f" 平台类型: {data.get('acc_type', 'N/A')}") + print(f" 消息类型: {self.get_msg_type_name(data.get('msg_type', 0))}") + print(f" 消息内容: {self.to_chinese(data.get('msg', 'N/A'))}") + + # 显示商品信息(如果有) + if data.get('goods_name'): + print(f" 商品名称: {self.to_chinese(data.get('goods_name', ''))}") + if data.get('goods_order'): + print(f" 订单信息: {self.to_chinese(data.get('goods_order', ''))}") + + print(f"{'='*50}\n") + + # 消息去重:同一条消息不重复处理 + msg_id = data.get('msg_id', '') + if msg_id and msg_id in self._replied_msg_ids: + logger.info(f"重复消息,跳过: {msg_id}") + return + if msg_id: + self._replied_msg_ids.append(msg_id) # deque 自动淘汰最旧的 + + # 空消息/无效消息过滤(N/A 或关键字段全为空) + from_id = data.get('from_id', '') + acc_id = data.get('acc_id', '') + msg_body = data.get('msg', '') + if not from_id or from_id == 'N/A' or not acc_id or acc_id == 'N/A': + print(f"[{self.get_time()}] 空消息跳过(from_id={from_id!r} acc_id={acc_id!r})") + return + + # Gemini 店铺:不回复,直接跳过 + goods_name = self.to_chinese(data.get('goods_name', '') or '') + if _get_shop_type(acc_id, goods_name) == "gemini_api": + print(f"[{self.get_time()}] Gemini 店铺消息,跳过") + try: + _chat_log( + data.get('from_id', ''), + self.to_chinese(data.get('msg', '')), + "in", + customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), + acc_id=data.get('acc_id', ''), + platform=data.get('acc_type', ''), + msg_type=data.get('msg_type', 0), + ) + except Exception: + pass + try: + from utils.wechat_chat_log import push_chat_to_wechat + asyncio.create_task(push_chat_to_wechat( + customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), + customer_id=data.get('from_id', ''), + acc_id=data.get('acc_id', ''), + customer_msg=self.to_chinese(data.get('msg', '')), + reply_msg="", + goods_name=goods_name, + )) + except Exception: + pass + return + + # 使用 Agent 自动回复(仅处理文本消息) + if self.enable_agent: + msg_type = data.get('msg_type', 0) + if msg_type == 0: + if self._is_transfer_msg(data): + # 会话转交 → 主动打招呼 + print(f"[{self.get_time()}] 收到转交消息,发送问候") + greeting = "在呢,发图来我看看" + await self.send_reply(data, greeting) + try: + from utils.wechat_chat_log import push_chat_to_wechat + asyncio.create_task(push_chat_to_wechat( + customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), + customer_id=data.get('from_id', ''), + acc_id=data.get('acc_id', ''), + customer_msg=self.to_chinese(data.get('msg', '')), + reply_msg=greeting, + goods_name=self.to_chinese(data.get('goods_name', '') or ''), + )) + except Exception: + pass + elif self._is_shop_card(data): + # 进店卡片:有历史对话就不回复,没有才打招呼(Gemini 已在上面统一跳过) + cid = data.get('from_id', '') + if self._has_chat_history(cid): + print(f"[{self.get_time()}] 进店卡片(已有记录),跳过") + else: + print(f"[{self.get_time()}] 进店卡片(新客户),发送问候") + greeting = "在呢,发图来我看看" + await self.send_reply(data, greeting) + try: + from utils.wechat_chat_log import push_chat_to_wechat + asyncio.create_task(push_chat_to_wechat( + customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), + customer_id=data.get('from_id', ''), + acc_id=data.get('acc_id', ''), + customer_msg=self.to_chinese(data.get('msg', '')), + reply_msg=greeting, + goods_name=goods_name, + )) + except Exception: + pass + elif self._should_ignore(data): + print(f"[{self.get_time()}] 系统通知,跳过回复") + else: + await self._debounce_agent_reply(data) + elif msg_type == 1: + # 图片消息直接处理,不走防抖(图片不会连续多发) + await self.handle_image_message(data) + + except json.JSONDecodeError: + print(f"[{timestamp}] 收到非JSON消息: {message}") + + async def _debounce_agent_reply(self, data: dict): + """ + 消息防抖:同一客户在 _DEBOUNCE_SECONDS 内的连续消息合并后再处理。 + 订单通知、图片URL、付款相关消息不走防抖,立即处理。 + """ + msg_body = data.get('msg', '') + # 以下情况跳过防抖,立即处理(后台执行,不阻塞接收循环) + immediate_keywords = ["买家已付款", "已付款", "[系统订单信息]"] + if any(kw in msg_body for kw in immediate_keywords) or self._msg_has_image_url(msg_body): + self._fire_and_forget(self._agent_reply_serialized(data)) + return + + key = f"{data.get('acc_id','')}:{data.get('from_id','')}" + + # 积攒消息 + if key not in self._pending_msgs: + self._pending_msgs[key] = [] + self._pending_msgs[key].append(msg_body) + + # 取消上一个等待任务(如果有) + old_task = self._debounce_tasks.get(key) + if old_task and not old_task.done(): + old_task.cancel() + + # 创建新的延迟处理任务 + async def _delayed(capture_key, capture_data): + await asyncio.sleep(self._DEBOUNCE_SECONDS) + msgs = self._pending_msgs.pop(capture_key, []) + if not msgs: + return + if len(msgs) == 1: + merged_msg = msgs[0] + else: + merged_msg = "、".join(m for m in msgs if m.strip()) + print(f"[{self.get_time()}] 防抖合并 {len(msgs)} 条消息: {merged_msg[:60]}") + merged_data = dict(capture_data) + merged_data['msg'] = merged_msg + await self._agent_reply_serialized(merged_data) + + task = asyncio.create_task(_delayed(key, data)) + self._debounce_tasks[key] = task + + def _msg_has_image_url(self, msg: str) -> bool: + """判断文本消息里是否包含图片URL(客户粘贴了图片链接,可能带前缀文字如 有吗#*#https://...)""" + if not msg: + return False + lower = msg.lower() + image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp") + image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com") + if "http://" in lower or "https://" in lower: + if any(ext in lower for ext in image_exts) or any(h in lower for h in image_hosts): + return True + return False + + def _msg_refers_images(self, msg: str) -> bool: + """判断文本是否指代之前的图片(图一/图二/这张/那张/上面那张等)""" + if not msg: + return False + refs = ("图一", "图二", "第一张", "第二张", "这张", "那张", "上面那张", "下面那张", "刚才那张", "上一张", "下一张") + return any(r in msg for r in refs) + + def _extract_image_urls(self, 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): + tokens = re.findall(r'(https?://\S+)', msg) + for t in tokens: + if any(ext in t.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]): + urls.append(t) + return urls[:8] + + def _collect_recent_image_urls(self, customer_id: str, acc_id: str, max_count: int = 6) -> list: + """从最近对话中回溯收集图片URL(优先买家消息),用于慢发或引用图片的场景""" + urls, seen = [], set() + try: + from db.chat_log_db import get_recent_conversation + recent = get_recent_conversation(customer_id=customer_id, acc_id=acc_id, limit=20) + # 从最近到更早遍历,收集买家(in)消息中的图片链接 + for m in reversed(recent): + if m.get("direction") != "in": + continue + ms = m.get("message") or "" + us = self._extract_image_urls(ms) + for u in us: + if u not in seen: + seen.add(u) + urls.append(u) + if len(urls) >= max_count: + return urls + except Exception: + pass + return urls + + def _msg_is_requirement(self, msg: str) -> bool: + if not msg: + return False + kws = ("要", "抓到", "放到", "合成", "替换", "抠", "修", "高清", "尺寸", "横", "竖", "颜色", "去背景", "排版", "一样", "类似", "同款") + return any(k in msg for k in kws) + + def _add_pending_images(self, key: str, urls: list, limit: int = 12): + if not urls: + return + cur = self._pending_images.get(key) or [] + for u in urls: + if u not in cur: + cur.append(u) + if len(cur) >= limit: + break + self._pending_images[key] = cur + + async def _flush_pending_images(self, key: str, data: dict): + urls = self._pending_images.get(key) or [] + if not urls: + return + self._pending_images[key] = [] + if len(urls) == 1: + await self._analyze_single_and_reply(data, urls[0]) + else: + await self._analyze_multi_and_reply(data, urls) + + def _msg_is_price_inquiry(self, msg: str) -> bool: + """判断是否是价格询问""" + if not msg: + return False + patterns = ("多少钱", "多少一张", "一张多少钱", "画图多少", "报价", "给个价", "几块", "多少钱") + return any(p in msg for p in patterns) + + def _detect_order_status(self, msg: str) -> str: + if not msg: + return "" + s = msg + if "买家已付款" in s or "已付款" in s: + return "paid" + if "[系统订单信息]" in s: + if "等待买家付款" in s or "未付款" in s: + return "waiting" + return "order" + return "" + + async def _analyze_single_and_reply(self, data: dict, url: str): + try: + from image.image_analyzer import image_analyzer + r = await image_analyzer.analyze(url) + if isinstance(r, dict) and r.get("success", False): + from config.config import MIN_PRICE_FLOOR + p = r.get("price_suggest", 20) + floor_dyn = r.get("price_min", MIN_PRICE_FLOOR) + floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR) + p = max(floor, round(p / 5) * 5) + try: + from db.customer_db import db as _db + _db.update_last_min_price(data.get('from_id',''), floor) + except Exception: + pass + reply = f"这张按{p}元,满意再拍" + else: + reply = "这张我看了,先按20元给你做" + await self.send_reply(data, reply) + try: + _chat_log( + data.get('from_id', ''), + reply, + "out", + customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), + acc_id=data.get('acc_id', ''), + platform=data.get('acc_type', '') + ) + except Exception: + pass + except Exception: + pass + + async def agent_reply(self, data: dict): + """使用 Agent 处理消息并回复""" + try: + msg_text = self.to_chinese(data.get('msg', '')) + _cid = data.get('from_id', '') + _name = self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')) + _plat = data.get('acc_type', '') + + # 记录客户来消息 + if _cid and msg_text: + try: + _chat_log(_cid, msg_text, "in", customer_name=_name, + acc_id=data.get('acc_id', ''), + platform=_plat, msg_type=data.get('msg_type', 0)) + except Exception: + pass + + # 消息含图片URL:累积到待处理列表,先询问要求 + if self._msg_has_image_url(msg_text): + urls = self._extract_image_urls(msg_text) + key = self._customer_key(data) + self._add_pending_images(key, urls) + await self.send_reply(data, "图片收到了,说下要求(尺寸/要做什么)") + old = self._pending_image_tasks.get(key) + if old and not old.done(): + old.cancel() + async def _delay_flush(capture_key, capture_data): + await asyncio.sleep(self._DEBOUNCE_SECONDS + 4) + await self._flush_pending_images(capture_key, capture_data) + task = asyncio.create_task(_delay_flush(key, data)) + self._pending_image_tasks[key] = task + elif self._msg_refers_images(msg_text): + urls = self._collect_recent_image_urls(_cid, data.get('acc_id', ''), max_count=6) + if urls: + key = self._customer_key(data) + self._add_pending_images(key, urls) + await self.send_reply(data, "稍等,我找找刚才那几张") + await self._flush_pending_images(key, data) + else: + status = self._detect_order_status(msg_text) + if status == "paid": + ack = "收到付款,我马上安排处理,有需要第一时间联系您" + await self.send_reply(data, ack) + elif status in ("waiting", "order"): + ack = "订单我看到了哈,方便的话请完成付款,我好安排处理" + await self.send_reply(data, ack) + else: + urls = self._extract_image_urls(msg_text) + if len(urls) == 1: + key = self._customer_key(data) + self._add_pending_images(key, urls) + await self.send_reply(data, "图片收到了,说下要求(尺寸/要做什么)") + else: + if self._msg_requests_external_contact(msg_text): + reply = "这里沟通就可以哦,其他联系方式不方便" + await self.send_reply(data, reply) + try: + from utils.wechat_chat_log import push_chat_to_wechat + asyncio.create_task(push_chat_to_wechat( + customer_name=_name, + customer_id=_cid, + acc_id=data.get('acc_id', ''), + customer_msg=msg_text, + reply_msg=reply, + goods_name=self.to_chinese(data.get('goods_name', '') or ''), + )) + except Exception: + pass + return + if self._msg_is_requirement(msg_text) or self._msg_is_price_inquiry(msg_text): + key = self._customer_key(data) + if self._pending_images.get(key): + await self.send_reply(data, "稍等,我把刚才那几张一起看下") + await self._flush_pending_images(key, data) + old = self._pending_image_tasks.get(key) + if old and not old.done(): + old.cancel() + return + if self._msg_is_price_inquiry(msg_text): + recent_urls = self._collect_recent_image_urls(_cid, data.get('acc_id', ''), max_count=6) + if recent_urls: + await self.send_reply(data, "稍等,我刚才那几张一起看下") + if len(recent_urls) == 1: + asyncio.create_task(self._analyze_single_and_reply(data, recent_urls[0])) + else: + asyncio.create_task(self._analyze_multi_and_reply(data, recent_urls)) + return + status = self._detect_order_status(msg_text) + if status == "paid": + ack = "收到付款,我马上安排处理,有需要第一时间联系您" + await self.send_reply(data, ack) + elif status in ("waiting", "order"): + ack = "订单我看到了哈,方便的话请完成付款,我好安排处理" + await self.send_reply(data, ack) + + # 构建 CustomerMessage + customer_msg = CustomerMessage( + msg_id=data.get('msg_id', ''), + acc_id=data.get('acc_id', ''), + msg=self.to_chinese(data.get('msg', '')), + from_id=data.get('from_id', ''), + from_name=self.to_chinese(data.get('from_name', '')), + cy_id=data.get('cy_id', ''), + acc_type=data.get('acc_type', ''), + msg_type=data.get('msg_type', 0), + cy_name=self.to_chinese(data.get('cy_name', '')), + goods_name=self.to_chinese(data.get('goods_name', '')) if data.get('goods_name') else None, + goods_order=self.to_chinese(data.get('goods_order', '')) if data.get('goods_order') else None + ) + + # 先检查是否是 workflow 等待确认中的回复(如邮箱、确认/不满意) + if workflow: + workflow_reply = await workflow.handle_customer_reply( + customer_id=data.get('from_id', ''), + message=self.to_chinese(data.get('msg', '')), + acc_id=data.get('acc_id', ''), + acc_type=data.get('acc_type', 'AliWorkbench') + ) + if workflow_reply: + logger.info(f"Workflow 回复: {workflow_reply}") + await self.send_reply(data, workflow_reply) + # 推送到企微:客户消息+回复成对 + try: + from utils.wechat_chat_log import push_chat_to_wechat + asyncio.create_task(push_chat_to_wechat( + customer_name=_name, + customer_id=_cid, + acc_id=data.get('acc_id', ''), + customer_msg=msg_text, + reply_msg=workflow_reply, + goods_name=self.to_chinese(data.get('goods_name', '') or ''), + )) + except Exception: + pass + return + + logger.info("Agent 正在处理消息...") + + # 调用 Agent + response = await self.agent.process_message(customer_msg) + + # 检查是否需要转接人工 + if response.need_transfer: + logger.info("Agent 决定转接人工") + await self.transfer_to_human(data, response.transfer_msg) + # 推送到企微:客户消息+转接回复成对 + try: + from utils.wechat_chat_log import push_chat_to_wechat + asyncio.create_task(push_chat_to_wechat( + customer_name=_name, + customer_id=_cid, + acc_id=data.get('acc_id', ''), + customer_msg=msg_text, + reply_msg=response.transfer_msg or "转接", + goods_name=self.to_chinese(data.get('goods_name', '') or ''), + )) + except Exception: + pass + + # 联系方式提取已由 Agent 的 update_contact_info 工具负责 + # 此处仅做兜底:更新最后联系时间 + customer_id = data.get('from_id', '') + if customer_id: + try: + profile = db.get_customer(customer_id) + profile.last_contact = datetime.now().isoformat() + db.save_customer(profile) + except Exception: + pass + + # 保存对话摘要(异步,不阻塞回复) + if response.should_reply and response.reply and customer_id: + asyncio.create_task(self._save_conversation_summary( + customer_id=customer_id, + buyer_msg=self.to_chinese(data.get('msg', '')), + agent_reply=response.reply, + )) + + # 正常回复 + if response.should_reply and response.reply: + # 过滤 AI 误输出的"无需回复"类废话,避免发给客户 + nonsense_patterns = [ + "无需", "流程已完成", "不需要回复", "无需额外", "已完成", + "无需回复", "不需要额外", "已经完成", "无需再", "操作已完成", + "任务完成", "流程完成", "记录完成", "报价已", + ] + matched = [p for p in nonsense_patterns if p in response.reply] + if matched: + logger.warning(f"Agent 回复含无效内容,已拦截: {response.reply} ← 命中pattern: {matched}") + else: + # 模拟真人打字延迟,避免瞬间回复太机械 + await asyncio.sleep(0.8) + logger.info(f"Agent 回复: {response.reply}") + await self.send_reply(data, response.reply) + # 记录客服回复 + if _cid: + try: + _chat_log(_cid, response.reply, "out", customer_name=_name, + acc_id=data.get('acc_id', ''), platform=_plat) + except Exception: + pass + # 推送到企微:客户消息+AI回复成对 + try: + from utils.wechat_chat_log import push_chat_to_wechat + asyncio.create_task(push_chat_to_wechat( + customer_name=_name, + customer_id=_cid, + acc_id=data.get('acc_id', ''), + customer_msg=msg_text, + reply_msg=response.reply, + goods_name=self.to_chinese(data.get('goods_name', '') or ''), + )) + except Exception: + pass + elif not response.need_transfer: + logger.info("Agent 决定不回复此消息") + + except Exception as e: + logger.error(f"Agent 处理失败: {e}") + + async def _analyze_multi_and_reply(self, data: dict, urls: list): + try: + from image.image_analyzer import image_analyzer + def _detect_composite_request() -> bool: + try: + from db.chat_log_db import get_recent_conversation + recent = get_recent_conversation( + customer_id=data.get('from_id', ''), + acc_id=data.get('acc_id', ''), + limit=8 + ) + kw = ("抓到", "放到", "合成", "融合", "嵌到", "换到", "替换", "P到", "抠出来放到") + for m in recent: + msg = (m.get("message") or "") + if any(k in msg for k in kw): + return True + except Exception: + pass + return False + + tasks = [image_analyzer.analyze(u) for u in urls] + results = await asyncio.gather(*tasks, return_exceptions=True) + pairs = [] + for u, r in zip(urls, results): + if isinstance(r, dict) and r.get("success", False): + from config.config import MIN_PRICE_FLOOR + floor_dyn = r.get("price_min", MIN_PRICE_FLOOR) + floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR) + ps = max(floor, round(r.get("price_suggest", 20) / 5) * 5) + pairs.append((u, ps, r.get("category", ""), r.get("megapixels", 0.0))) + try: + if pairs: + floors = [] + for u, r in zip(urls, results): + if isinstance(r, dict) and r.get("success", False): + floor_dyn = r.get("price_min", MIN_PRICE_FLOOR) + floor = max(MIN_PRICE_FLOOR, int(floor_dyn) if isinstance(floor_dyn, (int, float)) else MIN_PRICE_FLOOR) + floors.append(floor) + if floors: + from db.customer_db import db as _db + _db.update_last_min_price(data.get('from_id',''), min(floors)) + except Exception: + pass + if not pairs: + await self.send_reply(data, "这组图我看了,先按20元一张给你做") + return + composite = _detect_composite_request() + composite_fee = 5 if composite else 0 + avg_raw = sum(p for _, p, _, _ in pairs) / len(pairs) + from config.config import MIN_PRICE_FLOOR + avg_price = max(MIN_PRICE_FLOOR, round((avg_raw + composite_fee) / 5) * 5) + top_price = max(MIN_PRICE_FLOOR, max(pairs, key=lambda x: x[1])[1] + composite_fee) + count = len(pairs) + if composite: + reply = f"这组{count}张我看了,按{avg_price}元一张;合成那张{top_price}元,满意再拍" + else: + reply = f"这组{count}张我看了,按{avg_price}元一张;复杂那张{top_price}元,满意再拍" + await self.send_reply(data, reply) + try: + _chat_log( + data.get('from_id', ''), + reply, + "out", + customer_name=self.to_chinese(data.get('from_name', '') or data.get('cy_name', '')), + acc_id=data.get('acc_id', ''), + platform=data.get('acc_type', '') + ) + except Exception: + pass + except Exception as e: + logger.error(f"多图分析失败: {e}") + try: + await self.send_reply(data, "这组图我看了,先按20元一张给你做") + except Exception: + pass + def _msg_requests_external_contact(self, msg: str) -> bool: + if not msg: + return False + lower = msg.lower() + kws = ("加qq", "qq号", "vx", "微信", "加v", "联系方式", "私聊", "加一下", "加个", "手机号", "电话", "加群", "q q", "v 信") + return any(k in lower for k in kws) + def _is_transfer_msg(self, data: dict) -> bool: + """判断是否是会话转交消息(需要主动打招呼)""" + msg = self.to_chinese(data.get('msg', '')) + return '转交给' in msg or '转接给' in msg + + def _is_shop_card(self, data: dict) -> bool: + """判断是否是进店卡片消息""" + msg = self.to_chinese(data.get('msg', '')) + return msg.startswith('[进店卡片]') or '我想咨询你们店的这个商品' in msg + + def _has_chat_history(self, customer_id: str) -> bool: + """判断该客户是否已有聊天记录(内存历史或数据库均可)""" + if not customer_id: + return False + # 先查内存对话历史(最快) + if customer_id in self.agent.message_histories and self.agent.message_histories[customer_id]: + return True + # 再查数据库(重启后仍有记录) + try: + from db.chat_log_db import get_conversation + msgs = get_conversation(customer_id, limit=1) + return len(msgs) > 0 + except Exception: + return False + + def _should_ignore(self, data: dict) -> bool: + """判断是否应该忽略该消息(不回复)""" + msg = self.to_chinese(data.get('msg', '')) + + # 会话转交由 _is_transfer_msg 单独处理,这里不再忽略 + ignore_patterns = [ + '已转接', + '接入会话', + '结束会话', + '会话已', + '[系统消息]', + '[系统通知]', + ] + for pattern in ignore_patterns: + if pattern in msg: + return True + + # 发送者是自己(店铺账号),避免回复自己发的消息 + acc_id = data.get('acc_id', '') + from_id = data.get('from_id', '') + if acc_id and from_id and acc_id == from_id: + return True + + return False + + def get_msg_type_name(self, msg_type): + """获取消息类型名称""" + types = { + 0: "文本", + 1: "图片", + 2: "视频", + 3: "文件" + } + return types.get(msg_type, f"未知({msg_type})") + + def _extract_and_save_customer_info(self, message: str, customer_id: str): + """从消息中提取客户信息并保存""" + if not message or not customer_id: + return + + # 提取邮箱 + email_pattern = r'[\w\.-]+@[\w\.-]+\.\w+' + email_match = re.search(email_pattern, message) + if email_match: + db.update_email(customer_id, email_match.group()) + + # 提取手机号 + phone_pattern = r'1[3-9]\d{9}' + phone_match = re.search(phone_pattern, message) + if phone_match: + db.update_phone(customer_id, phone_match.group()) + + # 提取微信号 + wechat_pattern = r'[Vv微信]+号[::]?\s*([\w-]+)' + wechat_match = re.search(wechat_pattern, message) + if wechat_match: + db.update_wechat(customer_id, wechat_match.group(1)) + + # 提取预算关键词 + budget_keywords = ['预算', '不超过', '最多', '便宜点', '便宜'] + for keyword in budget_keywords: + if keyword in message: + db.add_personality_tag(customer_id, "关注价格") + break + + # 提取性格关键词 + personality_keywords = { + '爽快': '爽快', + '干脆': '爽快', + '纠结': '纠结', + '墨迹': '纠结', + '砍价': '砍价', + '贵': '砍价' + } + for keyword, tag in personality_keywords.items(): + if keyword in message: + db.add_personality_tag(customer_id, tag) + + # 更新最后联系时间 + profile = db.get_customer(customer_id) + profile.last_contact = datetime.now().isoformat() + db.save_customer(profile) + + def to_chinese(self, text): + """处理文本,安全地转换 unicode 转义""" + if not isinstance(text, str): + return text + if '\\u' not in text: + return text + try: + return json.loads(f'"{text}"') + except Exception: + return text + + async def handle_image_message(self, data: dict): + """ + 处理图片消息。 + 先回复"我找找",然后把图片URL作为消息内容交给 Agent(后台执行)。 + Agent 会自主调用 analyze_image() 工具分析复杂度,再报价。 + 整个过程由 Agent 自主协调,无需外部干预。 + 不阻塞接收循环,可同时接收其他客户消息。 + """ + # 立刻回复,让客户感觉真人在操作 + await self.send_reply(data, "我找找") + + # 把图片URL当作消息内容,交给 Agent 后台处理(图片分析约 12 秒,不阻塞新消息接收) + image_data = dict(data) + image_data['msg'] = f"[客户发来图片] {data.get('msg', '')}" + image_data['msg_type'] = 0 # 转为文本消息,让 agent_reply 处理 + self._fire_and_forget(self._agent_reply_serialized(image_data)) + + async def transfer_to_human(self, data: dict, transfer_msg: str = ""): + """ + 转接人工客服。 + 1. 优先从 designer_roster 轮询派单(在线设计师) + 2. 无人在线或未配置时,回退到 config/transfer_groups.json + 设计师在线状态:仅在转人工时按需查询,不轮询。 + """ + if not self.websocket: + print(f"[{self.get_time()}] 错误: 未连接到服务器") + return + + acc_id = data.get("acc_id", "") + group_id = None + + # 1. 转人工时按需查询设计师在线状态(调用另一台 AI 的查询服务),再派单 + try: + from utils.designer_roster import poll_and_update_roster + from db.designer_roster_db import get_transfer_group_for_shop + await poll_and_update_roster() + group_id = get_transfer_group_for_shop(acc_id) + except Exception as e: + logger.debug(f"设计师派单未启用或异常: {e}") + + # 2. 无人在线时企微提醒 + if not group_id: + try: + from config.config import WECHAT_WEBHOOK + if WECHAT_WEBHOOK: + import httpx + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.post(WECHAT_WEBHOOK, json={ + "msgtype": "text", + "text": {"content": "谁在线啊"} + }) + if resp.status_code != 200: + logger.warning(f"企微提醒发送失败: {resp.status_code} {resp.text}") + else: + logger.debug("未配置 WECHAT_WEBHOOK,跳过企微提醒") + except Exception as e: + logger.warning(f"企微提醒发送异常: {e}") + + # 3. 回退到静态配置 + if not group_id: + group_id = _get_transfer_group(acc_id) + + # 先发一条提示语给客户 + await self.send_reply(data, "亲,正在为您转接人工客服,请稍等~") + + cmd = f"话术|[转移会话],分组{group_id},无原因" + await self.send_reply(data, cmd) + print(f"[{self.get_time()}] 已发送转接请求 (店铺:{acc_id or '未知'} -> 分组:{group_id})") + + async def _save_conversation_summary(self, customer_id: str, buyer_msg: str, agent_reply: str): + """用 AI 生成一句话对话摘要并持久化""" + try: + from db.customer_db import db + from openai import AsyncOpenAI + client = AsyncOpenAI( + api_key=self.agent.api_key if self.agent else None, + base_url=self.agent.base_url if self.agent else None, + ) + resp = await client.chat.completions.create( + model=self.agent.model_name if self.agent else "gpt-4o-mini", + messages=[ + {"role": "system", "content": "用一句话(15字以内)总结这段对话的核心内容,只输出摘要文字。"}, + {"role": "user", "content": f"买家:{buyer_msg}\n客服:{agent_reply}"}, + ], + max_tokens=30, + temperature=0.3, + ) + summary = resp.choices[0].message.content.strip() + db.save_conversation_summary(customer_id, summary) + except Exception: + pass # 摘要失败不影响主流程 + + async def _workflow_agent_notify( + self, + customer_id: str, + acc_id: str, + acc_type: str, + system_hint: str, + ): + """图片处理完成后,让客服 AI 生成自然话术发给客户""" + if not self.enable_agent or not self.agent: + return + try: + from core.pydantic_ai_agent import CustomerMessage + notify_msg = CustomerMessage( + msg_id="workflow_notify", + acc_id=acc_id, + msg=system_hint, + from_id=customer_id, + from_name="", + cy_id=customer_id, + acc_type=acc_type, + msg_type=0, + cy_name="", + ) + response = await self.agent.process_message(notify_msg) + if response.should_reply and response.reply: + nonsense_patterns = [ + "无需", "流程已完成", "不需要回复", "无需额外", "已完成", + "无需回复", "不需要额外", "已经完成", "无需再", "操作已完成", + "任务完成", "流程完成", "记录完成", "报价已", + ] + if not any(p in response.reply for p in nonsense_patterns): + # 构造一个虚拟原始消息用于 send_reply + fake_data = { + "acc_id": acc_id, + "from_id": customer_id, + "from_name": "", + "cy_id": customer_id, + "acc_type": acc_type, + } + await asyncio.sleep(0.5) + await self.send_reply(fake_data, response.reply) + try: + _chat_log(customer_id, response.reply, "out", + acc_id=acc_id, platform=acc_type) + except Exception: + pass + logger.info(f"[Workflow] AI 通知已发送: {response.reply}") + except Exception as e: + logger.error(f"[Workflow] AI 通知生成失败: {e}") + + async def _workflow_send( + self, + customer_id: str, + acc_id: str, + acc_type: str, + content: str, + msg_type: int = 0 + ): + """workflow 回调:图片AI完成后用此方法推送消息给客户""" + msg = { + "msg_id": "", + "acc_id": acc_id, + "msg": content, + "from_id": customer_id, + "from_name": customer_id, + "cy_id": customer_id, + "acc_type": acc_type, + "msg_type": msg_type, + "cy_name": customer_id + } + await self.send_message(msg) + + async def send_reply(self, original_msg, reply_content): + """ + 发送回复消息 + + Args: + original_msg: 收到的原始消息字典 + reply_content: 回复内容(文本或本地文件路径/http地址) + """ + if not self.websocket: + print(f"[{self.get_time()}] 错误: 未连接到服务器") + return + + shop_id = original_msg.get("acc_id", "") + + # 根据轻简API文档: + # from_id = 客户ID(收消息方) + # cy_id = 非群聊时与 from_id 相同 + customer_id = original_msg.get("from_id", "") + customer_name = original_msg.get("from_name", "") + + reply = { + "msg_id": "", + "acc_id": shop_id, + "msg": reply_content, + "from_id": customer_id, + "from_name": customer_name, + "cy_id": customer_id, + "acc_type": original_msg.get("acc_type", ""), + "msg_type": 0, + "cy_name": customer_name + } + + await self.send_message(reply) + + async def send_text(self, cy_id, acc_type, content): + """ + 主动发送文本消息 + + Args: + cy_id: 会话ID(对方ID) + acc_type: 平台类型 + content: 消息内容 + """ + message = { + "msg_id": "", + "acc_id": "", + "msg": content, + "from_id": self.reply_id, + "from_name": self.reply_id, + "cy_id": cy_id, + "acc_type": acc_type, + "msg_type": 0, + "cy_name": "" + } + await self.send_message(message) + + async def send_image(self, cy_id, acc_type, image_path): + """ + 主动发送图片消息 + + Args: + cy_id: 会话ID(对方ID) + acc_type: 平台类型 + image_path: 图片本地路径或http地址 + """ + message = { + "msg_id": "", + "acc_id": "", + "msg": image_path, + "from_id": self.reply_id, + "from_name": self.reply_id, + "cy_id": cy_id, + "acc_type": acc_type, + "msg_type": 1, + "cy_name": "" + } + await self.send_message(message) + + async def send_message(self, message): + """发送消息到服务器""" + if self.websocket and self.websocket.state == websockets.protocol.State.OPEN: + try: + msg_json = json.dumps(message, ensure_ascii=False) + await self.websocket.send(msg_json) + pretty = json.dumps(message, ensure_ascii=False, indent=2) + print(f"[{self.get_time()}] 发送成功:\n{pretty}") + except Exception as e: + print(f"[{self.get_time()}] 发送失败: {e}") + else: + print(f"[{self.get_time()}] 错误: 连接未打开") + + async def auto_reply(self, data): + """自动回复示例(已弃用,使用 agent_reply 替代)""" + pass + + async def command_handler(self): + """命令行交互""" + print("\n命令帮助:") + print(" reply <内容> - 回复最后一条消息") + print(" text <平台> <内容> - 发送文本消息") + print(" img <平台> <路径> - 发送图片") + print(" setid - 设置回复ID") + print(" agent on/off - 开启/关闭 Agent") + print(" exit/quit - 退出\n") + + while self.running: + try: + loop = asyncio.get_running_loop() + user_input = await loop.run_in_executor(None, input, "") + + parts = user_input.strip().split(maxsplit=1) + if not parts: + continue + + cmd = parts[0].lower() + + if cmd in ["exit", "quit", "q"]: + print(f"[{self.get_time()}] 正在关闭...") + self.running = False + if self.websocket: + await self.websocket.close() + break + + elif cmd == "setid" and len(parts) > 1: + self.reply_id = parts[1] + print(f"[{self.get_time()}] 回复ID已设置为: {self.reply_id}") + + elif cmd == "agent" and len(parts) > 1: + if parts[1].lower() == "on": + self.enable_agent = True + print(f"[{self.get_time()}] Agent 已开启") + elif parts[1].lower() == "off": + self.enable_agent = False + print(f"[{self.get_time()}] Agent 已关闭") + + elif cmd == "reply" and len(parts) > 1: + if self.last_msg: + await self.send_reply(self.last_msg, parts[1]) + else: + print(f"[{self.get_time()}] 错误: 还没有收到任何消息") + + elif cmd == "text" and len(parts) > 1: + # text cy_id acc_type content + args = parts[1].split(maxsplit=2) + if len(args) >= 3: + await self.send_text(args[0], args[1], args[2]) + else: + print(f"[{self.get_time()}] 格式: text <内容>") + + elif cmd == "img" and len(parts) > 1: + # img cy_id acc_type image_path + args = parts[1].split(maxsplit=2) + if len(args) >= 3: + await self.send_image(args[0], args[1], args[2]) + else: + print(f"[{self.get_time()}] 格式: img <图片路径>") + + else: + print(f"[{self.get_time()}] 未知命令: {cmd}") + + except Exception as e: + print(f"[{self.get_time()}] 命令错误: {e}") + + def get_time(self): + """获取当前时间字符串""" + return datetime.now().strftime("%H:%M:%S") + + async def run(self): + """运行客户端""" + tasks = [self.connect(), self.command_handler()] + + # 启动邮件接收后台任务 + try: + from mail.email_receiver import email_receiver + if email_receiver.username: + print(f"[{self.get_time()}] 邮件接收已启动,监控: {email_receiver.username}") + tasks.append(email_receiver.start()) + else: + print(f"[{self.get_time()}] 未配置邮件账号,跳过邮件接收") + except Exception as e: + print(f"[{self.get_time()}] 邮件接收模块加载失败: {e}") + + # 启动每日汇总定时任务 + try: + from utils.daily_summary import scheduler as daily_scheduler + tasks.append(daily_scheduler()) + print(f"[{self.get_time()}] 每日日报定时任务已启动") + except Exception as e: + print(f"[{self.get_time()}] 日报模块加载失败: {e}") + + # 设计师在线状态:转人工时按需查询,不再轮询 + + # 启动健康检查(轻简/企微断线告警) + try: + from utils.health_check import health_check_loop + def _qingjian_ok(): + return self.websocket is not None and not getattr(self.websocket, "closed", True) + tasks.append(health_check_loop(_qingjian_ok)) + print(f"[{self.get_time()}] 健康检查已启动") + except Exception as e: + print(f"[{self.get_time()}] 健康检查模块加载失败: {e}") + + # 每天早上8点发送启动消息到企微群 + try: + from utils.wechat_chat_log import morning_startup_scheduler + tasks.append(morning_startup_scheduler()) + print(f"[{self.get_time()}] 早8点企微启动消息已启动") + except Exception as e: + print(f"[{self.get_time()}] 企微启动消息模块加载失败: {e}") + + await asyncio.gather(*tasks) + + +if __name__ == "__main__": + import sys + + # 检查是否有 --no-agent 参数 + enable_agent = "--no-agent" not in sys.argv + + client = QingjianAPIClient(enable_agent=enable_agent) + try: + asyncio.run(client.run()) + except KeyboardInterrupt: + print("\n已停止") diff --git a/core/workflow.py b/core/workflow.py new file mode 100644 index 0000000..8053a97 --- /dev/null +++ b/core/workflow.py @@ -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() diff --git a/customer_db/customers.json b/customer_db/customers.json new file mode 100644 index 0000000..a45ded7 --- /dev/null +++ b/customer_db/customers.json @@ -0,0 +1,3810 @@ +{ + "tb056906307893": { + "customer_id": "tb056906307893", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 30, + "last_price_time": "2026-02-25T18:05:35.822928", + "last_image_url": "https://img.alicdn.com/imgextra/i3/O1CN015r1E1y1kr8tLkTbdZ_!!4611686018427387680-0-amp.jpg", + "last_image_time": "2026-02-25T18:05:30.939198", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家问身份,客服称是店家", + "last_conversation_time": "2026-02-26T10:45:06.893182", + "total_images_sent": 2, + "complexity_history": [ + "complex", + "complex" + ], + "image_type_history": [ + "产品", + "产品" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-25 15:18] 报价 30元(单图处理)", + "[2026-02-25 15:21] 报价 40元(单图处理)", + "[2026-02-25 15:22] 报价 40元(单图复杂处理)", + "[2026-02-25 15:22] 报价 40元(单图复杂处理)", + "[2026-02-25 17:21] 报价 30元(单图处理)", + "[2026-02-25 17:26] 报价 25元(单图处理)", + "[2026-02-25 18:02] 报价 30元(单图处理)", + "[2026-02-25 18:05] 报价 30元(单图处理)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T10:45:01.348362", + "last_update": "2026-02-26T10:45:06.893182" + }, + "飞扬草89": { + "customer_id": "飞扬草89", + "name": "", + "nickname": "", + "email": "", + "phone": "16860184273", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-25T15:28:31.551548", + "last_update": "2026-02-25T15:28:31.551548" + }, + "默雪微凉": { + "customer_id": "默雪微凉", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "客服让买家放心拍不满意可退", + "last_conversation_time": "2026-02-27T10:30:31.753772", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T10:30:24.698816", + "last_update": "2026-02-27T10:30:31.755040" + }, + "h846967523": { + "customer_id": "h846967523", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "", + "last_conversation_time": "", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T00:18:45.344764", + "last_update": "2026-02-26T00:18:45.344764" + }, + "test_buyer_001": { + "customer_id": "test_buyer_001", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-25T16:34:25.734977", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-25 15:44] 报价 20元(单图处理)", + "[2026-02-25 15:45] 报价 40元(单图处理)", + "[2026-02-25 15:49] 报价 25元(单图处理)", + "[2026-02-25 15:49] 报价 30元(单图处理)", + "[2026-02-25 15:50] 报价 30元(单图处理)", + "[2026-02-25 15:57] 报价 25元(单图处理)", + "[2026-02-25 15:57] 报价 30元(单图处理)", + "[2026-02-25 16:09] 报价 25元(单图处理)", + "[2026-02-25 16:22] 报价 25元(单图处理)", + "[2026-02-25 16:23] 报价 15元(单图处理)", + "[2026-02-25 16:24] 报价 15元(单图处理)", + "[2026-02-25 16:25] 报价 25元(单图处理)", + "[2026-02-25 16:26] 报价 25元(单图处理)", + "[2026-02-25 16:26] 报价 100元(五图打包)", + "[2026-02-25 16:27] 报价 15元(单图处理)", + "[2026-02-25 16:27] 报价 15元(单图处理)", + "[2026-02-25 16:29] 报价 15元(单图处理)", + "[2026-02-25 16:29] 报价 15元(单图处理)", + "[2026-02-25 16:30] 报价 25元(单图处理)", + "[2026-02-25 16:30] 报价 32元(四张图打包处理)", + "[2026-02-25 16:31] 报价 15元(单图处理)", + "[2026-02-25 16:32] 报价 15元(单图处理)", + "[2026-02-25 16:33] 报价 25元(单图处理)", + "[2026-02-25 16:34] 报价 25元(单图处理)" + ], + "tags": [], + "created_at": "", + "last_contact": "", + "last_update": "2026-02-25T16:34:25.737317" + }, + "jajp5257": { + "customer_id": "jajp5257", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-25T17:39:00.239783", + "last_update": "2026-02-25T17:39:00.239783" + }, + "": { + "customer_id": "", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "", + "last_image_time": "", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "", + "last_conversation_time": "", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "", + "last_update": "2026-02-25T18:40:25.907580" + }, + "tb9244405304": { + "customer_id": "tb9244405304", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 1, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "2026-02-26T18:02:48.649796", + "first_order_date": "2026-02-26T18:02:48.649796", + "order_ids": [ + "3263604169112478274" + ], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "D", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "客服让买家发图以便处理", + "last_conversation_time": "2026-02-26T19:05:31.307469", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [ + "产品", + "产品", + "产品" + ], + "price_sensitivity": "低", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T19:05:25.018904", + "last_update": "2026-02-26T19:05:31.307469" + }, + "tb2801080146": { + "customer_id": "tb2801080146", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:提取该花砖印花,去除实拍背景杂物,还原清晰平面印花|ratio:1:1|perspective:mild|proc_type:印花提取|subject:花砖印花图案|quality:截图", + "complexity:complex|prompt:提取复古花砖餐垫印花,去除桌面背景,保留色彩细节|ratio:1:1|perspective:mild|proc_type:印花提取|subject:花砖印花餐垫|quality:截图", + "complexity:complex|prompt:提取复古花砖印花,去除拍摄背景,还原色彩细节|ratio:1:1|perspective:mild|proc_type:印花提取|subject:花砖印花图案|quality:截图" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 30, + "last_price_time": "2026-02-25T19:18:08.110317", + "last_image_url": "https://img.alicdn.com/imgextra/i3/O1CN01uQbEnC1qIEOd94v3r_!!4611686018427380880-0-amp.jpg", + "last_image_time": "2026-02-25T19:32:23.346846", + "last_gemini_prompt": "提取复古花砖印花,去除拍摄背景,还原色彩细节", + "last_aspect_ratio": "1:1", + "last_perspective": "mild", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家付款下单客服安排服务", + "last_conversation_time": "2026-02-25T19:33:14.215188", + "total_images_sent": 6, + "complexity_history": [ + "complex", + "complex", + "complex", + "complex", + "complex", + "complex" + ], + "image_type_history": [ + "产品", + "产品", + "产品", + "产品", + "花砖印花图案", + "产品", + "花砖印花餐垫", + "花砖印花图案", + "产品" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-25 18:21] 报价 30元(单图处理)", + "[2026-02-25 18:31] 报价 30元(单图处理)", + "[2026-02-25 18:39] 报价 30元(单图处理)", + "[2026-02-25 19:02] 报价 30元(单图处理)", + "[2026-02-25 19:18] 报价 30元(单图处理)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-25T19:33:09.670757", + "last_update": "2026-02-25T19:33:40.028103" + }, + "沙沙爱沙沙123": { + "customer_id": "沙沙爱沙沙123", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "https://img.alicdn.com/imgextra/i4/O1CN01f9NInZ2IPNovVI7nr_!!4611686018427383486-0-amp.jpg", + "last_image_time": "2026-02-25T18:46:58.057669", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "客服让买家拍下后马上安排", + "last_conversation_time": "2026-02-25T19:23:09.755507", + "total_images_sent": 1, + "complexity_history": [ + "normal" + ], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-25T19:22:54.303664", + "last_update": "2026-02-25T19:23:09.755507" + }, + "test_user_001": { + "customer_id": "test_user_001", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:提取复古花砖餐垫印花,去除实拍背景与杂物,保留色彩细节|ratio:1:1|perspective:mild|proc_type:印花提取|subject:花砖印花餐垫|quality:截图", + "complexity:complex|prompt:提取复古花砖印花图案,去除桌面杂物背景,保留色彩细节,输出平面印花图|ratio:1:1|perspective:mild|proc_type:印花提取|subject:复古花砖印花图案|quality:截图", + "complexity:complex|prompt:提取该复古花砖印花,去除桌面背景杂物,保留色彩细节,输出平面印花图|ratio:1:1|perspective:mild|proc_type:印花提取|subject:复古花砖印花图案|quality:截图", + "complexity:complex|prompt:提取复古花砖印花,去除桌面背景,保留色彩细节,输出干净平面印花图|ratio:1:1|perspective:mild|proc_type:印花提取|subject:花砖印花图案|quality:截图" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "", + "last_image_time": "", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "", + "last_conversation_time": "", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-25T19:13:22.533990", + "last_update": "2026-02-25T19:13:22.533990" + }, + "zn19900727": { + "customer_id": "zn19900727", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家未存原图客服索要现图", + "last_conversation_time": "2026-02-25T19:33:46.463407", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [ + "人物" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-25T19:33:41.781455", + "last_update": "2026-02-25T19:33:46.463407" + }, + "天天爱买耶": { + "customer_id": "天天爱买耶", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:normal|prompt:去除顶部白边,优化色彩过渡,保留原图风景细节|ratio:9:16|perspective:strong|proc_type:去背景|subject:渐变山峦风景|quality:清晰", + "complexity:normal|prompt:提取该抽象山脉印花,保留色彩纹理,去除多余白边|ratio:9:16|perspective:mild|proc_type:印花提取|subject:印花图案|quality:清晰" + ], + "preference_services": [], + "total_orders": 1, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "2026-02-26T10:53:49.096624", + "first_order_date": "2026-02-26T10:53:49.096624", + "order_ids": [ + "3262728216497229278" + ], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "D", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "https://img.alicdn.com/imgextra/i2/2782227892/O1CN01EzwbG528AasLEt7wU_!!2782227892-2-ampmedia.png", + "last_image_time": "2026-02-25T22:03:25.276837", + "last_gemini_prompt": "提取该抽象山脉印花,保留色彩纹理,去除多余白边", + "last_aspect_ratio": "9:16", + "last_perspective": "mild", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "png", + "preferred_size": "", + "last_conversation_summary": "买家下单修图,客服索要图片。", + "last_conversation_time": "2026-02-26T10:54:03.053690", + "total_images_sent": 2, + "complexity_history": [ + "normal", + "normal" + ], + "image_type_history": [ + "渐变山峦风景", + "印花图案", + "产品" + ], + "price_sensitivity": "低", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T10:57:13.391540", + "last_update": "2026-02-26T10:57:13.391540" + }, + "levey萤火": { + "customer_id": "levey萤火", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:normal|prompt:去除电商页面多余元素,保留鼠标垫产品展示效果,优化画面清晰度|ratio:9:16|perspective:mild|proc_type:其他|subject:宝可梦主题RGB电竞鼠标垫|quality:截图" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 20, + "last_price_time": "2026-02-25T23:19:38.626636", + "last_image_url": "https://img.alicdn.com/imgextra/i1/O1CN01yZBLzz2EtpgvnvipX_!!4611686018427382211-0-amp.jpg", + "last_image_time": "2026-02-25T23:18:40.994670", + "last_gemini_prompt": "去除电商页面多余元素,保留鼠标垫产品展示效果,优化画面清晰度", + "last_aspect_ratio": "9:16", + "last_perspective": "mild", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家嫌贵,客服降价促成交", + "last_conversation_time": "2026-02-25T23:22:02.889483", + "total_images_sent": 1, + "complexity_history": [ + "normal" + ], + "image_type_history": [ + "宝可梦主题RGB电竞鼠标垫" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-25 23:18] 报价 15元(单图处理)", + "[2026-02-25 23:19] 报价 20元(单图处理+5k尺寸调整)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-25T23:21:48.843046", + "last_update": "2026-02-25T23:22:02.889483" + }, + "江石之恋": { + "customer_id": "江石之恋", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:保留雕花画框和油画细节,保持复古质感,输出高清成品|ratio:1:1|proc_type:其他|subject:带雕花画框的静物油画|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-26T00:16:41.942275", + "last_image_url": "https://img.alicdn.com/imgextra/i1/O1CN01j95n1g1VaSDwO3cVH_!!4611686018427379981-0-amp.jpg", + "last_image_time": "2026-02-26T00:16:36.045639", + "last_gemini_prompt": "保留雕花画框和油画细节,保持复古质感,输出高清成品", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家发图,客服称已完成报价记录", + "last_conversation_time": "2026-02-26T00:16:49.486759", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "带雕花画框的静物油画" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-26 00:16] 报价 25元(单图处理)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T00:16:47.467194", + "last_update": "2026-02-26T00:16:49.486759" + }, + "tankgoing": { + "customer_id": "tankgoing", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:normal|prompt:优化壁纸细节,保留手机界面与外观,清理背景杂物,去除抖音水印|ratio:2:3|proc_type:其他|subject:iPhone手机及主屏界面|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "https://img.alicdn.com/imgextra/i1/O1CN01GA4it822Lgm9GLRn2_!!4611686018427380640-0-amp.jpg", + "last_image_time": "2026-02-26T09:52:21.952226", + "last_gemini_prompt": "优化壁纸细节,保留手机界面与外观,清理背景杂物,去除抖音水印", + "last_aspect_ratio": "2:3", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家问可否擦边,客服要看图", + "last_conversation_time": "2026-02-26T09:51:56.148574", + "total_images_sent": 1, + "complexity_history": [ + "normal" + ], + "image_type_history": [ + "iPhone手机及主屏界面" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T09:52:21.957434", + "last_update": "2026-02-26T09:52:21.957434" + }, + "tb427924597502": { + "customer_id": "tb427924597502", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家要求重发,客服同意重发", + "last_conversation_time": "2026-02-26T10:45:38.499947", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T10:45:28.253646", + "last_update": "2026-02-26T10:45:38.499947" + }, + "楚茜tracy": { + "customer_id": "楚茜tracy", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:增强画面细节,保留赛博光影和人物造型,优化光效发丝质感|ratio:9:16|perspective:mild|proc_type:人像修复|subject:动漫女性角色|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 20, + "last_price_time": "2026-02-26T10:55:52.191700", + "last_image_url": "https://img.alicdn.com/imgextra/i2/O1CN01j1qr9P22z4pz7tup7_!!4611686018427382278-0-amp.jpg", + "last_image_time": "2026-02-26T10:51:52.323288", + "last_gemini_prompt": "增强画面细节,保留赛博光影和人物造型,优化光效发丝质感", + "last_aspect_ratio": "9:16", + "last_perspective": "mild", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 1, + "lowest_price_accepted": 20, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "客服称价格已是最低价", + "last_conversation_time": "2026-02-26T10:58:21.994304", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "动漫女性角色", + "人物" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-26 10:52] 报价 25元(单张动漫壁纸高清修复)", + "[2026-02-26 10:55] 报价 20元(单张动漫壁纸高清修复(优惠后))" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T10:58:09.418707", + "last_update": "2026-02-26T10:58:21.994304" + }, + "shanshan_5200": { + "customer_id": "shanshan_5200", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:保留展板黑金质感与文字,去除舞台场景,导出高清平面图|ratio:16:9|perspective:strong|proc_type:其他|subject:活动宣传展板|quality:清晰" + ], + "preference_services": [], + "total_orders": 1, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "2026-02-26T11:01:55.091860", + "first_order_date": "2026-02-26T11:01:55.091860", + "order_ids": [ + "3262607077713177965" + ], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "D", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "https://img.alicdn.com/imgextra/i1/O1CN01QHZb0t1XYjyQZARMD_!!4611686018427382648-0-amp.jpg", + "last_image_time": "2026-02-26T11:00:47.165693", + "last_gemini_prompt": "保留展板黑金质感与文字,去除舞台场景,导出高清平面图", + "last_aspect_ratio": "16:9", + "last_perspective": "strong", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家下单,客服回应马上安排", + "last_conversation_time": "2026-02-26T11:02:10.144435", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "活动宣传展板", + "产品" + ], + "price_sensitivity": "低", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T11:01:58.774826", + "last_update": "2026-02-26T11:02:11.054909" + }, + "绯殇枫焱": { + "customer_id": "绯殇枫焱", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "", + "last_conversation_time": "", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T11:52:59.407592", + "last_update": "2026-02-26T11:52:59.410191" + }, + "kai19960222": { + "customer_id": "kai19960222", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家询问进度,客服回复快好了", + "last_conversation_time": "2026-02-26T13:06:55.712730", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T13:06:51.839306", + "last_update": "2026-02-26T13:06:55.712730" + }, + "ghcnwei": { + "customer_id": "ghcnwei", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:修复该漫画截图,保留所有文字与人物细节,去除界面冗余元素|ratio:9:16|proc_type:高清修复|subject:鬼灭之刃同人漫画|quality:截图", + "complexity:complex|prompt:该图片含违规内容,无法处理|ratio:16:9|proc_type:其他|subject:同人漫画截图|quality:截图" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-26T13:08:15.800720", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i3/O1CN01qskN6l2N9Q65ugJVk_!!4611686018427384960-2-amp.png", + "last_image_time": "2026-02-26T13:09:15.701068", + "last_gemini_prompt": "该图片含违规内容,无法处理", + "last_aspect_ratio": "16:9", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "png", + "preferred_size": "", + "last_conversation_summary": "买家致谢,客服友好回应。", + "last_conversation_time": "2026-02-26T20:19:16.252161", + "total_images_sent": 2, + "complexity_history": [ + "complex", + "complex" + ], + "image_type_history": [ + "鬼灭之刃同人漫画", + "同人漫画截图" + ], + "price_sensitivity": "", + "decision_speed": "快", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-26 13:08] 报价 25元(单图高清修复)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T20:19:05.895030", + "last_update": "2026-02-26T20:19:16.252161" + }, + "tb305370_55": { + "customer_id": "tb305370_55", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:normal|prompt:高清修复该邮件截图,保留所有文字,去除拍摄阴影与透视,优化清晰度|ratio:3:4|perspective:mild|proc_type:高清修复|subject:谷歌家人群组通知邮件截图|quality:轻微模糊" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 50, + "last_price_time": "2026-02-26T14:46:10.292771", + "last_image_url": "https://img.alicdn.com/imgextra/i3/O1CN01eR7msr1HpCLyqaTH7_!!4611686018427386614-0-amp.jpg", + "last_image_time": "2026-02-26T14:44:19.334519", + "last_gemini_prompt": "高清修复该邮件截图,保留所有文字,去除拍摄阴影与透视,优化清晰度", + "last_aspect_ratio": "3:4", + "last_perspective": "mild", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "客服告知买家pro权限到期需续费", + "last_conversation_time": "2026-02-26T14:46:24.915898", + "total_images_sent": 1, + "complexity_history": [ + "normal" + ], + "image_type_history": [ + "产品", + "谷歌家人群组通知邮件截图" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-26 14:44] 报价 15元(单图高清修复)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T14:46:10.300326", + "last_update": "2026-02-26T14:46:24.915898" + }, + "黄梅榕98": { + "customer_id": "黄梅榕98", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:提取该流水生财山水印花,保留文字细节,去除背景,输出干净平面图|ratio:16:9|perspective:mild|proc_type:印花提取|subject:流水生财山水印花|quality:清晰", + "complexity:complex|prompt:提取该家和福字装饰图案,去除背景与支架,保留所有细节|ratio:3:4|perspective:mild|proc_type:印花提取|subject:家和福字装饰图案|quality:清晰", + "complexity:complex|prompt:提取该家和福字装饰印花,保留所有文字和花卉细节,去除背景和屏风框架|ratio:9:16|perspective:mild|proc_type:印花提取|subject:印花图案|quality:清晰" + ], + "preference_services": [], + "total_orders": 1, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "2026-02-26T19:19:37.683510", + "first_order_date": "2026-02-26T19:19:37.683510", + "order_ids": [ + "5085769644678975532" + ], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "D", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-26T19:16:17.105069", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i1/O1CN01Wps0F21ZuqJp2lxqd_!!4611686018427385927-2-amp.png", + "last_image_time": "2026-02-26T19:16:13.969512", + "last_gemini_prompt": "提取该家和福字装饰印花,保留所有文字和花卉细节,去除背景和屏风框架", + "last_aspect_ratio": "9:16", + "last_perspective": "mild", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "png", + "preferred_size": "", + "last_conversation_summary": "买家已付款,客服将尽快处理发出", + "last_conversation_time": "2026-02-26T19:20:02.811961", + "total_images_sent": 3, + "complexity_history": [ + "complex", + "complex", + "complex" + ], + "image_type_history": [ + "流水生财山水印花", + "家和福字装饰图案", + "印花图案", + "产品" + ], + "price_sensitivity": "低", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-26 14:57] 报价 25元(单图山水印花提取)", + "[2026-02-26 18:50] 报价 25元(单图印花提取)", + "[2026-02-26 19:16] 报价 25元(单图印花提取)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T19:22:59.025828", + "last_update": "2026-02-26T19:22:59.025828" + }, + "xiaobin8703": { + "customer_id": "xiaobin8703", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:去除外围浅蓝色边框,修复画面细节,保留油画色彩与肌理|ratio:1:1|proc_type:高清修复|subject:抽象花卉油画图案|quality:轻微模糊" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i1/O1CN01Pdr6QW1KK0Dw8DHu4_!!4611686018427380856-0-amp.jpg", + "last_image_time": "2026-02-26T15:35:05.232933", + "last_gemini_prompt": "去除外围浅蓝色边框,修复画面细节,保留油画色彩与肌理", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家询居中图,客服报价拍下发", + "last_conversation_time": "2026-02-26T15:35:14.647440", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "抽象花卉油画图案" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T15:35:06.858823", + "last_update": "2026-02-26T15:35:14.647440" + }, + "tb5438719_2012": { + "customer_id": "tb5438719_2012", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家询一元修图客服称最低十元", + "last_conversation_time": "2026-02-26T17:29:02.489193", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T17:28:50.811768", + "last_update": "2026-02-26T17:29:02.489193" + }, + "珍惜我们的时候": { + "customer_id": "珍惜我们的时候", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家反馈未发货,客服回应马上发。", + "last_conversation_time": "2026-02-26T18:03:59.162058", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T18:03:41.433262", + "last_update": "2026-02-26T18:03:59.162058" + }, + "tb123201563": { + "customer_id": "tb123201563", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 1, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "2026-02-26T18:41:08.073526", + "first_order_date": "2026-02-26T18:41:08.073526", + "order_ids": [ + "5085473654517257211" + ], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "D", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家说好了,客服说稍等查看", + "last_conversation_time": "2026-02-26T18:41:35.391391", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [ + "产品" + ], + "price_sensitivity": "低", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T18:41:28.652682", + "last_update": "2026-02-26T18:41:35.391391" + }, + "cyc5511": { + "customer_id": "cyc5511", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:normal|prompt:提取该水墨山水壁画图案,去除楼梯背景,保留细节|ratio:4:3|perspective:mild|proc_type:印花提取|subject:水墨山水风景图案|quality:清晰", + "complexity:complex|prompt:去除楼梯多余墙面,保留手绘壁画并还原细节|ratio:2:3|perspective:strong|proc_type:去背景|subject:江南水乡手绘壁画与楼梯|quality:清晰" + ], + "preference_services": [], + "total_orders": 1, + "total_spent": 35.0, + "avg_order_value": 35.0, + "purchase_frequency": "", + "last_order_date": "2026-02-26T19:02:19.935201", + "first_order_date": "2026-02-26T19:02:19.935201", + "order_ids": [ + "3263881152997215964" + ], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i2/O1CN01MhARHr1xaHJVoJvjS_!!4611686018427387275-0-amp.jpg", + "last_image_time": "2026-02-26T19:01:12.933480", + "last_gemini_prompt": "去除楼梯多余墙面,保留手绘壁画并还原细节", + "last_aspect_ratio": "2:3", + "last_perspective": "strong", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "客服索要买家邮箱以便发原图", + "last_conversation_time": "2026-02-26T19:15:41.165568", + "total_images_sent": 2, + "complexity_history": [ + "normal", + "complex" + ], + "image_type_history": [ + "水墨山水风景图案", + "江南水乡手绘壁画与楼梯", + "产品" + ], + "price_sensitivity": "低", + "decision_speed": "", + "revision_count": 1, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-26T19:15:33.879387", + "last_update": "2026-02-26T19:15:41.165568" + }, + "lian2099": { + "customer_id": "lian2099", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:保留文字与卡通元素,去除现场杂物和阴影,还原平整清晰背景|ratio:3:4|perspective:mild|proc_type:高清修复|subject:百天宴装饰背景|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-27T10:22:33.367453", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i1/53824251/O1CN01uUm2BD1hH0ng0EZT8_!!53824251-0-ampmedia.jpg", + "last_image_time": "2026-02-27T10:22:28.641691", + "last_gemini_prompt": "保留文字与卡通元素,去除现场杂物和阴影,还原平整清晰背景", + "last_aspect_ratio": "3:4", + "last_perspective": "mild", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家发商品图,客服报价让拍下", + "last_conversation_time": "2026-02-27T10:22:41.391517", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "百天宴装饰背景" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 10:22] 报价 25元(单图高清修复)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T10:22:35.798105", + "last_update": "2026-02-27T10:22:41.391517" + }, + "tb33332993": { + "customer_id": "tb33332993", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:去除画框与背景阴影,提取画芯,保留题款细节,高清修复画面|ratio:16:9|proc_type:去背景|subject:青绿山水风景画|quality:清晰", + "complexity:complex|prompt:高清修复这幅山水风景画,保留题款与画面细节|ratio:16:9|proc_type:高清修复|subject:风景山水画|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 40, + "last_price_time": "2026-02-27T10:26:52.452593", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i4/3976129458/O1CN01VHBbWA2Jjp7BP5BAi_!!3976129458-2-ampmedia.png", + "last_image_time": "2026-02-27T10:26:41.971113", + "last_gemini_prompt": "高清修复这幅山水风景画,保留题款与画面细节", + "last_aspect_ratio": "16:9", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "png", + "preferred_size": "", + "last_conversation_summary": "买家发图客服称等待客户回应", + "last_conversation_time": "2026-02-27T10:27:05.712050", + "total_images_sent": 2, + "complexity_history": [ + "complex", + "complex" + ], + "image_type_history": [ + "青绿山水风景画", + "风景山水画" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 10:26] 报价 25元(单图处理(青绿山水风景画))", + "[2026-02-27 10:26] 报价 40元(两张风景山水画打包处理)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T10:26:54.729377", + "last_update": "2026-02-27T10:27:05.712050" + }, + "世界因你们美丽": { + "customer_id": "世界因你们美丽", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:normal|prompt:去除背景,保留装饰马全部细节,还原色彩,输出清晰平面图案|ratio:1:1|proc_type:印花提取|subject:装饰马印花图案|quality:清晰" + ], + "preference_services": [], + "total_orders": 1, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "2026-02-27T10:37:34.509280", + "first_order_date": "2026-02-27T10:37:34.509280", + "order_ids": [ + "3264150326359502463" + ], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "D", + "vip": false, + "vip_level": 0, + "last_price": 15, + "last_price_time": "2026-02-27T10:33:24.939223", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i3/O1CN01wrBw2I1waRqQWBGoZ_!!4611686018427386132-0-amp.jpg", + "last_image_time": "2026-02-27T10:33:19.700846", + "last_gemini_prompt": "去除背景,保留装饰马全部细节,还原色彩,输出清晰平面图案", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家下单找图,客服回应接单", + "last_conversation_time": "2026-02-27T10:37:47.753454", + "total_images_sent": 1, + "complexity_history": [ + "normal" + ], + "image_type_history": [ + "装饰马印花图案", + "老照片", + "产品" + ], + "price_sensitivity": "低", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 10:33] 报价 15元(单图装饰印花提取处理)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T10:37:39.163915", + "last_update": "2026-02-27T10:37:53.522807" + }, + "为何你总是笑个不够": { + "customer_id": "为何你总是笑个不够", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:修复该红旗渠国画,保留山水细节,去除多余水印,优化明暗阴影|ratio:4:3|perspective:strong|proc_type:高清修复|subject:风景|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-27T11:14:17.293058", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i2/O1CN01g9PheP2KMINsOwlxR_!!4611686018427387302-2-amp.png", + "last_image_time": "2026-02-27T11:14:09.576986", + "last_gemini_prompt": "修复该红旗渠国画,保留山水细节,去除多余水印,优化明暗阴影", + "last_aspect_ratio": "4:3", + "last_perspective": "strong", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "png", + "preferred_size": "", + "last_conversation_summary": "买家不用高清客服找不了出版信息", + "last_conversation_time": "2026-02-27T11:15:36.544687", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "风景" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 11:14] 报价 25元(单图风景高清修复)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T11:15:13.138660", + "last_update": "2026-02-27T11:15:36.544687" + }, + "tb01593913": { + "customer_id": "tb01593913", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:normal|prompt:去除墙面和画框背景,保留天鹅樱花湖风景细节,提升画面清晰度|ratio:16:9|proc_type:去背景|subject:风景|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 10, + "last_price_time": "2026-02-27T11:12:52.633216", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i4/O1CN01ZhwGSo1YN7XbZ4WQv_!!4611686018427382534-0-amp.jpg", + "last_image_time": "2026-02-27T11:11:24.779589", + "last_gemini_prompt": "去除墙面和画框背景,保留天鹅樱花湖风景细节,提升画面清晰度", + "last_aspect_ratio": "16:9", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家称没链接,客服让直接下单。", + "last_conversation_time": "2026-02-27T11:22:16.857125", + "total_images_sent": 1, + "complexity_history": [ + "normal" + ], + "image_type_history": [ + "风景" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 11:11] 报价 15元(单图处理)", + "[2026-02-27 11:12] 报价 10元(图一天鹅移到图二替换原有天鹅)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T11:23:01.046250", + "last_update": "2026-02-27T11:23:01.046250" + }, + "crab314": { + "customer_id": "crab314", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:提取蒲公英印花图案,去除背景场景,保留细节输出干净平面图|ratio:9:16|perspective:mild|proc_type:印花提取|subject:蒲公英印花图案|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i4/O1CN017eWihn2B5xiYrsPF1_!!4611686018427382192-0-amp.jpg#*#https://img.alicdn.com/imgextra/i4/O1CN01MS2Hw72B5xiaN1DjI_!!4611686018427382192-0-amp.jpg#*#https://img.alicdn.com/imgextra/i1/O1CN01o9Shyi2B5xiZ2sDLE_!!4611686018427382192-0-amp.jpg#*#https://img.alicdn.com/imgextra/i2/O1CN01XxUFtU2B5xiYfXBZ4_!!4611686018427382192-0-amp.jpg#*#https://img.alicdn.com/imgextra/i4/O1CN01IZsQWa2B5xiZDM0mG_!!4611686018427382192-0-amp.jpg#*#https://img.alicdn.com/imgextra/i3/O1CN01VZNSCU2B5xiZ5e1u4_!!4611686018427382192-0-amp.jpg#*#https://img.alicdn.com/imgextra/i1/O1CN01kv9Cx32B5xiZ2t5Lv_!!4611686018427382192-0-amp.jpg#*#https://img.alicdn.com/imgextra/i4/O1CN01eBaxY82B5xiZXUXWy_!!4611686018427382192-0-amp.jpg", + "last_image_time": "2026-02-27T11:26:33.081935", + "last_gemini_prompt": "提取蒲公英印花图案,去除背景场景,保留细节输出干净平面图", + "last_aspect_ratio": "9:16", + "last_perspective": "mild", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家询问找图价格客服看图定价", + "last_conversation_time": "2026-02-27T11:25:26.889758", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "蒲公英印花图案" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T11:26:33.092837", + "last_update": "2026-02-27T11:26:33.092837" + }, + "王2474313415": { + "customer_id": "王2474313415", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家问价,客服要求发图", + "last_conversation_time": "2026-02-27T12:32:29.487749", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T12:32:25.029569", + "last_update": "2026-02-27T12:32:29.487749" + }, + "张欣然2009": { + "customer_id": "张欣然2009", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:提取该中式地毯印花,去除地面背景,保留所有图案细节还原真实色彩|ratio:16:9|perspective:strong|proc_type:印花提取|subject:中式印花地毯图案|quality:清晰", + "complexity:complex|prompt:高清修复该截图,保留地毯花纹细节,优化阴影,去除多余界面元素|ratio:9:16|perspective:strong|proc_type:高清修复|subject:民族花纹装饰地毯、短视频界面|quality:截图", + "complexity:complex|prompt:提取该草原风景挂毯图案,去除水印和界面文字,保留色彩细节|ratio:16:9|perspective:mild|proc_type:图案提取|subject:风景挂毯图案|quality:截图", + "complexity:complex|prompt:提取该草原牧马挂毯印花,去除地板背景,保留细节输出平面图|ratio:16:9|proc_type:印花提取|subject:挂毯印花图案|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-27T12:57:41.812629", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i1/O1CN01F8MdIA2MyszInS0wV_!!4611686018427384345-0-amp.jpg", + "last_image_time": "2026-02-27T12:57:37.634954", + "last_gemini_prompt": "提取该草原牧马挂毯印花,去除地板背景,保留细节输出平面图", + "last_aspect_ratio": "16:9", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "尺寸1.6×3米", + "last_conversation_summary": "买家询问付款,客服指引操作。", + "last_conversation_time": "2026-02-27T12:58:34.831864", + "total_images_sent": 4, + "complexity_history": [ + "complex", + "complex", + "complex", + "complex" + ], + "image_type_history": [ + "中式印花地毯图案", + "民族花纹装饰地毯、短视频界面", + "风景挂毯图案", + "挂毯印花图案" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 12:40] 报价 60元(三张印花细节图打包处理)", + "[2026-02-27 12:40] 报价 40元(两张截图高清修复打包)", + "[2026-02-27 12:56] 报价 25元(单图处理,改尺寸1.6×3米)", + "[2026-02-27 12:57] 报价 25元(第二张单图处理,印花提取)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T12:58:26.122002", + "last_update": "2026-02-27T12:58:34.831864" + }, + "xx友邦xx": { + "customer_id": "xx友邦xx", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:normal|prompt:高清修复该国风茶饮菜单,保留全部文字和装饰,保持原有风格|ratio:4:3|proc_type:高清修复|subject:国风茶饮饮品菜单海报|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 15, + "last_price_time": "2026-02-27T12:44:46.968099", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i4/2238780260/O1CN01sxtBnY1Dn86Mxt4tJ_!!2238780260-2-ampmedia.png", + "last_image_time": "2026-02-27T12:44:39.359110", + "last_gemini_prompt": "高清修复该国风茶饮菜单,保留全部文字和装饰,保持原有风格", + "last_aspect_ratio": "4:3", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "png", + "preferred_size": "", + "last_conversation_summary": "买家询问,客服承诺不满意可退", + "last_conversation_time": "2026-02-27T12:47:07.603409", + "total_images_sent": 1, + "complexity_history": [ + "normal" + ], + "image_type_history": [ + "国风茶饮饮品菜单海报" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 12:44] 报价 15元(单图高清修复)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T12:46:58.406933", + "last_update": "2026-02-27T12:47:07.603409" + }, + "t_1480389856809_0613": { + "customer_id": "t_1480389856809_0613", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家应允后客服请其稍等", + "last_conversation_time": "2026-02-27T13:01:10.377629", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T13:01:04.464866", + "last_update": "2026-02-27T13:01:10.377629" + }, + "轩5235": { + "customer_id": "轩5235", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:修复该调理海报,清晰化文字,保留配图,去除手机界面元素|ratio:9:16|proc_type:高清修复|subject:颈肩腰腿疼调理宣传海报|quality:截图" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-27T13:44:37.305553", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i2/O1CN01VHbDsZ1KfzEYTBESf_!!4611686018427380792-0-amp.jpg", + "last_image_time": "2026-02-27T13:44:24.551737", + "last_gemini_prompt": "修复该调理海报,清晰化文字,保留配图,去除手机界面元素", + "last_aspect_ratio": "9:16", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "客服确认商品图告知拍下价格", + "last_conversation_time": "2026-02-27T13:44:50.013434", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "颈肩腰腿疼调理宣传海报" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 13:44] 报价 25元(单图高清修复(含人脸,细节偏多))" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T13:44:42.912813", + "last_update": "2026-02-27T13:44:50.013434" + }, + "tb941834206": { + "customer_id": "tb941834206", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "", + "last_image_time": "", + "last_gemini_prompt": "", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "", + "preferred_size": "", + "last_conversation_summary": "买家应允后客服称马上办好", + "last_conversation_time": "2026-02-27T13:49:21.903069", + "total_images_sent": 0, + "complexity_history": [], + "image_type_history": [], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T13:49:13.035572", + "last_update": "2026-02-27T13:49:21.903069" + }, + "姜汶丰233": { + "customer_id": "姜汶丰233", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:提取该复古花卉印花,保留色彩细节,去除背景,输出干净平面图|ratio:1:1|proc_type:印花提取|subject:印花图案|quality:轻微模糊", + "complexity:complex|prompt:提取花鸟植物印花,去除背景,保留色彩细节,输出干净平面印花图|ratio:1:1|proc_type:印花提取|subject:印花图案|quality:轻微模糊" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 0, + "last_price_time": "", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i4/2949310257/O1CN018tGeFP1DlkuhIoyk7_!!2949310257-0-ampmedia.jpg", + "last_image_time": "2026-02-27T13:50:51.301023", + "last_gemini_prompt": "提取花鸟植物印花,去除背景,保留色彩细节,输出干净平面印花图", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家发图询价,客服报价回应", + "last_conversation_time": "2026-02-27T13:50:46.999728", + "total_images_sent": 2, + "complexity_history": [ + "complex", + "complex" + ], + "image_type_history": [ + "印花图案", + "印花图案" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T13:50:58.686838", + "last_update": "2026-02-27T13:50:58.706577" + }, + "栀夏明月2280": { + "customer_id": "栀夏明月2280", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:normal|prompt:保留原图宣传标语与插画,去除边缘黑框,优化画面效果|ratio:16:9|proc_type:其他|subject:宣传标语与休闲插画|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 15, + "last_price_time": "2026-02-27T14:37:08.910680", + "last_quote_no_convert": false, + "last_image_url": "https://img.alicdn.com/imgextra/i2/O1CN01WKkvzF2CSgzMRDlow_!!4611686018427381385-0-amp.jpg", + "last_image_time": "2026-02-27T14:36:56.631943", + "last_gemini_prompt": "保留原图宣传标语与插画,去除边缘黑框,优化画面效果", + "last_aspect_ratio": "16:9", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家无需文字,客服同意安排。", + "last_conversation_time": "2026-02-27T14:38:03.403170", + "total_images_sent": 1, + "complexity_history": [ + "normal" + ], + "image_type_history": [ + "宣传标语与休闲插画" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 14:37] 报价 15元(单图处理)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T14:37:56.489193", + "last_update": "2026-02-27T14:38:03.403170" + }, + "tb405958516": { + "customer_id": "tb405958516", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:提取该柴犬向日葵洞洞板印花,保留细节,去除背景杂物,还原清晰图案|ratio:3:4|perspective:mild|proc_type:印花提取|subject:柴犬向日葵洞洞板装饰印花|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-27T15:20:11.942171", + "last_quote_no_convert": false, + "last_min_price": 20, + "last_image_url": "https://img.alicdn.com/imgextra/i2/O1CN01iJ7h0K26nrbVUHLL7_!!4611686018427382091-0-amp.jpg", + "last_image_time": "2026-02-27T15:20:07.903491", + "last_gemini_prompt": "提取该柴犬向日葵洞洞板印花,保留细节,去除背景杂物,还原清晰图案", + "last_aspect_ratio": "3:4", + "last_perspective": "mild", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "jpg", + "preferred_size": "", + "last_conversation_summary": "买家要求正常比例客服回应安排", + "last_conversation_time": "2026-02-27T15:20:24.202009", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "柴犬向日葵洞洞板装饰印花" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 15:20] 报价 25元(单图印花提取)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T15:20:17.052679", + "last_update": "2026-02-27T15:20:24.202009" + }, + "tb01680130": { + "customer_id": "tb01680130", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:修复该板面广告海报,保留所有文字画面,去除墙面射灯背景|ratio:16:9|proc_type:其他|subject:牛肉板面宣传广告海报|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 25, + "last_price_time": "2026-02-27T15:27:38.577642", + "last_quote_no_convert": false, + "last_min_price": 20, + "last_image_url": "https://img.alicdn.com/imgextra/i2/O1CN01Z1AKVu1OBXMlpYf1r_!!4611686018427382051-2-amp.png", + "last_image_time": "2026-02-27T15:27:32.683507", + "last_gemini_prompt": "修复该板面广告海报,保留所有文字画面,去除墙面射灯背景", + "last_aspect_ratio": "16:9", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "png", + "preferred_size": "", + "last_conversation_summary": "买家发图询价,客服报价待拍", + "last_conversation_time": "2026-02-27T15:27:55.838970", + "total_images_sent": 1, + "complexity_history": [ + "complex" + ], + "image_type_history": [ + "牛肉板面宣传广告海报" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 15:27] 报价 25元(单图处理)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T15:27:45.569863", + "last_update": "2026-02-27T15:27:55.838970" + }, + "菠罗菠罗蜜20143": { + "customer_id": "菠罗菠罗蜜20143", + "name": "", + "nickname": "", + "email": "", + "phone": "", + "wechat": "", + "address": "", + "platform": "", + "platform_id": "", + "budget": "", + "budget_range_min": 0, + "budget_range_max": 0, + "requirements": [ + "complexity:complex|prompt:提取青花印花图案,去除背景与阴影,保留图案原有细节|ratio:1:1|proc_type:印花提取|subject:印花图案|quality:轻微模糊", + "complexity:complex|prompt:提取该孔雀花卉印花,去除背景、水印和阴影,保留细节还原色彩|ratio:1:1|proc_type:印花提取|subject:印花图案|quality:清晰" + ], + "preference_services": [], + "total_orders": 0, + "total_spent": 0.0, + "avg_order_value": 0.0, + "purchase_frequency": "", + "last_order_date": "", + "first_order_date": "", + "order_ids": [], + "pending_orders": 0, + "completed_orders": 0, + "refund_count": 0, + "personality": [], + "communication_prefer": "", + "response_speed": "", + "patience_level": "", + "customer_level": "C", + "vip": false, + "vip_level": 0, + "last_price": 35, + "last_price_time": "2026-02-27T15:49:26.196425", + "last_quote_no_convert": true, + "last_min_price": 20, + "last_image_url": "https://img.alicdn.com/imgextra/i1/1975036200/O1CN01MPhy7n1vfejU00FTF_!!1975036200-2-ampmedia.png", + "last_image_time": "2026-02-27T15:49:14.744270", + "last_gemini_prompt": "提取该孔雀花卉印花,去除背景、水印和阴影,保留细节还原色彩", + "last_aspect_ratio": "1:1", + "last_perspective": "no", + "processing_status": "", + "processing_image_url": "", + "expected_done_at": "", + "discount_given_count": 0, + "lowest_price_accepted": 0, + "preferred_format": "png", + "preferred_size": "分辨率高于300,? ? ? ?300mm", + "last_conversation_summary": "买家提议加QQ客服同意并索号", + "last_conversation_time": "2026-02-27T15:55:42.265292", + "total_images_sent": 2, + "complexity_history": [ + "complex", + "complex" + ], + "image_type_history": [ + "印花图案", + "印花图案" + ], + "price_sensitivity": "", + "decision_speed": "", + "revision_count": 0, + "revision_orders": 0, + "total_completed_orders": 0, + "bulk_potential": "", + "churn_risk": "低", + "upsell_opportunity": [], + "blacklist": false, + "blacklist_reason": "", + "vip_custom_price": 0, + "last_email_status": "", + "good_reviews": 0, + "bad_reviews": 0, + "dispute_count": 0, + "follow_up_by": "", + "follow_up_date": "", + "next_follow_date": "", + "source": "", + "coupon_used": "", + "notes": [ + "[2026-02-27 15:32] 报价 25元(单图印花提取处理)", + "[2026-02-27 15:49] 报价 35元(两张图印花提取打包)" + ], + "tags": [], + "created_at": "", + "last_contact": "2026-02-27T15:55:22.174045", + "last_update": "2026-02-27T15:55:42.265292" + } +} \ No newline at end of file diff --git a/customer_db/schema.json b/customer_db/schema.json new file mode 100644 index 0000000..ccd4d0f --- /dev/null +++ b/customer_db/schema.json @@ -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": "上次对话时间" + } +} diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db/__pycache__/__init__.cpython-310.pyc b/db/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..fec3c2e Binary files /dev/null and b/db/__pycache__/__init__.cpython-310.pyc differ diff --git a/db/__pycache__/chat_log_db.cpython-310.pyc b/db/__pycache__/chat_log_db.cpython-310.pyc new file mode 100644 index 0000000..6d87751 Binary files /dev/null and b/db/__pycache__/chat_log_db.cpython-310.pyc differ diff --git a/db/__pycache__/customer_db.cpython-310.pyc b/db/__pycache__/customer_db.cpython-310.pyc new file mode 100644 index 0000000..e128049 Binary files /dev/null and b/db/__pycache__/customer_db.cpython-310.pyc differ diff --git a/db/__pycache__/deal_outcome_db.cpython-310.pyc b/db/__pycache__/deal_outcome_db.cpython-310.pyc new file mode 100644 index 0000000..8dd4a6e Binary files /dev/null and b/db/__pycache__/deal_outcome_db.cpython-310.pyc differ diff --git a/db/__pycache__/designer_roster_db.cpython-310.pyc b/db/__pycache__/designer_roster_db.cpython-310.pyc new file mode 100644 index 0000000..795744a Binary files /dev/null and b/db/__pycache__/designer_roster_db.cpython-310.pyc differ diff --git a/db/chat_log_db.py b/db/chat_log_db.py new file mode 100644 index 0000000..b8d9b26 --- /dev/null +++ b/db/chat_log_db.py @@ -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] diff --git a/db/chat_log_db/chats.db b/db/chat_log_db/chats.db new file mode 100644 index 0000000..54f6472 Binary files /dev/null and b/db/chat_log_db/chats.db differ diff --git a/db/customer_db.py b/db/customer_db.py new file mode 100644 index 0000000..6d89c63 --- /dev/null +++ b/db/customer_db.py @@ -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() diff --git a/db/deal_outcome_db.py b/db/deal_outcome_db.py new file mode 100644 index 0000000..0c8a652 --- /dev/null +++ b/db/deal_outcome_db.py @@ -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] diff --git a/db/deal_outcome_db/outcomes.db b/db/deal_outcome_db/outcomes.db new file mode 100644 index 0000000..ee7f52b Binary files /dev/null and b/db/deal_outcome_db/outcomes.db differ diff --git a/db/designer_roster_db.py b/db/designer_roster_db.py new file mode 100644 index 0000000..76004ae --- /dev/null +++ b/db/designer_roster_db.py @@ -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 diff --git a/db/designer_roster_db/roster.db b/db/designer_roster_db/roster.db new file mode 100644 index 0000000..1ab90f0 Binary files /dev/null and b/db/designer_roster_db/roster.db differ diff --git a/image/__init__.py b/image/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/image/__pycache__/__init__.cpython-310.pyc b/image/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..e77975b Binary files /dev/null and b/image/__pycache__/__init__.cpython-310.pyc differ diff --git a/image/__pycache__/image_analyzer.cpython-310.pyc b/image/__pycache__/image_analyzer.cpython-310.pyc new file mode 100644 index 0000000..03e5735 Binary files /dev/null and b/image/__pycache__/image_analyzer.cpython-310.pyc differ diff --git a/image/__pycache__/image_precheck.cpython-310.pyc b/image/__pycache__/image_precheck.cpython-310.pyc new file mode 100644 index 0000000..b212d0e Binary files /dev/null and b/image/__pycache__/image_precheck.cpython-310.pyc differ diff --git a/image/__pycache__/image_processor.cpython-310.pyc b/image/__pycache__/image_processor.cpython-310.pyc new file mode 100644 index 0000000..30bea40 Binary files /dev/null and b/image/__pycache__/image_processor.cpython-310.pyc differ diff --git a/image/__pycache__/image_qa.cpython-310.pyc b/image/__pycache__/image_qa.cpython-310.pyc new file mode 100644 index 0000000..942eac6 Binary files /dev/null and b/image/__pycache__/image_qa.cpython-310.pyc differ diff --git a/image/image_analyzer.py b/image/image_analyzer.py new file mode 100644 index 0000000..291173a --- /dev/null +++ b/image/image_analyzer.py @@ -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 图像生成提示词专家。 +请仔细分析这张图片,输出以下字段,每行一个,不要多余内容: + +敏感内容: +平整度: +含文字: +含人脸: +阴影: +复杂度: +原因: <15字以内,说明复杂度判断依据> +主体: <图片核心内容,如:印花图案/logo/人物/产品/老照片/风景/文字/其他> +类型: <处理类型,如:印花提取/高清修复/去背景/老照片修复/logo提取/人像修复/其他> +质量: <原图质量,如:清晰/轻微模糊/严重模糊/低分辨率/截图/扫描件> +可做: +风险: +透视: +比例: <从以下选一个最合适的:1:1 / 9:16 / 16:9 / 3:4 / 4:3 / 3:2 / 2:3 / 5:4 / 4:5> +提示词: <为 Gemini 写处理指令,中文,60字以内,说明要做什么、保留什么、去掉什么> +备注: <给客服AI的特别提示,没有则填无> + +判断规则: + +【报价核心:越平整越便宜】 +- 平整度 flat:画面平整、无褶皱、无透视 → 便宜 +- 平整度 mild:轻微褶皱/透视 → 中等 +- 平整度 rough:有褶皱/透视/曲面 → 贵 +- 含文字:大字没关系不加价;小字需精细保留/清晰化 → 加价(含文字填 yes 仅指有小字的情况) +- 含人脸 yes:有人脸 → 加价 +- 阴影 yes:有明显阴影需处理 → 加价 +综合以上因素,越平整、无小字、无人脸、无阴影 → 越便宜(simple) + +【含文字】 +- yes:含小字需精细保留/清晰化(小字难处理 → 加价) +- no:无文字,或仅有大字(大字没关系 → 不加价) + +【含人脸】 +- yes:图中有真实人物面孔(人像照/集体照/证件照/老照片等) +- no:无人脸或人脸极小不影响主体 + +【风险评估】 +- none:印花/图案/logo/风景/产品,AI处理效果稳定,可直接报价 +- low:有人脸但清晰度尚可,AI修复后人脸相似度70-90%,建议先看效果 +- high:以下任一情况 → 严重模糊的人脸照片/老照片人像/需要打印/客户问能否找回原图 + high情况下,可做改为partial,备注写明风险话术 + +【敏感内容】优先判断,若为 yes 则 可做 必填 no +- yes:图片含色情/黄色/擦边/裸露/性暗示/大尺度等违规内容 +- no:无上述敏感内容 + +【可做判断】 +- yes:效果有把握,可直接处理 +- partial:能处理但有明显限制(人脸变形风险/分辨率极低/严重损坏) +- no:无法处理(纯黑/纯白/完全损坏/找原始RAW文件/敏感内容) + +【风险话术模板(备注字段)】 +- 含人脸+需打印:AI修复后人脸可能有轻微变化,建议先看效果确认再打印 +- 严重模糊人脸:这张模糊程度较高,修复后清晰了但人脸可能跟原来有差异 +- 找原图:找不到原始文件,只能对现有图片做高清修复处理 +- 完全损坏:这张无法处理 + +【透视判断】 +- no:正面拍摄,无明显变形 +- mild:轻微透视(衣服悬挂/桌面小角度斜拍) +- strong:严重透视(俯拍/贴墙/大角度倾斜) + +【比例选择】 +- 印花/图案/logo/正方形 -> 1:1 +- 竖屏壁纸/短视频封面 -> 9:16 +- 宽屏/横版视频 -> 16:9 +- 移动广告/Instagram竖图 -> 4:5 +- 竖向人像/海报/证件照 -> 3:4 +- 竖向相机照片 -> 2:3 +- 接近正方形产品图 -> 5:4 +- 横向标准图/风景 -> 4:3 +- 横向相机照片/产品实拍 -> 3:2 + +示例1(印花,无风险): +敏感内容: no +平整度: mild +含文字: no +含人脸: no +阴影: no +复杂度: complex +原因: 印花细节密集颜色层次多 +主体: 印花图案 +类型: 印花提取 +质量: 轻微模糊 +可做: yes +风险: none +透视: mild +比例: 1:1 +提示词: 提取衣物印花图案,去除褶皱和背景杂色,补全缺失部分,保持颜色细节100%还原,输出干净平面印花图 +备注: 无 + +示例2(人像老照片,要打印): +敏感内容: no +平整度: flat +含文字: no +含人脸: yes +阴影: no +复杂度: hard +原因: 严重模糊人脸细节丢失 +主体: 人物照片 +类型: 人像修复 +质量: 严重模糊 +可做: partial +风险: high +透视: no +比例: 3:4 +提示词: 对模糊人像进行高清修复,增强细节,保持人物特征不变 +备注: AI修复后人脸可能有轻微变化,建议先看效果确认满意再用于打印 + +示例3(平整印花,最便宜): +敏感内容: no +平整度: flat +含文字: no +含人脸: no +阴影: no +复杂度: simple +原因: 画面平整无褶皱无文字无人脸 +主体: 印花图案 +类型: 印花提取 +质量: 清晰 +可做: yes +风险: none +透视: no +比例: 1:1 +提示词: 提取印花图案,去除背景,输出干净平面图 +备注: 无""" + + +class ImageAnalyzer: + """图片复杂度分析器""" + + # 同一 URL 5 分钟内复用结果,节省 API 调用 + _CACHE_TTL_SECONDS = 300 + _analysis_cache: dict = {} # url -> (result_dict, timestamp) + + PRICE_MAP = { + "simple": (10, 15, "画面简单干净"), + "normal": (15, 20, "一般复杂度"), + "complex": (20, 25, "细节偏多"), + "hard": (25, 30, "非常复杂"), + } + + def __init__(self): + self.api_key = os.getenv("OPENAI_API_KEY") + self.base_url = os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4") + # 视觉模型,智谱 GLM-4V 系列 + self.vision_model = os.getenv("VISION_MODEL", "glm-4v-flash") + + def _is_url(self, image_path: str) -> bool: + return image_path.startswith("http://") or image_path.startswith("https://") + + def _load_image_base64(self, image_path: str) -> Optional[str]: + """本地图片转 base64""" + try: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + except Exception as e: + print(f"[ImageAnalyzer] 读取图片失败: {e}") + return None + + async def _get_image_size(self, image_path: str) -> Tuple[int, int]: + """获取图片像素尺寸 (width, height),URL 或 本地路径""" + try: + if self._is_url(image_path): + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(image_path) as resp: + if resp.status != 200: + return (0, 0) + data = await resp.read() + from io import BytesIO + with Image.open(BytesIO(data)) as img: + w, h = img.size + return (int(w), int(h)) + else: + with Image.open(image_path) as img: + w, h = img.size + return (int(w), int(h)) + except Exception as e: + print(f"[ImageAnalyzer] 获取尺寸失败: {e}") + return (0, 0) + + # 最短等待时间(秒):即使AI极快返回,也等这么久,看起来像真人在找 + MIN_WAIT_SECONDS = 4 + + async def analyze(self, image_path: str) -> dict: + """ + 异步分析图片复杂度(使用火山引擎 /responses 接口)。 + 实际等待时间 = max(视觉AI响应时间, MIN_WAIT_SECONDS) + + Args: + image_path: 图片URL 或 本地路径 + + Returns: + { + "complexity": "simple|normal|complex|hard", + "reason": "原因描述", + "price_min": 最低报价, + "price_max": 最高报价, + "price_suggest": 建议报价, + "elapsed": 实际耗时秒数, + "success": True/False + } + """ + if not self.api_key: + await asyncio.sleep(self.MIN_WAIT_SECONDS) + return self._fallback("未配置 API Key") + + # 缓存:仅对 URL 生效,本地路径不缓存 + cache_key = image_path if self._is_url(image_path) else None + if cache_key: + now = time.monotonic() + cached = self._analysis_cache.get(cache_key) + if cached: + result, cached_at = cached + if now - cached_at < self._CACHE_TTL_SECONDS: + print(f"[ImageAnalyzer] 缓存命中 | URL 已分析过,跳过 API 调用") + result = dict(result) + result["elapsed"] = 0 + return result + else: + del self._analysis_cache[cache_key] + + start = time.monotonic() + + try: + # 构建图片内容 + if self._is_url(image_path): + image_item = { + "type": "input_image", + "image_url": image_path + } + else: + b64 = self._load_image_base64(image_path) + if not b64: + await asyncio.sleep(self.MIN_WAIT_SECONDS) + return self._fallback("图片读取失败") + image_item = { + "type": "input_image", + "image_url": f"data:image/jpeg;base64,{b64}" + } + + # 使用火山引擎官方 SDK(AsyncOpenAI + /responses 接口) + client = AsyncOpenAI( + base_url=self.base_url, + api_key=self.api_key, + ) + + response = await client.responses.create( + model=self.vision_model, + input=[ + { + "role": "user", + "content": [ + image_item, + { + "type": "input_text", + "text": ANALYSIS_PROMPT + } + ] + } + ] + ) + + content = response.output_text + + elapsed = time.monotonic() - start + print(f"[ImageAnalyzer] 视觉AI响应耗时: {elapsed:.1f}s") + + await self._wait_remaining(elapsed) + + result = self._parse_result(content) + result["elapsed"] = elapsed + + # 计算尺寸与类型加价 + try: + w, h = await self._get_image_size(image_path) + mp = round((w * h) / 1_000_000, 2) if w and h else 0.0 + result["width"] = w + result["height"] = h + result["megapixels"] = mp + + # 归一化类型 + subj = (result.get("subject") or "").lower() + ptype = (result.get("proc_type") or "").lower() + ratio = result.get("aspect_ratio") or "1:1" + category = "general" + # 初步判断 + if ("壁纸" in subj) or ("wallpaper" in subj) or ratio in ("9:16", "16:9"): + category = "wallpaper" + elif ("衣" in subj) or ("服" in subj) or ("印花" in subj) or ("fabric" in subj) or ("cloth" in subj) or ("服装" in subj) or ("印花" in ptype): + category = "clothing" + elif ("logo" in subj) or ("logo" in ptype): + category = "logo" + elif ("海报" in subj) or ("poster" in subj): + category = "poster" + elif ("人像" in subj) or ("人物" in subj) or ("portrait" in subj): + category = "portrait" + elif ("产品" in subj) or ("product" in subj): + category = "product" + elif ("老照片" in subj) or ("old photo" in subj): + category = "old_photo" + # 可印花/印刷物体扩展 + keywords = subj + " " + ptype + if any(k in keywords for k in ["装饰画", "挂画", "油画", "canvas", "painting"]): + category = "decor_painting" + elif any(k in keywords for k in ["窗帘", "curtain"]): + category = "curtain" + elif any(k in keywords for k in ["地垫", "脚垫", "地毯", "垫", "mat", "rug"]): + category = "floor_mat" + elif any(k in keywords for k in ["广告牌", "喷绘", "展架", "灯箱", "banner", "billboard"]): + category = "billboard" + elif any(k in keywords for k in ["毯子", "毛毯", "blanket"]): + category = "blanket" + elif any(k in keywords for k in ["桌布", "台布", "tablecloth", "桌旗"]): + category = "tablecloth" + elif any(k in keywords for k in ["书本", "书籍", "封面", "book", "book cover"]): + category = "book" + elif any(k in keywords for k in ["鼠标垫", "mouse pad", "mousepad"]): + category = "mouse_pad" + elif any(k in keywords for k in ["头像", "个人头像", "个人照", "profile", "avatar"]): + category = "avatar" + result["category"] = category + + surcharge = 0 + size_note = "" + # 按类别设定尺寸要求与加价阈值(单位:百万像素) + if category == "wallpaper": + if h and h < 1920: + size_note = "壁纸高度低于1920px,清晰度可能不足" + if mp > 8: + surcharge = 10 + elif mp > 3: + surcharge = 5 + elif category == "clothing": + if (w and w < 1024) or (h and h < 1024): + size_note = "印花源图边长低于1024px,放大后细节可能不足" + if mp > 6: + surcharge = 10 + elif mp > 2: + surcharge = 5 + elif category in ("poster", "portrait", "product"): + if mp > 12: + surcharge = 10 + elif mp > 6: + surcharge = 5 + elif category == "logo": + if mp > 6: + surcharge = 5 + elif category == "decor_painting": + if (w and w < 1500) or (h and h < 1500): + size_note = "装饰画边长低于1500px,打印放大可能不够清晰" + if mp > 12: + surcharge = 10 + elif mp > 6: + surcharge = 5 + elif category == "curtain": + if (w and w < 1500): + size_note = "窗帘宽度低于1500px,印花放大可能不够清晰" + if mp > 16: + surcharge = 10 + elif mp > 8: + surcharge = 5 + elif category == "floor_mat": + if mp > 12: + surcharge = 10 + elif mp > 6: + surcharge = 5 + elif category == "billboard": + if (w and w < 2000) or (h and h < 1000): + size_note = "广告牌尺寸较小,建议更高分辨率以保证喷绘清晰" + if mp > 20: + surcharge = 10 + elif mp > 10: + surcharge = 5 + elif category == "blanket": + if mp > 16: + surcharge = 10 + elif mp > 8: + surcharge = 5 + elif category == "tablecloth": + if mp > 12: + surcharge = 10 + elif mp > 6: + surcharge = 5 + elif category == "book": + if (w and w < 800): + size_note = "书本封面宽度低于800px,印刷细节可能不足" + if mp > 6: + surcharge = 5 + elif category == "mouse_pad": + if (w and w < 1000): + size_note = "鼠标垫源图宽度低于1000px,细节可能不足" + if mp > 4: + surcharge = 5 + elif category == "avatar": + if (w and w < 800) or (h and h < 800): + size_note = "头像边长低于800px,清晰度可能不足" + if mp > 6: + surcharge = 5 + else: + if mp > 8: + surcharge = 10 + elif mp > 4: + surcharge = 5 + + # 应用加价,保持5的整数倍与 10-30 区间 + base = result.get("price_suggest", 20) + adjusted = base + surcharge + adjusted = max(10, min(30, adjusted)) + adjusted = round(adjusted / 5) * 5 + # 同步范围 + result["price_suggest"] = adjusted + result["price_max"] = max(result["price_max"], adjusted) + result["size_surcharge"] = surcharge + result["size_note"] = size_note + except Exception as e: + print(f"[ImageAnalyzer] 尺寸与类型加价计算失败: {e}") + + # 写入缓存 + if cache_key: + self._analysis_cache[cache_key] = (dict(result), time.monotonic()) + # 简单清理:缓存超过 50 条时删最旧的 + if len(self._analysis_cache) > 50: + oldest = min(self._analysis_cache.items(), key=lambda x: x[1][1]) + del self._analysis_cache[oldest[0]] + + return result + + except asyncio.TimeoutError: + elapsed = time.monotonic() - start + print(f"[ImageAnalyzer] 请求超时 ({elapsed:.1f}s)") + return self._fallback("请求超时") + except Exception as e: + elapsed = time.monotonic() - start + print(f"[ImageAnalyzer] 分析失败: {e}") + await self._wait_remaining(elapsed) + return self._fallback(str(e)) + + async def _wait_remaining(self, elapsed: float): + """补足最短等待时间""" + remaining = self.MIN_WAIT_SECONDS - elapsed + if remaining > 0: + await asyncio.sleep(remaining) + + def _parse_line(self, content: str, *keys: str) -> str: + """从多行文本中提取指定字段值,支持中英文冒号""" + for line in content.strip().split("\n"): + line = line.strip() + for key in keys: + if line.startswith(key): + return line.split(":", 1)[-1].split(":", 1)[-1].strip() + return "" + + def _parse_result(self, content: str) -> dict: + """解析模型返回的结果""" + p = self._parse_line + + # 复杂度 + complexity_raw = p(content, "复杂度:", "复杂度:").lower() + complexity = complexity_raw if complexity_raw in self.PRICE_MAP else "normal" + + sensitive = p(content, "敏感内容:", "敏感内容:").lower().strip() + flatness = p(content, "平整度:", "平整度:").lower().strip() # flat|mild|rough + has_text = p(content, "含文字:", "含文字:").lower().strip() + has_face = p(content, "含人脸:", "含人脸:").lower().strip() + has_shadow = p(content, "阴影:", "阴影:").lower().strip() + reason = p(content, "原因:", "原因:") + subject = p(content, "主体:", "主体:") + proc_type = p(content, "类型:", "类型:") + quality = p(content, "质量:", "质量:") + feasibility = p(content, "可做:", "可做:").lower() + risk = p(content, "风险:", "风险:").lower().strip() + perspective = p(content, "透视:", "透视:").lower().strip() + aspect_ratio = p(content, "比例:", "比例:").strip() + gemini_prompt= p(content, "提示词:", "提示词:") + note = p(content, "备注:", "备注:") + + if has_face not in ("yes", "no"): + has_face = "no" + if risk not in ("none", "low", "high"): + risk = "none" + if perspective not in ("no", "mild", "strong"): + perspective = "no" + + # 校验比例合法性 + valid_ratios = {"1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5"} + if aspect_ratio not in valid_ratios: + aspect_ratio = "1:1" # 默认正方形 + + price_min, price_max, default_reason = self.PRICE_MAP[complexity] + if not reason: + reason = default_reason + if feasibility not in ("yes", "partial", "no"): + feasibility = "yes" + + # 建议报价:complex/hard 取固定值,simple/normal 取中间,且必须为5的整数倍 + raw = price_max if complexity in ("complex", "hard") else (price_min + price_max) // 2 + price_suggest = round(raw / 5) * 5 + + if sensitive == "yes": + feasibility = "no" + note = "图片含敏感内容,不接单" + risk_label = {"none": "无风险", "low": "低风险", "high": "高风险"}.get(risk, "") + sens_tag = " | 敏感:是" if sensitive == "yes" else "" + print(f"[ImageAnalyzer] 识别结果: {complexity} | {reason} | 建议报价: {price_suggest}元{sens_tag}") + print(f"[ImageAnalyzer] 主体: {subject} | 类型: {proc_type} | 质量: {quality} | 平整度: {flatness} | 含文字: {has_text} | 含人脸: {has_face} | 阴影: {has_shadow} | 风险: {risk_label} | 透视: {perspective} | 比例: {aspect_ratio} | 可做: {feasibility}") + if gemini_prompt: + print(f"[ImageAnalyzer] Gemini提示词: {gemini_prompt}") + if note and note not in ("无", ""): + print(f"[ImageAnalyzer] 备注: {note}") + + return { + "complexity": complexity, + "reason": reason, + "subject": subject, + "proc_type": proc_type, + "quality": quality, + "flatness": flatness if flatness in ("flat", "mild", "rough") else "", + "has_text": has_text if has_text in ("yes", "no") else "no", + "has_face": has_face, # yes / no + "has_shadow": has_shadow if has_shadow in ("yes", "no") else "no", + "risk": risk, # none / low / high + "feasibility": feasibility, + "perspective": perspective, + "aspect_ratio": aspect_ratio, + "gemini_prompt": gemini_prompt, + "note": note, + "price_min": price_min, + "price_max": price_max, + "price_suggest": price_suggest, + "success": True + } + + def _fallback(self, reason: str) -> dict: + """识别失败时的默认结果(返回 normal,让人工判断)""" + print(f"[ImageAnalyzer] 识别失败,使用默认值: {reason}") + return { + "complexity": "normal", + "reason": reason, + "subject": "", + "proc_type": "", + "quality": "", + "flatness": "", + "has_text": "no", + "has_face": "no", + "has_shadow": "no", + "risk": "none", + "feasibility": "yes", + "perspective": "no", + "aspect_ratio": "1:1", + "gemini_prompt": "", + "note": "", + "price_min": 20, + "price_max": 30, + "price_suggest": 25, + "success": False + } + + +# 全局实例 +image_analyzer = ImageAnalyzer() diff --git a/image/image_precheck.py b/image/image_precheck.py new file mode 100644 index 0000000..facbf6f --- /dev/null +++ b/image/image_precheck.py @@ -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, "" diff --git a/image/image_processor.py b/image/image_processor.py new file mode 100644 index 0000000..dec6739 --- /dev/null +++ b/image/image_processor.py @@ -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() diff --git a/image/image_qa.py b/image/image_qa.py new file mode 100644 index 0000000..416dd37 --- /dev/null +++ b/image/image_qa.py @@ -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> +结论: +问题: <简述主要问题,不超过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() diff --git a/image/image_tools.py b/image/image_tools.py new file mode 100644 index 0000000..a29cc8d --- /dev/null +++ b/image/image_tools.py @@ -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) diff --git a/image/perspective_fix.py b/image/perspective_fix.py new file mode 100644 index 0000000..09eab7b --- /dev/null +++ b/image/perspective_fix.py @@ -0,0 +1,651 @@ +""" +透视矫正三步流程: + Step1: Gemini 去背景 → 纯白背景 + Step2: OpenCV 在白背景图上检测四角 → warpPerspective 展平 + Step3: Gemini 对展平结果做高清增强 + +用法: + python perspective_fix.py <图片路径或URL> [--debug] [--skip-step1] [--skip-step3] +""" +import sys, io +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +import os, asyncio, uuid, tempfile +import numpy as np +import cv2 +from dotenv import load_dotenv + +load_dotenv() + +_OUTPUT_DIR = os.getenv("RESULT_IMAGE_DIR", "results") +os.makedirs(_OUTPUT_DIR, exist_ok=True) + +# ═══════════════════════════════════════════════════════════════ +# Gemini 辅助函数 +# ═══════════════════════════════════════════════════════════════ + +async def _gemini_call(input_path: str, output_path: str, prompt: str, + aspect_ratio: str = "1:1", label: str = "") -> bool: + from services.service_gemini import GeminiExtractV2Service + service = GeminiExtractV2Service() + try: + ok, msg, _ = await service.extract_pattern( + input_path=input_path, + output_path=output_path, + custom_prompt=prompt, + aspect_ratio=aspect_ratio, + ) + status = "成功" if ok else "失败" + print(f" [{label}] Gemini {status}: {msg[:80]}") + return ok and os.path.exists(output_path) + except Exception as e: + print(f" [{label}] Gemini 异常: {e}") + return False + finally: + await service.cleanup() + + +PROMPT_WHITE_BG = ( + "请处理这张图片:\n" + "1. 识别图中的地毯/地垫/印花布料/产品本体作为主体\n" + "2. 去掉主体上面放置的所有物品(杯子、碗、餐具、装饰品等),只保留地垫本身\n" + "3. 把所有背景(桌面、地板、墙壁、阴影)全部替换为纯白色(#FFFFFF)\n" + "4. 保持地垫/产品的颜色、图案、边缘完全不变\n" + "输出:只有主体产品、纯白背景、无杂物的干净产品图。" +) + +# 当第一次去背景效果不好时(白色覆盖率过低),用更强硬的提示词重试 +PROMPT_WHITE_BG_STRONG = ( + "严格执行:将这张图的背景彻底替换为纯白色 RGB(255,255,255)。\n" + "只保留图片中央的产品/地毯/布料主体,其他所有区域(桌面/地板/墙/阴影/物品)" + "一律改为纯白色。产品边缘要干净锐利,不留任何半透明或灰色区域。\n" + "重要:不论主体上摆放了什么东西,统统去掉,只输出产品本身+白色背景。" +) + +PROMPT_ENHANCE = ( + "请对这张已展平的图案进行高清增强:提升整体清晰度和色彩饱和度," + "修复边缘锯齿,补全缺失细节,输出印刷级高质量平面图,背景保持纯白。" +) + +# Step3 增强失败时的兜底提示词(更简单,成功率更高) +PROMPT_ENHANCE_SIMPLE = ( + "请提升这张图片的清晰度和画质,输出高清版本,背景保持纯白。" +) + + +def _measure_white_coverage(image: np.ndarray) -> float: + """返回图片中白色像素的百分比,用于判断去背景效果""" + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + _, mask = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY) + return float(np.sum(mask == 255)) / mask.size + + +def _color_match(source: np.ndarray, target: np.ndarray, + strength: float = 0.75, exclude_white: bool = True) -> np.ndarray: + """ + 将 target 的色调匹配到 source(类 PS「匹配颜色」)。 + 使用 LAB 色彩空间 Reinhard 均值/标准差迁移。 + + Args: + source: 原图(色彩参考来源) + target: 待调整图(处理后结果) + strength: 迁移强度 0.0-1.0,推荐 0.6-0.85 + exclude_white: 统计时排除白色像素,避免背景影响肤色/图案计算 + Returns: + 调色后的 BGR 图像 + """ + src_f = source.astype(np.float32) / 255.0 + tgt_f = target.astype(np.float32) / 255.0 + + src_lab = cv2.cvtColor(src_f, cv2.COLOR_BGR2Lab) + tgt_lab = cv2.cvtColor(tgt_f, cv2.COLOR_BGR2Lab) + result = tgt_lab.copy() + + for ch in range(3): + if exclude_white: + # 排除极亮像素(L > 95)统计,只看图案区域 + src_mask = src_lab[:, :, 0] < 95 + tgt_mask = tgt_lab[:, :, 0] < 95 + src_vals = src_lab[:, :, ch][src_mask] + tgt_vals = tgt_lab[:, :, ch][tgt_mask] + else: + src_vals = src_lab[:, :, ch].ravel() + tgt_vals = tgt_lab[:, :, ch].ravel() + + if src_vals.size == 0 or tgt_vals.size == 0: + continue + + src_mean, src_std = float(src_vals.mean()), float(src_vals.std()) + tgt_mean, tgt_std = float(tgt_vals.mean()), float(tgt_vals.std()) + + if tgt_std < 1e-6: + continue + + # Reinhard 迁移:先归一化到目标,再重映射到源分布 + shifted = (tgt_lab[:, :, ch] - tgt_mean) / tgt_std * src_std + src_mean + # 按 strength 混合:strength=1 完全迁移,0 保持不变 + result[:, :, ch] = shifted * strength + tgt_lab[:, :, ch] * (1.0 - strength) + + result_bgr = cv2.cvtColor(result, cv2.COLOR_Lab2BGR) + result_bgr = np.clip(result_bgr * 255, 0, 255).astype(np.uint8) + + print(f" [颜色匹配] 强度={strength:.0%} | " + f"源均值L={src_lab[:,:,0].mean():.1f} → 目标均值L={tgt_lab[:,:,0].mean():.1f}") + return result_bgr + + +# ═══════════════════════════════════════════════════════════════ +# OpenCV 透视矫正 +# ═══════════════════════════════════════════════════════════════ + +def order_points(pts: np.ndarray) -> np.ndarray: + """ + 把四个点排列为 [左上, 右上, 右下, 左下]。 + 使用质心角度排序,对矩形、菱形、平行四边形等各种透视形状均适用。 + """ + cx, cy = pts[:, 0].mean(), pts[:, 1].mean() + # 计算每个点相对质心的角度(从正上方顺时针) + angles = np.arctan2(pts[:, 1] - cy, pts[:, 0] - cx) + # 顺时针排序:从右上开始(角度最小的) + order = np.argsort(angles) + sorted_pts = pts[order] + # 找到最左上角作为起点(x+y 最小) + s = sorted_pts.sum(axis=1) + start = np.argmin(s) + # 从左上角开始顺时针排列 → [左上, 右上, 右下, 左下] + indices = [(start + i) % 4 for i in range(4)] + rect = sorted_pts[indices].astype("float32") + return rect + + +def four_point_transform(image: np.ndarray, pts: np.ndarray) -> np.ndarray: + rect = order_points(pts) + tl, tr, br, bl = rect + + w1 = np.linalg.norm(br - bl) + w2 = np.linalg.norm(tr - tl) + h1 = np.linalg.norm(tr - br) + h2 = np.linalg.norm(tl - bl) + W = int(max(w1, w2)) + H = int(max(h1, h2)) + + print(f" [CV] 角点: TL={tl.astype(int)} TR={tr.astype(int)} BR={br.astype(int)} BL={bl.astype(int)}") + print(f" [CV] 矫正后目标尺寸: {W}x{H}") + + dst = np.array([ + [0, 0 ], + [W - 1, 0 ], + [W - 1, H - 1], + [0, H - 1], + ], dtype="float32") + + M = cv2.getPerspectiveTransform(rect, dst) + warped = cv2.warpPerspective( + image, M, (W, H), + flags=cv2.INTER_LANCZOS4, + borderMode=cv2.BORDER_CONSTANT, + borderValue=(255, 255, 255), + ) + return warped + + +def _detect_bg_color(image: np.ndarray, corner_size: int = 24) -> np.ndarray: + """ + 从图片四个角落采样,估计背景颜色(BGR)。 + 适用于白色、米色、黄色、灰色等各种背景。 + """ + H, W = image.shape[:2] + cs = min(corner_size, H // 5, W // 5) + corners = [ + image[:cs, :cs], # 左上 + image[:cs, W-cs:], # 右上 + image[H-cs:, :cs], # 左下 + image[H-cs:, W-cs:], # 右下 + ] + pixels = np.concatenate([c.reshape(-1, 3) for c in corners], axis=0) + bg = np.median(pixels, axis=0).astype(np.uint8) + return bg # BGR + + +def tool_trim_white_border(image: np.ndarray, + tolerance: int = 18, + bg_ratio: float = 0.90, + padding: int = 4) -> tuple[np.ndarray, bool, dict]: + """ + 【Tool】智能背景边裁切(支持任意背景色:白/黄/米/灰等)。 + + 算法: + 1. 从四角采样估计背景色 + 2. 逐行/列扫描:若该行/列中 bg_ratio 以上的像素与背景色差异 <= tolerance,则为背景行/列 + 3. 找到内容区域边界后裁切 + + Returns: + (裁切后图片, 是否裁切, 详情dict) + """ + H, W = image.shape[:2] + bg_color = _detect_bg_color(image) + img_f = image.astype(np.int32) + + # 每个像素与背景色的最大通道差异 + diff = np.abs(img_f - bg_color.astype(np.int32)).max(axis=2) # H x W + is_bg = diff <= tolerance # True = 接近背景色 + + row_bg_ratio = is_bg.mean(axis=1) # 每行的背景像素占比 + col_bg_ratio = is_bg.mean(axis=0) # 每列的背景像素占比 + + top = next((i for i in range(H) if row_bg_ratio[i] < bg_ratio), H) + bottom = next((i for i in range(H-1,-1,-1) if row_bg_ratio[i] < bg_ratio), -1) + 1 + left = next((i for i in range(W) if col_bg_ratio[i] < bg_ratio), W) + right = next((i for i in range(W-1,-1,-1) if col_bg_ratio[i] < bg_ratio), -1) + 1 + + border_top = top + border_bottom = H - bottom + border_left = left + border_right = W - right + max_border = max(border_top, border_bottom, border_left, border_right) + + bg_hex = "#{:02X}{:02X}{:02X}".format(int(bg_color[2]), int(bg_color[1]), int(bg_color[0])) + info = {"top": border_top, "bottom": border_bottom, + "left": border_left, "right": border_right, "bg_color": bg_hex} + + if max_border < 5: + print(f" [裁边] 背景色{bg_hex} | 上{border_top} 下{border_bottom} 左{border_left} 右{border_right}px → 无需裁切") + return image, False, info + + y1 = max(0, top - padding) + y2 = min(H, bottom + padding) + x1 = max(0, left - padding) + x2 = min(W, right + padding) + cropped = image[y1:y2, x1:x2] + ch, cw = cropped.shape[:2] + print(f" [裁边] 背景色{bg_hex} | 上{border_top} 下{border_bottom} 左{border_left} 右{border_right}px → 裁切 {W}x{H}→{cw}x{ch}") + return cropped, True, info + + +async def tool_color_match(orig_img: np.ndarray, result_img: np.ndarray, + strength: float = 0.75) -> np.ndarray: + """【Tool】颜色匹配(封装版,供 AI 决策层调用)""" + return _color_match(orig_img, result_img, strength=strength) + + +async def ai_decide_postprocess(orig_img: np.ndarray, result_img: np.ndarray) -> dict: + """ + 【AI 决策层】用视觉模型分析出图效果,决定是否需要颜色匹配和白边裁切。 + + Returns: + { + "need_color_match": bool, + "color_strength": float, # 0.5-0.9 + "need_trim": bool, + "reason": str, + } + """ + import base64 + from dotenv import load_dotenv + load_dotenv() + api_key = os.getenv("OPENAI_API_KEY") + base_url = os.getenv("OPENAI_BASE_URL") + model = os.getenv("VISION_MODEL", "glm-4v-flash") + + # 无 API 时默认两个都做 + if not api_key: + return {"need_color_match": True, "color_strength": 0.75, + "need_trim": True, "reason": "无API Key,默认执行"} + + def _encode(img: np.ndarray) -> str: + resized = cv2.resize(img, (512, 512)) + _, buf = cv2.imencode(".jpg", resized, [cv2.IMWRITE_JPEG_QUALITY, 80]) + return base64.b64encode(buf).decode() + + orig_b64 = _encode(orig_img) + result_b64 = _encode(result_img) + + prompt = ( + "你是图片后处理决策助手。图一是原图,图二是AI处理后的结果图。请判断:\n\n" + "【问题1】颜色差异:处理后图片的整体色调与原图相比,差异是否明显?\n" + "(明显=色调/饱和度/冷暖差异很大;轻微=有轻微偏差;无=颜色基本一致)\n\n" + "【问题2】多余边框:处理后图片四周是否有不属于图案内容的多余空白边框?\n" + "注意:边框颜色不一定是白色,也可能是黄色、米色、灰色等任何纯色。\n" + "判断标准:图案内容的外围是否有一圈明显的纯色空白带。\n\n" + "严格按格式回答(每行一个字段,不要多余内容):\n" + "颜色差异: <明显|轻微|无>\n" + "多余边框: <有|无>\n" + "边框位置: <有边框的方向如「上下」,没有则填无>" + ) + + try: + from openai import AsyncOpenAI + client = AsyncOpenAI(base_url=base_url, api_key=api_key) + response = await client.chat.completions.create( + model=model, + messages=[{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{orig_b64}"}}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{result_b64}"}}, + {"type": "text", "text": prompt}, + ], + }], + ) + text = response.choices[0].message.content or "" + print(f" [AI决策] 原始回答: {text.strip()[:120]}") + + def _get(key): + for line in text.splitlines(): + line = line.strip() + if line.startswith(key): + return line.split(":", 1)[-1].strip() + return "" + + color_level = _get("颜色差异") + has_border = "有" in _get("多余边框") + border_pos = _get("边框位置") + + strength_map = {"明显": 0.80, "轻微": 0.55, "无": 0.0} + color_strength = strength_map.get(color_level, 0.75) + need_color = color_strength > 0 + + reason = f"颜色差异={color_level or '?'}, 边框={'有('+border_pos+')' if has_border else '无'}" + print(f" [AI决策] {reason} → 颜色匹配={'✓' if need_color else '✗'}(强度{color_strength:.0%}), 裁边={'✓' if has_border else '✗'}") + + return { + "need_color_match": need_color, + "color_strength": color_strength, + "need_trim": has_border, + "reason": reason, + } + + except Exception as e: + print(f" [AI决策] 调用失败({e}),默认执行颜色匹配+裁边") + return {"need_color_match": True, "color_strength": 0.75, + "need_trim": True, "reason": f"AI决策失败: {e}"} + + +def _points_are_unique(pts: np.ndarray, min_dist: float = 20.0) -> bool: + """检查4个角点两两之间距离都大于 min_dist,防止重复点导致退化变换""" + for i in range(len(pts)): + for j in range(i + 1, len(pts)): + if np.linalg.norm(pts[i] - pts[j]) < min_dist: + return False + return True + + +def find_quad(image: np.ndarray): + """ + 在白背景图上检测主体四边形角点。 + 策略(按优先级): + 1. 二值化 + approxPolyDP(epsilon 从小到大尝试) + 2. 凸包取极值四点(最左/最右/最上/最下) + 3. minAreaRect 四角 + """ + h, w = image.shape[:2] + img_area = h * w + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # ── 获取主体轮廓 ────────────────────────────────────────── + _, thresh = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY_INV) + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 20)) + closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) + + cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if not cnts: + edges = cv2.Canny(gray, 30, 100) + k2 = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 10)) + closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, k2) + cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + if not cnts: + print(" [CV] 无法检测轮廓") + return None + + c = max(cnts, key=cv2.contourArea) + area = cv2.contourArea(c) + print(f" [CV] 主体轮廓面积: {area:.0f} / {img_area} ({area/img_area*100:.1f}%)") + if area < img_area * 0.05: + print(" [CV] 面积太小,背景可能去除不完全") + return None + + peri = cv2.arcLength(c, True) + + # ── 策略1:approxPolyDP,epsilon 逐步放大直到得到4个唯一角点 ── + for eps_ratio in [0.02, 0.03, 0.04, 0.05, 0.06]: + approx = cv2.approxPolyDP(c, eps_ratio * peri, True) + pts = approx.reshape(-1, 2).astype("float32") + if len(pts) == 4 and _points_are_unique(pts): + print(f" [CV] approxPolyDP 成功 (eps={eps_ratio}), 4个唯一角点") + return pts + print(f" [CV] approxPolyDP eps={eps_ratio}: {len(pts)} 顶点,唯一={_points_are_unique(pts) if len(pts)==4 else 'N/A'}") + + # ── 策略2:凸包极值四点(最左/最上/最右/最下)───────────── + hull = cv2.convexHull(c).reshape(-1, 2).astype("float32") + if len(hull) >= 4: + # 取4个极值方向的点 + left = hull[np.argmin(hull[:, 0])] # 最左 + right = hull[np.argmax(hull[:, 0])] # 最右 + top = hull[np.argmin(hull[:, 1])] # 最上 + bottom = hull[np.argmax(hull[:, 1])] # 最下 + pts = np.array([left, top, right, bottom], dtype="float32") + if _points_are_unique(pts): + print(f" [CV] 使用凸包极值四点: L={left.astype(int)} T={top.astype(int)} R={right.astype(int)} B={bottom.astype(int)}") + return pts + + # ── 策略3:minAreaRect 四角(兜底)───────────────────────── + print(f" [CV] 兜底:使用 minAreaRect") + rect = cv2.minAreaRect(c) + box = cv2.boxPoints(rect).astype("float32") + return box + + +def save_debug_img(image: np.ndarray, pts, path: str): + """保存带角点标注的调试图""" + dbg = image.copy() + if pts is not None: + rect = order_points(pts) + labels = ["TL", "TR", "BR", "BL"] + colors = [(0,0,255), (0,255,0), (255,0,0), (0,165,255)] + for i, (px, py) in enumerate(rect): + cv2.circle(dbg, (int(px), int(py)), 12, colors[i], -1) + cv2.putText(dbg, labels[i], (int(px)+15, int(py)), + cv2.FONT_HERSHEY_SIMPLEX, 1.2, colors[i], 3) + box = rect.reshape((-1,1,2)).astype(np.int32) + cv2.polylines(dbg, [box], True, (0,0,255), 3) + cv2.imwrite(path, dbg, [cv2.IMWRITE_JPEG_QUALITY, 90]) + print(f" [Debug] 调试图: {path}") + + +# ═══════════════════════════════════════════════════════════════ +# 主流程 +# ═══════════════════════════════════════════════════════════════ + +async def process(src: str, debug: bool = False, + skip_step1: bool = False, skip_step3: bool = False) -> str | None: + uid = uuid.uuid4().hex + tmp = [] # 临时文件列表,最后统一清理 + + # ── 下载(URL 情况)────────────────────────────────────── + if src.startswith("http"): + import aiohttp + dl = os.path.join(tempfile.gettempdir(), f"pfix_dl_{uid}.jpg") + tmp.append(dl) + print("[下载] 原图中...") + async with aiohttp.ClientSession(headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + "Referer": "https://www.taobao.com/", + }) as sess: + async with sess.get(src, timeout=aiohttp.ClientTimeout(total=30)) as r: + if r.status != 200: + print(f"[下载] 失败: HTTP {r.status}") + return None + with open(dl, "wb") as f: + f.write(await r.read()) + local_src = dl + else: + local_src = src + + current = local_src # 当前处理中的文件 + orig_img = cv2.imread(local_src) # 保留原图用于颜色匹配 + # 记录原图宽高比,用于检测 Gemini 旋转问题 + orig_ratio = (orig_img.shape[1] / orig_img.shape[0]) if orig_img is not None else 1.0 + + try: + # ── Step 1: Gemini 去背景 → 白背景 ────────────────── + if not skip_step1: + print("\n" + "─"*50) + print("Step 1 / 3 | Gemini 去背景 → 白色背景") + print("─"*50) + s1_out = os.path.join(tempfile.gettempdir(), f"pfix_s1_{uid}.jpg") + tmp.append(s1_out) + ok = await _gemini_call(current, s1_out, PROMPT_WHITE_BG, + aspect_ratio="auto", label="去背景") + if ok: + # 检查白色覆盖率,判断背景去除是否充分 + s1_img = cv2.imread(s1_out) + white_pct = _measure_white_coverage(s1_img) if s1_img is not None else 0.0 + print(f" [去背景] 白色覆盖率: {white_pct:.1%}", end="") + if white_pct < 0.20: + # 背景去除太差,用强化提示词重试 + print(" → 太低,强化提示词重试...") + s1_retry = os.path.join(tempfile.gettempdir(), f"pfix_s1r_{uid}.jpg") + tmp.append(s1_retry) + ok2 = await _gemini_call(current, s1_retry, PROMPT_WHITE_BG_STRONG, + aspect_ratio="auto", label="去背景(强化)") + if ok2: + r_img = cv2.imread(s1_retry) + retry_pct = _measure_white_coverage(r_img) if r_img is not None else 0.0 + print(f" [去背景] 重试白色覆盖率: {retry_pct:.1%}", end="") + if retry_pct >= white_pct: + print(" → 效果更好,采用重试结果") + current = s1_retry + else: + print(" → 效果未提升,保留首次结果") + current = s1_out + else: + print(" [去背景] 重试失败,保留首次结果") + current = s1_out + else: + print(" → 合格") + current = s1_out + else: + print(" Step1 失败,用原图继续") + else: + print("\n[跳过 Step1] 直接用原图") + + # ── Step 2: OpenCV 在白背景图上检测+透视矫正 ───────── + print("\n" + "─"*50) + print("Step 2 / 3 | OpenCV 轮廓检测 + 透视矫正") + print("─"*50) + img = cv2.imread(current) + if img is None: + print(f" 无法读取: {current}") + return None + + h, w = img.shape[:2] + print(f" 输入尺寸: {w}x{h}") + pts = find_quad(img) + + if debug: + dbg_path = os.path.join(_OUTPUT_DIR, f"debug_{uid}.jpg") + save_debug_img(img, pts, dbg_path) + + if pts is not None: + warped = four_point_transform(img, pts) + + # ── 方向校正:Gemini 可能把图旋转 90°,需要纠正 ── + wh2, ww2 = warped.shape[:2] + warped_ratio = ww2 / wh2 # 宽/高 + # 若原图横竖方向与矫正结果相反(比例差异超过 1.5 倍),旋转 90° + if orig_ratio > 1.0 and warped_ratio < 1.0 / 1.5: + # 原图横,结果竖 → 顺时针转 90° + warped = cv2.rotate(warped, cv2.ROTATE_90_CLOCKWISE) + print(f" [方向校正] 原图横({orig_ratio:.2f}) vs 矫正竖({warped_ratio:.2f}) → 旋转90°") + elif orig_ratio < 1.0 and warped_ratio > 1.5: + # 原图竖,结果横 → 逆时针转 90° + warped = cv2.rotate(warped, cv2.ROTATE_90_COUNTERCLOCKWISE) + print(f" [方向校正] 原图竖({orig_ratio:.2f}) vs 矫正横({warped_ratio:.2f}) → 旋转-90°") + else: + print(f" [方向校正] 方向一致,无需旋转 (原图比例={orig_ratio:.2f}, 矫正比例={warped_ratio:.2f})") + + s2_out = os.path.join(tempfile.gettempdir(), f"pfix_s2_{uid}.jpg") + tmp.append(s2_out) + cv2.imwrite(s2_out, warped, [cv2.IMWRITE_JPEG_QUALITY, 95]) + current = s2_out + wh2, ww2 = warped.shape[:2] + print(f" 透视矫正完成 → {ww2}x{wh2}") + else: + print(" 角点检测失败,跳过透视矫正,继续用白背景图") + + # ── Step 3: Qwen 高清增强 ───────────────────────────── + if not skip_step3: + print("\n" + "─"*50) + print("Step 3 / 5 | Qwen 高清增强(RunningHub)") + print("─"*50) + final_out = os.path.join(_OUTPUT_DIR, f"pfix_final_{uid}.jpg") + from services.service_qwen import 清晰化_api + ok = await 清晰化_api(img_path=current, save_path=final_out) + if ok: + print(f" [高清增强] Qwen 成功") + else: + # Qwen 失败,用 Gemini 简化提示词兜底 + print(" Qwen 失败,Gemini 兜底重试...") + ok = await _gemini_call(current, final_out, PROMPT_ENHANCE_SIMPLE, + aspect_ratio="auto", label="高清增强(Gemini兜底)") + if not ok: + print(" Step3 全部失败,直接保存矫正结果") + import shutil + shutil.copy2(current, final_out) + else: + final_out = os.path.join(_OUTPUT_DIR, f"pfix_final_{uid}.jpg") + import shutil + shutil.copy2(current, final_out) + print("\n[跳过 Step3] 直接保存矫正结果") + + # ── Step 4: AI 决策 + 后处理(颜色匹配 & 白边裁切)──── + print("\n" + "─"*50) + print("Step 4 / 4 | AI 决策后处理(颜色匹配 / 白边裁切)") + print("─"*50) + final_img = cv2.imread(final_out) + if final_img is not None and orig_img is not None: + decision = await ai_decide_postprocess(orig_img, final_img) + + # Tool 1: 颜色匹配 + if decision["need_color_match"]: + final_img = await tool_color_match(orig_img, final_img, + strength=decision["color_strength"]) + cv2.imwrite(final_out, final_img, [cv2.IMWRITE_JPEG_QUALITY, 95]) + else: + print(" [颜色匹配] AI 判断无需调色,跳过") + + # Tool 2: 白边裁切 + if decision["need_trim"]: + trimmed, did_trim, _ = tool_trim_white_border(final_img) + if did_trim: + cv2.imwrite(final_out, trimmed, [cv2.IMWRITE_JPEG_QUALITY, 95]) + else: + print(" [裁边] AI 判断无白边,跳过") + else: + print(" [Step4] 图片读取失败,跳过后处理") + + size_kb = os.path.getsize(final_out) / 1024 + print(f"\n{'='*50}") + print(f" 完成!输出文件: {final_out}") + print(f" 文件大小: {size_kb:.0f} KB") + print(f"{'='*50}") + return final_out + + finally: + for f in tmp: + if os.path.exists(f): + os.remove(f) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("用法: python perspective_fix.py <图片路径或URL> [--debug] [--skip-step1] [--skip-step3]") + sys.exit(1) + + src_arg = sys.argv[1] + debug_arg = "--debug" in sys.argv + skip1_arg = "--skip-step1" in sys.argv + skip3_arg = "--skip-step3" in sys.argv + asyncio.run(process(src_arg, debug=debug_arg, skip_step1=skip1_arg, skip_step3=skip3_arg)) diff --git a/logs/chat_2026-02-25.log b/logs/chat_2026-02-25.log new file mode 100644 index 0000000..7a07dac --- /dev/null +++ b/logs/chat_2026-02-25.log @@ -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 决定不回复此消息 diff --git a/logs/chat_2026-02-26.log b/logs/chat_2026-02-26.log new file mode 100644 index 0000000..5264dcc --- /dev/null +++ b/logs/chat_2026-02-26.log @@ -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 回复: 没事,想要了直接拍下就行,不满意包退哈。 diff --git a/logs/chat_2026-02-27.log b/logs/chat_2026-02-27.log new file mode 100644 index 0000000..d06646a --- /dev/null +++ b/logs/chat_2026-02-27.log @@ -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号码发给我,我加你哈。 diff --git a/mail/__init__.py b/mail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mail/__pycache__/__init__.cpython-310.pyc b/mail/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..e759d81 Binary files /dev/null and b/mail/__pycache__/__init__.cpython-310.pyc differ diff --git a/mail/__pycache__/email_receiver.cpython-310.pyc b/mail/__pycache__/email_receiver.cpython-310.pyc new file mode 100644 index 0000000..2ef5ae2 Binary files /dev/null and b/mail/__pycache__/email_receiver.cpython-310.pyc differ diff --git a/mail/__pycache__/email_sender.cpython-310.pyc b/mail/__pycache__/email_sender.cpython-310.pyc new file mode 100644 index 0000000..934686d Binary files /dev/null and b/mail/__pycache__/email_sender.cpython-310.pyc differ diff --git a/mail/email_receiver.py b/mail/email_receiver.py new file mode 100644 index 0000000..6b791a0 --- /dev/null +++ b/mail/email_receiver.py @@ -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( + "您好!收到您的邮件。

" + "请将您需要处理的图片作为附件发送过来,我们会尽快为您报价。

" + "支持格式: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},建议报价 {price} 元" + + (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"

📦 您共发来 {n} 张图片,支持打包优惠,欢迎咨询。" + elif n >= 3: + tip = f"

📦 您共发来 {n} 张图片,3张以上可享9折优惠。" + else: + tip = "" + + quote_html = "
".join(quotes) + body = self._html( + f"您好!感谢您发来图片,已为您完成分析:

" + f"{quote_html}{tip}

" + f"如需处理,请直接在淘宝店铺下单,付款后我们会尽快为您完成制作并发回。
" + 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""" + + {content} +

+
+

修图客服 · 自动回复

+ + """ + + +# ========== 全局实例(从 .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")), +) diff --git a/mail/email_sender.py b/mail/email_sender.py new file mode 100644 index 0000000..24cec55 --- /dev/null +++ b/mail/email_sender.py @@ -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'') + 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""" + + +

您好 {customer_name},您的修图作品已完成!

+

感谢您选择我们的服务。以下是您处理后的图片:

+

处理内容: {image_description}

+
+

如有任何问题,请随时联系我们。

+
+

祝您生活愉快!

+ + + """ + + return self.send(to_email, subject, body, result_images) + + +# 全局实例 +email_sender = EmailSender() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a90e6c4 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/results/20260225211854.jpg b/results/20260225211854.jpg new file mode 100644 index 0000000..2751a2e Binary files /dev/null and b/results/20260225211854.jpg differ diff --git a/results/debug_7debc0124b0441da9945feaeceef93b1.jpg b/results/debug_7debc0124b0441da9945feaeceef93b1.jpg new file mode 100644 index 0000000..c2ba038 Binary files /dev/null and b/results/debug_7debc0124b0441da9945feaeceef93b1.jpg differ diff --git a/results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg b/results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg new file mode 100644 index 0000000..82e7d6e Binary files /dev/null and b/results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg differ diff --git a/results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg b/results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg new file mode 100644 index 0000000..82fea33 Binary files /dev/null and b/results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg differ diff --git a/results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg b/results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg new file mode 100644 index 0000000..9e9feff Binary files /dev/null and b/results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg differ diff --git a/results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg b/results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg new file mode 100644 index 0000000..b8cf8a1 Binary files /dev/null and b/results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg differ diff --git a/results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg b/results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg new file mode 100644 index 0000000..0fed44d Binary files /dev/null and b/results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg differ diff --git a/results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg b/results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg new file mode 100644 index 0000000..569e90e Binary files /dev/null and b/results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg differ diff --git a/results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg b/results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg new file mode 100644 index 0000000..da4a251 Binary files /dev/null and b/results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg differ diff --git a/results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg b/results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg new file mode 100644 index 0000000..058c84d Binary files /dev/null and b/results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg differ diff --git a/results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg b/results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg new file mode 100644 index 0000000..701e07b Binary files /dev/null and b/results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg differ diff --git a/results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg b/results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg new file mode 100644 index 0000000..e1be6b8 Binary files /dev/null and b/results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg differ diff --git a/results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg b/results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg new file mode 100644 index 0000000..d84338a Binary files /dev/null and b/results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg differ diff --git a/results/result_90eaf777934445af81abbd60fe4778c5.jpg b/results/result_90eaf777934445af81abbd60fe4778c5.jpg new file mode 100644 index 0000000..745f3a3 Binary files /dev/null and b/results/result_90eaf777934445af81abbd60fe4778c5.jpg differ diff --git a/run.py b/run.py new file mode 100644 index 0000000..8266b5a --- /dev/null +++ b/run.py @@ -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已停止") diff --git a/scripts/__pycache__/chat_ui.cpython-310.pyc b/scripts/__pycache__/chat_ui.cpython-310.pyc new file mode 100644 index 0000000..173d7a1 Binary files /dev/null and b/scripts/__pycache__/chat_ui.cpython-310.pyc differ diff --git a/scripts/chat_log_viewer.py b/scripts/chat_log_viewer.py new file mode 100644 index 0000000..7864292 --- /dev/null +++ b/scripts/chat_log_viewer.py @@ -0,0 +1,371 @@ +""" +聊天记录查看器 +用法: + python scripts/chat_log_viewer.py # 列出所有客户 + python scripts/chat_log_viewer.py <客户ID> # 查看某客户全部对话 + python scripts/chat_log_viewer.py -s <关键词> # 全局搜索 + python scripts/chat_log_viewer.py -t <客户ID> # 只看今天 + python scripts/chat_log_viewer.py -l # 实时监听最新消息(10条/刷新) +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import time +import os +from datetime import datetime + +# 强制 UTF-8 输出(Windows 终端需要) +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + +from db import chat_log_db as db + +# ========== ANSI 颜色 ========== +try: + import ctypes + ctypes.windll.kernel32.SetConsoleMode(ctypes.windll.kernel32.GetStdHandle(-11), 7) +except Exception: + pass + +RESET = "\033[0m" +BOLD = "\033[1m" +DIM = "\033[2m" +GREEN = "\033[32m" +CYAN = "\033[36m" +YELLOW = "\033[33m" +BLUE = "\033[34m" +MAGENTA= "\033[35m" +RED = "\033[31m" +WHITE = "\033[97m" +BG_DARK= "\033[48;5;236m" + + +def clear(): + os.system("cls" if os.name == "nt" else "clear") + + +def header(text: str): + width = 60 + print(f"\n{BOLD}{CYAN}{'─' * width}{RESET}") + print(f"{BOLD}{CYAN} {text}{RESET}") + print(f"{BOLD}{CYAN}{'─' * width}{RESET}\n") + + +def fmt_time(ts: str) -> str: + """缩短时间戳显示""" + today = datetime.now().strftime("%Y-%m-%d") + if ts.startswith(today): + return ts[11:16] # 只显示 HH:MM + return ts[:16] + + +def platform_badge(platform: str) -> str: + badges = { + "AliWorkbench": f"{YELLOW}[淘宝]{RESET}", + "taobao": f"{YELLOW}[淘宝]{RESET}", + "pinduoduo": f"{RED}[拼多多]{RESET}", + "jd": f"{RED}[京东]{RESET}", + "wechat": f"{GREEN}[微信]{RESET}", + "email": f"{BLUE}[邮件]{RESET}", + } + return badges.get(platform, f"{DIM}[{platform}]{RESET}" if platform else "") + + +def print_bubble(direction: str, message: str, ts: str): + """打印聊天气泡""" + time_str = fmt_time(ts) + lines = [] + if "#*#" in (message or ""): + parts = [p.strip() for p in message.split("#*#") if p.strip()] + if parts: + lines = parts + if not lines: + lines = (message or "").split("\n") + + if direction == "in": # 客户来消息 → 左对齐 + print(f" {DIM}{time_str}{RESET} {WHITE}买家{RESET}") + for line in lines: + print(f" {BG_DARK} {line} {RESET}") + else: # 客服回复 → 右对齐(缩进) + print(f" {DIM}{time_str}{RESET} {GREEN}客服{RESET}") + for line in lines: + print(f" {GREEN}> {line}{RESET}") + print() + + +def cmd_list_customers(): + """列出所有客户""" + customers = db.get_customers(limit=100) + if not customers: + print(f"{YELLOW}暂无聊天记录。{RESET}") + return + + header(f"客户列表 共 {len(customers)} 人") + print(f" {'#':<4} {'客户ID':<24} {'姓名':<12} {'平台':<10} {'消息数':>6} {'最后活跃'}") + print(f" {'─'*4} {'─'*24} {'─'*12} {'─'*10} {'─'*6} {'─'*16}") + for i, c in enumerate(customers, 1): + badge = platform_badge(c.get("platform", "")) + name = (c.get("customer_name") or "")[:10] + cid = c["customer_id"] + total = c["total_msgs"] + last = c.get("last_time", "")[:16] + print(f" {i:<4} {CYAN}{cid:<24}{RESET} {name:<12} {badge:<18} {total:>6}条 {DIM}{last}{RESET}") + + print(f"\n{DIM}用法:python chat_log_viewer.py <客户ID>{RESET}\n") + + +def cmd_show_conversation(customer_id: str, today_only: bool = False): + """显示某客户对话""" + if today_only: + messages = db.get_conversation_today(customer_id) + title = f"今日对话 {customer_id}" + else: + messages = db.get_conversation(customer_id, limit=300) + title = f"对话记录 {customer_id}" + + if not messages: + print(f"{YELLOW}该客户暂无记录:{customer_id}{RESET}") + return + + header(f"{title} ({len(messages)} 条)") + + last_date = "" + for m in messages: + ts = m.get("timestamp", "") + date = ts[:10] + if date != last_date: + print(f" {DIM}{'─'*20} {date} {'─'*20}{RESET}") + last_date = date + print_bubble(m["direction"], m["message"], ts) + + print(f"{DIM} ── 以上共 {len(messages)} 条 ──{RESET}\n") + + +def cmd_search(keyword: str, customer_id: str = None): + """搜索关键词""" + results = db.search_messages(keyword, customer_id=customer_id, limit=50) + title = f"搜索 [{keyword}]" + if customer_id: + title += f" 客户:{customer_id}" + header(f"{title} 共 {len(results)} 条") + + if not results: + print(f"{YELLOW}未找到包含 [{keyword}] 的消息。{RESET}") + return + + last_cid = "" + for r in results: + cid = r["customer_id"] + if cid != last_cid: + print(f" {CYAN}{cid}{RESET} {r.get('customer_name','')}") + last_cid = cid + direction = "买家" if r["direction"] == "in" else "客服" + color = WHITE if r["direction"] == "in" else GREEN + # 高亮关键词 + msg = r["message"].replace(keyword, f"{RED}{BOLD}{keyword}{RESET}{color}") + print(f" {DIM}{r['timestamp'][:16]}{RESET} {color}[{direction}] {msg}{RESET}") + print() + + +def cmd_live(refresh: int = 3): + """实时监听最新消息""" + header("实时消息监听 Ctrl+C 退出") + seen_ids = set() + + try: + while True: + import sqlite3 + conn = sqlite3.connect(db._DB_PATH) + conn.row_factory = sqlite3.Row + rows = conn.execute(""" + SELECT id, customer_id, customer_name, direction, message, timestamp + FROM chat_logs + ORDER BY id DESC LIMIT 20 + """).fetchall() + conn.close() + + new_rows = [dict(r) for r in rows if r["id"] not in seen_ids] + if new_rows: + new_rows.reverse() + for r in new_rows: + seen_ids.add(r["id"]) + cid = r["customer_id"] + name = r.get("customer_name") or "" + label = f"{CYAN}{cid}{RESET}" + (f" {DIM}({name}){RESET}" if name else "") + print(f"\n{label}") + print_bubble(r["direction"], r["message"], r["timestamp"]) + else: + print(f"\r {DIM}等待新消息... {datetime.now().strftime('%H:%M:%S')}{RESET}", end="", flush=True) + + time.sleep(refresh) + except KeyboardInterrupt: + print(f"\n{DIM}已退出监听。{RESET}") + + +def _extract_urls(msg: str) -> list: + if not msg: + return [] + parts = [p.strip() for p in msg.split("#*#") if p.strip()] + urls = [] + for p in parts: + if p.startswith("http://") or p.startswith("https://"): + urls.append(p) + if not urls and ("http://" in msg or "https://" in msg): + import re as _re + tokens = _re.findall(r'(https?://\S+)', msg) + for t in tokens: + tl = t.lower() + if any(ext in tl for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]): + urls.append(t) + return urls + + +def _msg_refers_images(msg: str) -> bool: + if not msg: + return False + refs = ("图一", "图二", "第一张", "第二张", "这张", "那张", "上面那张", "下面那张", "刚才那张", "上一张", "下一张") + return any(r in msg for r in refs) + + +def _parse_ts(ts: str): + try: + from datetime import datetime as _dt + return _dt.fromisoformat(ts.replace("Z","")) + except Exception: + return None + + +def analyze_conversation(messages: list) -> list: + issues = [] + n = len(messages) + for i, m in enumerate(messages): + msg = m.get("message") or "" + dir = m.get("direction") + ts = _parse_ts(m.get("timestamp","")) + # 图片后未及时回复 + if dir == "in" and _extract_urls(msg): + replied = False + delay_ok = True + for j in range(i+1, min(i+6, n)): + mj = messages[j] + if mj.get("direction") == "out": + replied = True + tsj = _parse_ts(mj.get("timestamp","")) + if ts and tsj and (tsj - ts).total_seconds() > 180: + delay_ok = False + break + if not replied: + issues.append("图片消息后未回复") + elif not delay_ok: + issues.append("图片消息后回复延迟超过3分钟") + # 引用图片但找不到历史图片 + if dir == "in" and _msg_refers_images(msg): + has_prev_img = False + for k in range(max(0, i-10), i): + if messages[k].get("direction") == "in" and _extract_urls(messages[k].get("message","")): + has_prev_img = True + break + if not has_prev_img: + issues.append("引用图片但历史中未找到对应图片") + # 订单后未确认/引导 + if dir == "in" and ("买家已付款" in msg or "[系统订单信息]" in msg): + confirmed = False + for j in range(i+1, min(i+6, n)): + if messages[j].get("direction") == "out": + confirmed = True + break + if not confirmed: + issues.append("订单消息后未进行确认或引导付款") + # 合成需求未报价格 + if dir == "in" and any(k in msg for k in ("抓到", "放到", "合成", "融合", "嵌到", "替换", "P到", "抠出来放到")): + priced = False + for j in range(i+1, min(i+6, n)): + mj = messages[j] + if mj.get("direction") == "out": + rm = mj.get("message","") + if "元" in rm: + priced = True + break + if not priced: + issues.append("客户提出合成需求但未给出价格") + # 去重 + dedup = [] + seen = set() + for it in issues: + if it not in seen: + seen.add(it) + dedup.append(it) + return dedup + + +def cmd_analyze_all(): + customers = db.get_customers(limit=200) + if not customers: + print(f"{YELLOW}暂无聊天记录。{RESET}") + return + header("聊天记录上下文分析") + total_issues = 0 + for c in customers: + cid = c["customer_id"] + msgs = db.get_conversation(cid, limit=500) + issues = analyze_conversation(msgs) + if issues: + total_issues += len(issues) + print(f"{CYAN}{cid}{RESET} {c.get('customer_name','')}") + for s in issues: + print(f" - {RED}{s}{RESET}") + print() + if total_issues == 0: + print(f"{GREEN}未发现明显异常。{RESET}") + else: + print(f"{YELLOW}共发现 {total_issues} 项问题(按客户汇总)。{RESET}") + + +def print_help(): + print(f""" +{BOLD}聊天记录查看器{RESET} + + {CYAN}python chat_log_viewer.py{RESET} 列出所有客户 + {CYAN}python chat_log_viewer.py <客户ID>{RESET} 查看该客户全部对话 + {CYAN}python chat_log_viewer.py -t <客户ID>{RESET} 只看今天的对话 + {CYAN}python chat_log_viewer.py -s <关键词>{RESET} 全局搜索 + {CYAN}python chat_log_viewer.py -l{RESET} 实时监听新消息 + {CYAN}python chat_log_viewer.py -a{RESET} 分析上下文,输出异常项 + {CYAN}python chat_log_viewer.py -h{RESET} 显示帮助 +""") + + +if __name__ == "__main__": + args = sys.argv[1:] + + if not args: + cmd_list_customers() + + elif args[0] in ("-h", "--help"): + print_help() + + elif args[0] == "-s": + keyword = args[1] if len(args) > 1 else "" + if not keyword: + print(f"{RED}请提供搜索关键词:python chat_log_viewer.py -s <关键词>{RESET}") + else: + cmd_search(keyword) + + elif args[0] == "-t": + cid = args[1] if len(args) > 1 else "" + if not cid: + print(f"{RED}请提供客户ID:python chat_log_viewer.py -t <客户ID>{RESET}") + else: + cmd_show_conversation(cid, today_only=True) + + elif args[0] == "-l": + cmd_live() + + elif args[0] == "-a": + cmd_analyze_all() + + else: + cmd_show_conversation(args[0]) diff --git a/scripts/chat_ui.py b/scripts/chat_ui.py new file mode 100644 index 0000000..ea3b2a1 --- /dev/null +++ b/scripts/chat_ui.py @@ -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""" + + + + + +聊天记录 + + + + +
+

💬 聊天记录

+ + ● 实时 +
+ +
+ +
+ + + + + +
+
+
💬
+

选择一位客户查看对话记录

+
+ + +
+
+ + + + +""" + +PRICING_HTML = r""" + + + + + +AI 报价测试 + + + +
+
🧪 AI 报价测试
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ +
+ +
提示:含图片URL时,Agent会自动调用图片分析并结合复杂度、尺寸、人脸与风险给出建议价;文本砍价低于最近图片底线会被礼貌拒绝。
+
+ + + + +""" + + +@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/") +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)}) diff --git a/scripts/init_designer_roster.py b/scripts/init_designer_roster.py new file mode 100644 index 0000000..9601e9a --- /dev/null +++ b/scripts/init_designer_roster.py @@ -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 # 查看当前数据") diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/__pycache__/__init__.cpython-310.pyc b/services/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..367e83e Binary files /dev/null and b/services/__pycache__/__init__.cpython-310.pyc differ diff --git a/services/__pycache__/service_gemini.cpython-310.pyc b/services/__pycache__/service_gemini.cpython-310.pyc new file mode 100644 index 0000000..79b095c Binary files /dev/null and b/services/__pycache__/service_gemini.cpython-310.pyc differ diff --git a/services/__pycache__/service_meitu.cpython-310.pyc b/services/__pycache__/service_meitu.cpython-310.pyc new file mode 100644 index 0000000..dde9f81 Binary files /dev/null and b/services/__pycache__/service_meitu.cpython-310.pyc differ diff --git a/services/__pycache__/service_vectorizer.cpython-310.pyc b/services/__pycache__/service_vectorizer.cpython-310.pyc new file mode 100644 index 0000000..3730130 Binary files /dev/null and b/services/__pycache__/service_vectorizer.cpython-310.pyc differ diff --git a/services/service_gemini.py b/services/service_gemini.py new file mode 100644 index 0000000..6596345 --- /dev/null +++ b/services/service_gemini.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +""" +Gemini印花提取V2服务 - 使用服务 +更经济的选择:1.4毛/张 +""" + +import asyncio +import aiohttp +import base64 +import json +import re +import os +import time +from datetime import datetime +from pathlib import Path +import logging + + +logger = logging.getLogger(__name__) + + +class ServiceBase: + """最小化基类,替代缺失的 utils.service_base""" + pass + + +class GeminiExtractV2Service(ServiceBase): + """Gemini印花提取V2服务类 - 使用服务,更经济""" + + SERVICE_NAME = "gemini_extract_v2" + + # 多API配置,按优先级排序(便宜的优先使用) + API_CONFIGS = [ + + + + + # { + # "name": "西风接口$0.003逆向", + # "api_key": "sk-UT9aupbfHI4rc3RUn8x5D8gN5Kk31yvLZQu8M3BCY5Nja1Fc", + # "api_url": "https://api.apiqik.com/v1/chat/completions" , + # "api_model": "gemini-2.5-flash-image", + # "max_retries": 3, # 贵接口少重试 + # "cost": "低" + # }, + + + { + "name": "西风接口$0.014", + "api_key": "sk-uRuvzLfIHsc3BiHZ2cyebk0cYsZ8NR9rLL326QqXCKIy9EpK", + "api_url": "https://api.apiqik.online/v1beta/models", + "api_model": "gemini-2.5-flash-image", # 更稳定的模型 + "max_retries": 2, # 贵接口少重试 + "cost": "中", + "use_gemini_format": True # 使用Gemini原生API格式 + }, + + { + "name": "最贵的", + "api_key": "sk-8i7uYE0RtnQwDImV8a5f7014DcAb46F6BcEb72Df92218aC8", + "api_url": "https://api.laozhang.ai/v1/chat/completions", + "api_model": "gemini-2.5-flash-image-preview", + "max_retries": 1, + "cost": "高" + } + ] + + # 默认提示词 + DEFAULT_PROMPT = "提取印花图案,把褶皱移除。补齐缺失的部分,要生成完整,细节丰富,严格按照原图的元素位置生成平面的印花图,不要相似的,相似度要100%,生成高质量的印刷图" + # DEFAULT_PROMPT = "生成图片,把衣服的图案展开起来做成数码印花印刷平面图。去掉皱褶,生成图案增强细节。排除衣服图案以外内容" + def __init__(self): + super().__init__() + self.session = None + + def image_to_base64(self, image_path: str) -> str: + """将图片文件转换为base64编码字符串""" + try: + if not os.path.exists(image_path): + logger.error(f"文件不存在: {image_path}") + return None + + with open(image_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + return encoded_string + + except Exception as e: + logger.error(f"Base64转换失败: {e}") + return None + + async def extract_pattern( + self, + input_path: str, + output_path: str, + custom_prompt: str = None, + aspect_ratio: str = "1:1", + ) -> tuple[bool, str, dict]: + """ + 使用多API配置进行印花图案提取 + + Args: + input_path: 输入图片路径 + output_path: 输出图片路径 + custom_prompt: 自定义提示词 + + Returns: + tuple: (success, message, data) + """ + # 转换图片为Base64 + img64 = self.image_to_base64(input_path) + if not img64: + return False, "图片编码失败", {} + + # 使用自定义提示词或默认提示词 + prompt = custom_prompt or self.DEFAULT_PROMPT + + # 按优先级逐个尝试API配置 + for config_index, config in enumerate(self.API_CONFIGS): + logger.info(f"尝试使用API: {config['name']} (成本: {config['cost']})") + + # 对每个API配置进行重试 + for attempt in range(config['max_retries']): + try: + logger.info(f"开始Gemini V2印花提取 - {config['name']} (第{attempt + 1}/{config['max_retries']}次尝试): {input_path}") + + # 准备请求数据和URL + if config.get('use_gemini_format', False): + # Gemini原生API格式 + api_url = f"{config['api_url']}/{config['api_model']}:generateContent?key={config['api_key']}" + headers = { + "Content-Type": "application/json" + } + + # 有效比例列表(Auto 不传 aspectRatio) + valid_ratios = {"1:1", "9:16", "16:9", "3:4", "4:3", "3:2", "2:3", "5:4", "4:5"} + image_config = {} + if aspect_ratio in valid_ratios: + image_config["aspectRatio"] = aspect_ratio + + data = { + "contents": [ + { + "role": "user", + "parts": [ + { + "inlineData": { + "mimeType": "image/jpeg", + "data": img64 + } + }, + { + "text": prompt + } + ] + } + ], + "generationConfig": { + "responseModalities": ["IMAGE"], + **({"imageConfig": image_config} if image_config else {}), + } + } + logger.info(f"Gemini 生成配置: 比例={aspect_ratio} 格式=JPEG") + else: + # OpenAI兼容格式 + api_url = config['api_url'] + headers = { + "Authorization": f"Bearer {config['api_key']}", + "Content-Type": "application/json" + } + + data = { + "model": config['api_model'], + "stream": False, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": prompt + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{img64}" + } + } + ] + } + ] + } + + logger.info(f"正在请求{config['name']}服务 (第{attempt + 1}次)...") + + # 发送异步请求 + timeout = aiohttp.ClientTimeout(total=300, connect=30) + connector = aiohttp.TCPConnector(limit=10, limit_per_host=5) + + try: + async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session: + async with session.post(api_url, headers=headers, json=data) as response: + if response.status != 200: + error_text = await response.text() + logger.error(f"{config['name']} API请求失败 (第{attempt + 1}次): {response.status} - {error_text}") + + # 如果是当前API配置的最后一次重试 + if attempt == config['max_retries'] - 1: + logger.warning(f"{config['name']} 所有重试已用完,切换到下一个API配置") + break + + # 当前API配置内部重试 + base_wait_time = 2 + wait_time = base_wait_time * (attempt + 1) + logger.info(f"等待{wait_time}秒后重试{config['name']}...") + await asyncio.sleep(wait_time) + continue + + result = await response.json() + + except (aiohttp.ClientError, asyncio.TimeoutError, AssertionError) as e: + logger.error(f"{config['name']} 网络连接错误 (第{attempt + 1}次): {str(e)}") + + # 如果是当前API配置的最后一次重试 + if attempt == config['max_retries'] - 1: + logger.warning(f"{config['name']} 网络重试已用完,切换到下一个API配置") + break + + # 当前API配置内部重试 + base_wait_time = 2 + wait_time = base_wait_time * (attempt + 1) + logger.info(f"等待{wait_time}秒后重试{config['name']}...") + await asyncio.sleep(wait_time) + continue + + logger.info(f"{config['name']} 服务请求成功 (第{attempt + 1}次),正在处理响应...") + + # 处理API响应并提取图片 + success, message, data = await self._process_api_response(result, output_path, config['name'], config) + + if success: + logger.info(f"使用 {config['name']} 成功完成印花提取") + try: + from utils.api_cost_tracker import record + record("gemini_extract", count=1) + except Exception: + pass + return True, f"Gemini V2印花提取完成 - 使用{config['name']}", data + else: + logger.warning(f"{config['name']} 响应处理失败: {message}") + + # 如果是当前API配置的最后一次重试 + if attempt == config['max_retries'] - 1: + logger.warning(f"{config['name']} 所有重试已用完,切换到下一个API配置") + break + + # 当前API配置内部重试 + base_wait_time = 2 + wait_time = base_wait_time * (attempt + 1) + logger.info(f"等待{wait_time}秒后重试{config['name']}...") + await asyncio.sleep(wait_time) + continue + + except Exception as e: + logger.error(f"{config['name']} API调用异常 (第{attempt + 1}次): {str(e)}") + + # 如果是当前API配置的最后一次重试 + if attempt == config['max_retries'] - 1: + logger.warning(f"{config['name']} 异常重试已用完,切换到下一个API配置") + break + + # 当前API配置内部重试 + base_wait_time = 2 + wait_time = base_wait_time * (attempt + 1) + logger.info(f"等待{wait_time}秒后重试{config['name']}...") + await asyncio.sleep(wait_time) + continue + + # 所有API配置都尝试过了,返回失败 + return False, "所有API配置都已尝试失败", {} + + async def _process_api_response(self, result: dict, output_path: str, api_name: str, config: dict) -> tuple[bool, str, dict]: + """处理API响应并提取图片""" + try: + # 根据API格式提取内容 + if config.get('use_gemini_format', False): + # Gemini原生API格式: candidates[0].content.parts[0] + content_parts = result['candidates'][0]['content']['parts'] + + # 查找包含图片数据的part + image_data = None + for part in content_parts: + # 注意:响应中使用驼峰命名 inlineData + if 'inlineData' in part: + # 提取Base64图片数据 + base64_data = part['inlineData']['data'] + logger.info(f"{api_name} 找到Gemini格式的inlineData图片") + try: + image_data = base64.b64decode(base64_data) + break + except Exception as e: + logger.error(f"{api_name} Base64解码失败: {e}") + return False, f"Base64解码失败: {e}", {} + + if not image_data: + logger.error(f"{api_name} 在Gemini响应中未找到图片数据") + return False, "未找到图片数据", {} + + # 直接保存图片 + return await self._save_image(image_data, output_path, api_name) + + else: + # OpenAI兼容格式: choices[0].message.content + content = result['choices'][0]['message']['content'] + logger.info(f"{api_name} 收到内容: {content[:200]}...") + + # 使用原有的URL/Base64提取逻辑 + return await self._extract_and_save_image(content, output_path, api_name) + + except KeyError as e: + logger.error(f"{api_name} 响应格式不正确,缺少字段: {e}") + logger.error(f"响应内容: {json.dumps(result, ensure_ascii=False)[:500]}") + return False, f"响应格式错误: {e}", {} + except Exception as e: + logger.error(f"{api_name} 处理响应时发生异常: {e}") + return False, f"处理异常: {e}", {} + + async def _save_image(self, image_data: bytes, output_path: str, api_name: str) -> tuple[bool, str, dict]: + """保存图片文件""" + try: + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with open(output_path, 'wb') as f: + f.write(image_data) + + logger.info(f"{api_name} 图片已保存到: {output_path}") + + # 验证保存的图片 + if os.path.exists(output_path) and os.path.getsize(output_path) > 0: + file_size = os.path.getsize(output_path) + logger.info(f"{api_name} 图片保存成功,文件大小: {file_size} bytes") + + return True, f"{api_name} 印花提取完成", { + 'output_path': output_path, + 'file_size': file_size, + 'api_used': api_name + } + else: + logger.error(f"{api_name} 保存的图片文件无效") + return False, "保存的图片文件无效", {} + + except Exception as e: + logger.error(f"{api_name} 保存图片时发生错误: {e}") + return False, f"保存图片失败: {e}", {} + + async def _extract_and_save_image(self, content: str, output_path: str, api_name: str) -> tuple[bool, str, dict]: + """从响应内容中提取并保存图片(URL或Base64格式)""" + # 查找和处理图片数据 + image_data = None + + # 方法1: 查找URL链接 (优先检查URL格式) + url_match = re.search(r'https?://[^\s\)]+\.(?:png|jpg|jpeg|gif|webp)', content) + if url_match: + image_url = url_match.group(0) + logger.info(f"{api_name} 找到图片URL: {image_url}") + + # 图片下载重试机制 + download_retries = 3 + for download_attempt in range(download_retries): + try: + logger.info(f"{api_name} 开始下载图片 (第{download_attempt + 1}/{download_retries}次尝试): {image_url}") + + # 异步下载图片,增加超时时间 + timeout = aiohttp.ClientTimeout(total=300, connect=60) + connector = aiohttp.TCPConnector(limit=5, limit_per_host=2) + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} + + async with aiohttp.ClientSession( + timeout=timeout, + connector=connector, + headers=headers + ) as download_session: + logger.info(f"{api_name} 正在发送HTTP请求...") + async with download_session.get(image_url) as img_response: + logger.info(f"{api_name} 收到HTTP响应: {img_response.status}") + if img_response.status == 200: + image_data = await img_response.read() + logger.info(f"{api_name} 图片下载成功,大小: {len(image_data)} bytes") + break # 成功则跳出重试循环 + else: + logger.error(f"{api_name} 图片下载失败,HTTP状态码: {img_response.status}") + if download_attempt == download_retries - 1: + return False, "图片下载失败", {} + else: + await asyncio.sleep(2) + continue + + except Exception as e: + logger.error(f"{api_name} 下载图片时发生异常 (第{download_attempt + 1}次): {type(e).__name__}: {str(e)}") + if download_attempt == download_retries - 1: + return False, f"图片下载异常: {str(e)}", {} + else: + await asyncio.sleep(2) + continue + + else: + # 方法2: 查找标准格式 data:image/type;base64,data + base64_match = re.search(r'data:image/[^;]+;base64,([A-Za-z0-9+/=]+)', content) + + if base64_match: + base64_data = base64_match.group(1) + logger.info(f"{api_name} 找到标准格式的Base64数据") + try: + image_data = base64.b64decode(base64_data) + except Exception as e: + logger.error(f"{api_name} Base64解码失败: {e}") + return False, f"Base64解码失败: {e}", {} + else: + # 方法3: 查找纯Base64数据(长字符串) + base64_match = re.search(r'([A-Za-z0-9+/=]{100,})', content) + if base64_match: + base64_data = base64_match.group(1) + logger.info(f"{api_name} 找到纯Base64数据") + try: + image_data = base64.b64decode(base64_data) + except Exception as e: + logger.error(f"{api_name} Base64解码失败: {e}") + return False, f"Base64解码失败: {e}", {} + else: + logger.error(f"{api_name} 在响应中未找到图片数据") + return False, "未找到图片数据", {} + + # 检查图片数据 + if not image_data: + logger.error(f"{api_name} 图片数据为空") + return False, "图片数据为空", {} + + # 保存图片 + return await self._save_image(image_data, output_path, api_name) + + async def correct_perspective( + self, + input_path: str, + output_path: str, + level: str = "mild", + ) -> tuple[bool, str, dict]: + """ + 透视矫正:先把有透视畸变的图还原为正面平铺视图,再做后续处理。 + + Args: + input_path: 本地图片路径 + output_path: 矫正后输出路径 + level: "mild" 或 "strong" + """ + if level == "strong": + prompt = ( + "这张图存在明显透视畸变(俯拍/斜拍/贴墙)。" + "请对图片进行透视矫正:将主体变换为正面平铺视图," + "使所有边缘变成水平或垂直,去除梯形形变," + "保持图案颜色和细节完全不变,只矫正几何形状,输出矫正后的完整图片。" + ) + else: + prompt = ( + "这张图存在轻微透视畸变(衣物悬挂/桌面斜拍)。" + "请做轻度透视矫正:将主体调整为尽量正视角," + "消除轻微的梯形拉伸感,保持图案颜色和细节不变,输出矫正后的图片。" + ) + + # 透视矫正使用 1:1 比例避免比例失真 + return await self.extract_pattern( + input_path=input_path, + output_path=output_path, + custom_prompt=prompt, + aspect_ratio="1:1", + ) + + async def cleanup(self): + """清理资源""" + if self.session and not self.session.closed: + await self.session.close() + +# 便捷函数 +async def extract_pattern_v2( + input_path: str, + output_path: str, + custom_prompt: str = None, + aspect_ratio: str = "1:1", +) -> tuple[bool, str, dict]: + """Gemini V2印花提取便捷函数""" + service = GeminiExtractV2Service() + try: + return await service.extract_pattern(input_path, output_path, custom_prompt, aspect_ratio) + finally: + await service.cleanup() + +if __name__ == "__main__": + # 测试代码 + import asyncio + + async def test(): + service = GeminiExtractV2Service() + + input_path = "F:/api/134.png" + output_path = "test_output_v2.png" + + success, message, data = await service.extract_pattern(input_path, output_path) + + print(f"结果: {success}") + print(f"消息: {message}") + print(f"数据: {data}") + + await service.cleanup() + + asyncio.run(test()) \ No newline at end of file diff --git a/services/service_meitu.py b/services/service_meitu.py new file mode 100644 index 0000000..74e023e --- /dev/null +++ b/services/service_meitu.py @@ -0,0 +1,755 @@ +""" +美图API服务模块 - 处理与美图API的交互 +""" + +import os +import time +import uuid +import json +import asyncio +import logging +import aiohttp +import aiofiles +from pathlib import Path +from typing import Optional, Dict, Any, Callable, List, Tuple + +# 配置日志 +logger = logging.getLogger(__name__) + +# 统计信息 +class MeituServiceStats: + def __init__(self): + self.total_requests = 0 + self.successful_requests = 0 + self.failed_requests = 0 + self.timeout_requests = 0 + self.network_error_requests = 0 + self.last_success_time = None + self.last_error_time = None + self.last_error_message = None + + def record_success(self): + self.total_requests += 1 + self.successful_requests += 1 + self.last_success_time = time.time() + + def record_failure(self, error_type="general", message=""): + self.total_requests += 1 + self.failed_requests += 1 + self.last_error_time = time.time() + self.last_error_message = message + + if error_type == "timeout": + self.timeout_requests += 1 + elif error_type == "network": + self.network_error_requests += 1 + + def get_success_rate(self): + if self.total_requests == 0: + return 0.0 + return self.successful_requests / self.total_requests * 100 + + def get_stats(self): + return { + "total_requests": self.total_requests, + "successful_requests": self.successful_requests, + "failed_requests": self.failed_requests, + "timeout_requests": self.timeout_requests, + "network_error_requests": self.network_error_requests, + "success_rate": self.get_success_rate(), + "last_success_time": self.last_success_time, + "last_error_time": self.last_error_time, + "last_error_message": self.last_error_message + } + +# 全局统计实例 +_service_stats = MeituServiceStats() + +class MeituServiceError(Exception): + """美图服务异常""" + pass + +class MeituTimeoutError(MeituServiceError): + """美图服务超时异常""" + pass + +class MeituNetworkError(MeituServiceError): + """美图服务网络异常""" + pass + +class MeituAPIService: + """美图API服务类,处理与美图API的所有交互""" + + # 服务状态 + _service_status = { + "available": False, + "last_check": 0, + "error": None + } + + # 支持的处理模式 + SUPPORTED_MODES = { + "crystal": "极速重绘", + "standard": "标准处理", + "enhance": "增强处理", + "hdr": "HDR处理", + "portrait": "人像优化" + } + + def __init__(self, api_url: str = None): + """ + 初始化美图API服务 + :param api_url: API基础URL,如果为None则使用环境变量或默认值 + """ + # self.api_url = api_url or os.environ.get('MEITU_API_URL', 'http://89358zi786.goho.co:38226') + self.api_url = api_url or os.environ.get('MEITU_API_URL', 'https://127.0.0.1:6668') # 本地 + self.stats = _service_stats # 使用全局统计实例 + self._active_tasks = set() # 追踪活跃任务,确保高并发安全 + self._task_cancellation_tokens = {} # 任务取消令牌 + + async def process_image(self, + image_path: str, + mode: str, + output_dir: Path, + progress_callback: Optional[Callable[[int, str], None]] = None) -> Dict[str, Any]: + """ + 处理图片 + :param image_path: 图片路径 + :param mode: 处理模式(crystal, standard等) + :param output_dir: 输出目录 + :param progress_callback: 进度回调函数 + :return: 处理结果,包含任务ID和结果图片路径 + """ + # 检查模式是否支持 + if mode not in self.SUPPORTED_MODES: + supported_modes = ", ".join(self.SUPPORTED_MODES.keys()) + raise MeituServiceError(f"不支持的处理模式: {mode},支持的模式: {supported_modes}") + + # 检查图片文件是否存在 + if not os.path.exists(image_path): + raise MeituServiceError(f"图片文件不存在: {image_path}") + + try: + # 生成唯一文件名 + unique_filename = os.path.basename(image_path) + file_extension = os.path.splitext(unique_filename)[1] + + # 上传图片并获取任务ID + task_id = await self._upload_image(image_path, unique_filename, mode) + + if not task_id: + raise MeituServiceError("美图API上传失败,未获取到任务ID") + + # 记录任务ID类型和值 + logger.info(f"获取到任务ID: {task_id}, 类型: {type(task_id)}") + + # 注册活跃任务 + self._active_tasks.add(task_id) + self._task_cancellation_tokens[task_id] = False + + # 轮询等待处理完成 - 确保异常正确传播 + try: + await self._wait_for_completion(task_id, progress_callback) + except (MeituTimeoutError, MeituServiceError) as e: + logger.error(f"美图处理被终止: {str(e)}") + # 立即清理任务并重新抛出异常 + await self.cleanup_failed_task(task_id) + raise + finally: + # 确保任务总是从活跃列表中移除 + self._active_tasks.discard(task_id) + self._task_cancellation_tokens.pop(task_id, None) + logger.info(f"任务{task_id}已从活跃列表中移除") + + # 下载处理后的图片 + processed_filename = f"processed_{task_id}_{int(time.time())}{file_extension}" + processed_path = output_dir / processed_filename + + # 确保输出目录存在 + output_dir.mkdir(exist_ok=True, parents=True) + + # 下载结果图片 + try: + await self._download_result(task_id, processed_path) + except Exception as download_error: + logger.error(f"下载处理结果失败: {str(download_error)}") + raise MeituServiceError(f"下载处理结果失败: {str(download_error)}") + + # 安全处理processing_time计算 + processing_time = 0 + try: + if '_' in task_id: + # 尝试从task_id中提取时间戳 + timestamp_str = task_id.split('_')[-1] + logger.info(f"从task_id提取时间戳: {timestamp_str}, 类型: {type(timestamp_str)}") + + # 安全转换为整数 + try: + timestamp = int(timestamp_str) + processing_time = time.time() - timestamp + logger.info(f"计算处理时间: {processing_time}秒") + except (ValueError, TypeError) as e: + logger.warning(f"时间戳转换失败: {e}") + else: + logger.warning(f"任务ID不包含下划线分隔符: {task_id}") + except Exception as time_error: + logger.warning(f"处理时间计算错误: {str(time_error)}") + + # 记录成功 + self.stats.record_success() + logger.info(f"美图服务处理成功 - 任务ID: {task_id}, 耗时: {processing_time:.2f}秒") + + return { + "task_id": task_id, + "processed_path": processed_path, + "processed_filename": processed_filename, + "mode": mode, + "mode_name": self.SUPPORTED_MODES.get(mode, "未知模式"), + "processing_time": processing_time + } + + except MeituTimeoutError as e: + self.stats.record_failure("timeout", str(e)) + logger.error(f"美图服务超时: {str(e)}") + raise + except MeituNetworkError as e: + self.stats.record_failure("network", str(e)) + logger.error(f"美图服务网络错误: {str(e)}") + raise + except MeituServiceError as e: + self.stats.record_failure("general", str(e)) + logger.error(f"美图服务错误: {str(e)}") + raise + except Exception as e: + self.stats.record_failure("unknown", str(e)) + logger.error(f"美图API处理失败: {str(e)}") + import traceback + logger.error(f"异常堆栈: {traceback.format_exc()}") + raise MeituServiceError(f"美图API处理失败: {str(e)}") + + async def process_batch(self, + image_paths: List[str], + mode: str, + output_dir: Path, + max_concurrent: int = 3) -> List[Dict[str, Any]]: + """ + 批量处理图片 + :param image_paths: 图片路径列表 + :param mode: 处理模式 + :param output_dir: 输出目录 + :param max_concurrent: 最大并发数 + :return: 处理结果列表 + """ + results = [] + semaphore = asyncio.Semaphore(max_concurrent) + + async def process_single(image_path): + async with semaphore: + try: + result = await self.process_image(image_path, mode, output_dir) + return { + "success": True, + "result": result, + "image_path": image_path + } + except Exception as e: + logger.error(f"处理图片失败: {image_path}, 错误: {str(e)}") + return { + "success": False, + "error": str(e), + "image_path": image_path + } + + # 创建任务 + tasks = [process_single(path) for path in image_paths] + + # 等待所有任务完成 + try: + results = await asyncio.gather(*tasks) + except Exception as e: + logger.error(f"批量处理任务异常: {str(e)}") + # 即使有异常,也返回已完成的结果 + results = [{"success": False, "error": f"批量处理异常: {str(e)}", "image_path": path} for path in image_paths] + + return results + + async def _upload_image(self, image_path: str, filename: str, mode: str) -> str: + """ + 上传图片到美图API + :param image_path: 图片路径 + :param filename: 文件名 + :param mode: 处理模式 + :return: 任务ID + """ + logger.info(f"上传图片到美图API - 文件: {filename}, 模式: {mode}") + + # 检查文件大小 + file_size = os.path.getsize(image_path) + if file_size > 10 * 1024 * 1024: # 10MB + raise MeituServiceError(f"文件太大: {file_size / 1024 / 1024:.2f}MB,最大允许10MB") + + # 检查文件类型 + content_type = await self._get_content_type(filename) + if not content_type.startswith('image/'): + raise MeituServiceError(f"不支持的文件类型: {content_type}") + + # 重试逻辑 + max_retries = 3 + retry_count = 0 + last_error = None + + while retry_count < max_retries: + try: + # 设置上传专用的超时配置 + upload_timeout = aiohttp.ClientTimeout(total=60, connect=10) # 上传允许更长时间 + async with aiohttp.ClientSession(timeout=upload_timeout) as session: + with open(image_path, 'rb') as f: + data = aiohttp.FormData() + data.add_field(filename, + f.read(), + filename=filename, + content_type=content_type) + + async with session.post(f'{self.api_url}/add_task?scene={mode}', data=data, ssl=False) as resp: + if resp.status != 200: + error_text = await resp.text() + logger.error(f"美图API上传失败 - 状态码: {resp.status}, 响应: {error_text}") + raise MeituServiceError(f"美图API上传失败: {error_text}") + + response_text = await resp.text() + logger.info(f"美图API上传响应: {response_text}") + + try: + result = json.loads(response_text) + if result.get('code') != 0: + raise MeituServiceError(result.get('message', '美图API上传失败')) + + task_id = result.get('id') + logger.info(f"美图API上传成功 - 任务ID: {task_id}") + return task_id + except json.JSONDecodeError: + logger.error(f"美图API响应解析失败: {response_text}") + raise MeituServiceError("美图API响应格式错误") + except Exception as e: + retry_count += 1 + last_error = e + wait_time = 2 ** retry_count # 指数退避 + logger.warning(f"上传失败,正在重试 ({retry_count}/{max_retries}),等待 {wait_time} 秒: {str(e)}") + await asyncio.sleep(wait_time) + + # 所有重试都失败 + raise last_error or MeituServiceError("上传图片失败,已达到最大重试次数") + + async def _wait_for_completion(self, + task_id: str, + progress_callback: Optional[Callable[[int, str], None]] = None, + max_wait_time: int = 180, # 最长等待3分钟(缩短超时时间) + check_interval: int = 2 # 每2秒检查一次 + ) -> None: + """ + 轮询等待处理完成 + :param task_id: 任务ID + :param progress_callback: 进度回调函数 + :param max_wait_time: 最长等待时间(秒) + :param check_interval: 检查间隔(秒) + """ + logger.info(f"开始等待美图API处理完成 - 任务ID: {task_id}") + + start_time = time.time() + progress = 0 + + # 连续失败计数 + consecutive_failures = 0 + max_consecutive_failures = 3 # 减少连续失败次数,更快失败 + + # 早期检测到的致命错误类型 + detected_fatal_errors = set() + + # 创建当前任务的取消令牌 + is_cancelled = False + + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: + for i in range(max_wait_time // check_interval): + # 检查是否被取消 - 支持外部取消令牌 + if is_cancelled or self._task_cancellation_tokens.get(task_id, False): + logger.error(f"美图处理任务被取消: {task_id}") + await self.cleanup_failed_task(task_id) + raise MeituTimeoutError("美图处理任务被取消") + + # 检查任务是否还在活跃列表中 + if task_id not in self._active_tasks: + logger.warning(f"任务{task_id}不在活跃列表中,可能已被清理") + raise MeituTimeoutError("任务已被停止") + + # 计算预估进度 + elapsed = time.time() - start_time + if elapsed < max_wait_time: + # 假设进度在90%以内线性增长 + progress = min(90, int(90 * elapsed / max_wait_time)) + + if progress_callback: + progress_callback(progress, f"美图API处理中 - {progress}%") + + try: + # 在每次API调用前再次检查取消状态 + if self._task_cancellation_tokens.get(task_id, False): + logger.info(f"任务{task_id}在API调用前被取消") + raise MeituTimeoutError("任务被取消") + + async with session.get(f'{self.api_url}/try_get?taskid={task_id}', ssl=False) as resp: + if resp.status != 200: + consecutive_failures += 1 + error_text = await resp.text() + logger.error(f"美图API状态检查失败 - 状态码: {resp.status}, 响应: {error_text}") + + # 如果连续失败次数过多,抛出异常 + if consecutive_failures >= max_consecutive_failures: + logger.error(f"美图API连续失败{consecutive_failures}次,尝试清理任务") + await self.cleanup_failed_task(task_id) + raise MeituTimeoutError(f"美图API连续失败{consecutive_failures}次: {error_text}") + + # 否则继续等待 + await asyncio.sleep(check_interval * 2) # 失败时等待更长时间 + continue + + # 重置失败计数 + consecutive_failures = 0 + + # 获取响应内容 + try: + response_text = await resp.text() + logger.debug(f"美图API状态检查响应: {response_text}") + + # 解析JSON响应 + try: + result = json.loads(response_text) + logger.debug(f"解析的JSON响应: {result}") + + # 获取code字段 + if 'code' not in result: + logger.warning(f"美图API响应中缺少code字段: {result}") + # 继续等待,不抛出异常 + await asyncio.sleep(check_interval) + continue + + # 安全获取code值 + try: + code = result['code'] + # 确保code是整数 + if not isinstance(code, (int, float)): + code = int(code) if str(code).isdigit() else -1 + except (ValueError, TypeError) as e: + logger.warning(f"无法转换code为整数: {e}, 原始值: {result.get('code')}") + code = -1 + + # 检查处理状态 + if code < 0: + error_message = str(result.get('message', '美图API处理失败')) + logger.error(f"美图API处理失败: {error_message}") + + # 检查是否是超时或严重错误,如果是则立即终止 + timeout_indicators = [ + "TimeoutException", + "selenium.common.exceptions.TimeoutException", + "WebDriver", + "已终止处理", + "处理超时", + "连接超时", + "响应超时" + ] + + is_timeout = any(indicator in error_message for indicator in timeout_indicators) + + if is_timeout: + logger.error(f"检测到严重错误/超时,立即终止美图处理: {error_message}") + raise MeituTimeoutError("美图处理超时或遇到严重错误,已终止处理") + + raise MeituServiceError(error_message) + elif code == 0: + logger.info(f"美图API处理完成 - 任务ID: {task_id}") + if progress_callback: + progress_callback(100, "美图API处理完成") + return + else: + # 其他状态码,继续等待 + logger.info(f"美图API处理中 - 状态码: {code}") + except json.JSONDecodeError as e: + logger.warning(f"JSON解析失败: {e}, 响应内容: {response_text[:100]}...") + # 继续等待,不抛出异常 + except Exception as e: + error_str = str(e) + logger.error(f"处理响应内容异常: {error_str}") + import traceback + logger.error(f"异常堆栈: {traceback.format_exc()}") + + # 检查是否是致命错误,如果是则立即终止 + fatal_error_indicators = [ + "MeituTimeoutError", + "美图处理超时", + "已终止处理", + "WebDriver", + "TimeoutException" + ] + + is_fatal = any(indicator in error_str for indicator in fatal_error_indicators) + if is_fatal: + # 记录检测到的致命错误类型 + for indicator in fatal_error_indicators: + if indicator in error_str: + detected_fatal_errors.add(indicator) + + logger.error(f"检测到致命错误,立即终止处理: {error_str}") + logger.error(f"已检测到的致命错误类型: {detected_fatal_errors}") + is_cancelled = True # 设置取消标志 + await self.cleanup_failed_task(task_id) # 立即清理 + raise MeituTimeoutError(f"美图处理遇到致命错误: {error_str}") + + # 增加连续失败计数 + consecutive_failures += 1 + if consecutive_failures >= max_consecutive_failures: + logger.error(f"连续失败{consecutive_failures}次,终止处理") + raise MeituServiceError(f"美图处理连续失败{consecutive_failures}次") + except asyncio.TimeoutError: + consecutive_failures += 1 + logger.error(f"美图API请求超时 - 第{consecutive_failures}次") + + # 如果连续超时次数过多,抛出异常 + if consecutive_failures >= max_consecutive_failures: + logger.error(f"美图API连续超时{consecutive_failures}次,尝试清理任务") + await self.cleanup_failed_task(task_id) + raise MeituTimeoutError(f"美图API连续超时{consecutive_failures}次,任务可能已失败") + + await asyncio.sleep(check_interval * 2) + continue + except aiohttp.ClientError as e: + consecutive_failures += 1 + logger.error(f"网络请求异常: {str(e)} - 第{consecutive_failures}次") + + # 如果连续失败次数过多,抛出异常 + if consecutive_failures >= max_consecutive_failures: + logger.error(f"美图API网络连接连续失败{consecutive_failures}次,尝试清理任务") + await self.cleanup_failed_task(task_id) + raise MeituNetworkError(f"美图API网络连接连续失败{consecutive_failures}次") + + await asyncio.sleep(check_interval * 2) + continue + except Exception as e: + if isinstance(e, MeituServiceError): + raise + + consecutive_failures += 1 + logger.error(f"美图API状态检查异常: {str(e)} - 第{consecutive_failures}次") + import traceback + logger.error(f"异常堆栈: {traceback.format_exc()}") + + # 如果连续失败次数过多,抛出异常 + if consecutive_failures >= max_consecutive_failures: + logger.error(f"美图API状态检查连续异常{consecutive_failures}次,尝试清理任务") + await self.cleanup_failed_task(task_id) + raise MeituServiceError(f"美图API状态检查连续异常{consecutive_failures}次: {str(e)}") + + await asyncio.sleep(check_interval * 2) + continue + + await asyncio.sleep(check_interval) + + # 超时 + logger.error(f"美图API处理超时({max_wait_time}秒),尝试清理任务") + await self.cleanup_failed_task(task_id) + raise MeituTimeoutError(f"美图API处理超时({max_wait_time}秒) - 任务ID: {task_id}") + + async def _download_result(self, task_id: str, output_path: Path) -> None: + """ + 下载处理结果 + :param task_id: 任务ID + :param output_path: 输出路径 + """ + logger.info(f"开始下载美图API处理结果 - 任务ID: {task_id}, 输出路径: {output_path}") + + # 确保输出目录存在 + output_path.parent.mkdir(exist_ok=True, parents=True) + + # 重试逻辑 + max_retries = 3 + retry_count = 0 + last_error = None + + while retry_count < max_retries: + try: + # 设置下载专用的超时配置 + download_timeout = aiohttp.ClientTimeout(total=120, connect=10) # 下载允许更长时间 + async with aiohttp.ClientSession(timeout=download_timeout) as session: + async with session.get(f'{self.api_url}/get_image?taskid={task_id}', ssl=False) as resp: + if resp.status != 200: + error_text = await resp.text() + logger.error(f"美图API下载失败 - 状态码: {resp.status}, 响应: {error_text}") + raise MeituServiceError(f"美图API下载失败: {error_text}") + + # 读取图片数据 + image_data = await resp.read() + if not image_data: + raise MeituServiceError("美图API返回空图片数据") + + # 保存图片 + async with aiofiles.open(output_path, 'wb') as f: + await f.write(image_data) + + # 安全获取文件大小 + try: + file_size = len(image_data) + logger.info(f"美图API结果下载成功 - 任务ID: {task_id}, 文件大小: {file_size} 字节") + except Exception as size_error: + logger.warning(f"获取文件大小失败: {str(size_error)}") + logger.info(f"美图API结果下载成功 - 任务ID: {task_id}") + + # 验证文件是否成功保存 + if not output_path.exists() or output_path.stat().st_size == 0: + raise MeituServiceError("图片保存失败或文件大小为0") + + return + except MeituServiceError: + # 直接抛出服务特定错误 + raise + except Exception as e: + retry_count += 1 + last_error = e + wait_time = 2 ** retry_count # 指数退避 + logger.warning(f"下载失败,正在重试 ({retry_count}/{max_retries}),等待 {wait_time} 秒: {str(e)}") + await asyncio.sleep(wait_time) + + # 所有重试都失败 + error_msg = str(last_error) if last_error else "未知错误" + logger.error(f"下载结果失败,已达到最大重试次数: {error_msg}") + raise MeituServiceError(f"下载结果失败,已达到最大重试次数: {error_msg}") + + @staticmethod + async def _get_content_type(filename: str) -> str: + """ + 根据文件名获取内容类型 + :param filename: 文件名 + :return: 内容类型 + """ + ext = os.path.splitext(filename)[1].lower() + content_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp' + } + return content_types.get(ext, 'application/octet-stream') + + @classmethod + async def test_connection(cls, api_url: str = None) -> Tuple[bool, str]: + """ + 测试与美图API的连接 + :param api_url: API URL,如果为None则使用默认值 + :return: (连接是否成功, 状态消息) + """ + # 如果最后检查时间在30秒内,直接返回缓存的状态 + if time.time() - cls._service_status["last_check"] < 30: + return ( + cls._service_status["available"], + "服务在线" if cls._service_status["available"] else f"服务离线: {cls._service_status['error']}" + ) + + service = MeituAPIService(api_url) + try: + async with aiohttp.ClientSession() as session: + # 尝试健康检查端点 + try: + async with session.get(f'{service.api_url}/health', ssl=False, timeout=5) as resp: + if resp.status == 200: + cls._service_status = { + "available": True, + "last_check": time.time(), + "error": None + } + return True, "服务在线" + except: + # 健康检查失败,尝试其他端点 + pass + + # 尝试调用API的其他端点 + try: + async with session.get(f'{service.api_url}/', ssl=False, timeout=5) as resp: + cls._service_status = { + "available": resp.status < 500, + "last_check": time.time(), + "error": f"状态码: {resp.status}" if resp.status >= 400 else None + } + return cls._service_status["available"], "服务可能在线,但健康检查失败" + except Exception as e: + cls._service_status = { + "available": False, + "last_check": time.time(), + "error": str(e) + } + return False, f"服务离线: {str(e)}" + except Exception as e: + cls._service_status = { + "available": False, + "last_check": time.time(), + "error": str(e) + } + logger.error(f"美图API连接测试失败: {str(e)}") + return False, f"服务离线: {str(e)}" + + async def cancel_task(self, task_id: str) -> bool: + """取消处理任务""" + try: + # 设置取消任务的超时配置 + cancel_timeout = aiohttp.ClientTimeout(total=15, connect=5) # 取消操作超时短一些 + async with aiohttp.ClientSession(timeout=cancel_timeout) as session: + async with session.post(f'{self.api_url}/cancel_task', + data={'taskid': task_id}, + ssl=False) as resp: + if resp.status == 200: + logger.info(f"任务取消成功: {task_id}") + return True + else: + logger.warning(f"任务取消失败: {task_id}, 状态码: {resp.status}") + return False + except Exception as e: + logger.error(f"取消任务异常: {task_id}, 错误: {str(e)}") + return False + + async def cancel_all_tasks(self) -> None: + """取消所有活跃任务""" + logger.warning(f"取消所有活跃任务,共{len(self._active_tasks)}个") + + for task_id in list(self._active_tasks): + self._task_cancellation_tokens[task_id] = True + await self.cleanup_failed_task(task_id) + + self._active_tasks.clear() + self._task_cancellation_tokens.clear() + + async def cleanup_failed_task(self, task_id: str) -> None: + """清理失败的任务""" + try: + # 尝试取消任务 + await self.cancel_task(task_id) + + # 清理可能的临时文件 + # 这里可以添加更多清理逻辑 + logger.info(f"失败任务清理完成: {task_id}") + except Exception as e: + logger.warning(f"清理失败任务异常: {task_id}, 错误: {str(e)}") + + @classmethod + def get_service_stats(cls) -> Dict[str, Any]: + """获取服务统计信息""" + return _service_stats.get_stats() + + @classmethod + def reset_stats(cls) -> None: + """重置服务统计信息""" + global _service_stats + _service_stats = MeituServiceStats() + + @classmethod + def get_supported_modes(cls) -> Dict[str, str]: + """获取支持的处理模式""" + return cls.SUPPORTED_MODES \ No newline at end of file diff --git a/services/service_qwen.py b/services/service_qwen.py new file mode 100644 index 0000000..c1e51e5 --- /dev/null +++ b/services/service_qwen.py @@ -0,0 +1,219 @@ +import time +import asyncio +import aiohttp +from PIL import Image +from pydantic import BaseModel +import logging + +logger = logging.getLogger(__name__) + +api_key = '8e32d44e3007447cb4be6ee52c5d3110' + + +class UploadInfo(BaseModel): + fileName: str + fileType: str + + +class CreateInfo(BaseModel): + taskId: str # 创建的任务 ID,可用于查询状态或获取结果 + taskStatus: str # 初始状态,可能为:QUEUED、RUNNING、FAILED + clientId: str # 平台内部标识,用于排错,无需关注 + netWssUrl: str # WebSocket 地址(当前不稳定,不推荐使用) + promptTips: str # ComfyUI 校验信息(字符串格式的 JSON),可用于识别配置异常节点 + + +class RunHubResponse(BaseModel): + code: int # 状态码,0 表示成功 + msg: str # 提示信息 + data: UploadInfo | CreateInfo | str | None = None # 数据对象 + + class Config: + extra = 'allow' # 允许添加额外字段 + + +async def upload(img_path: str) -> RunHubResponse: + with open(img_path, 'rb') as f: + img_data = f.read() + + form = aiohttp.FormData() + form.add_field('apiKey', api_key) + form.add_field('file', img_data, filename='image.jpg', content_type='image/jpeg') + form.add_field('fileType', 'image') + + url = 'https://www.runninghub.cn/task/openapi/upload' + + async with aiohttp.ClientSession() as session: + async with session.post(url, data=form) as resp: + response = await resp.json() + + return RunHubResponse.model_validate(response) + + +async def create(workflow_id: str, node_info_list: list[dict[str, str]]) -> RunHubResponse: + url = 'https://www.runninghub.cn/task/openapi/create' + json_data = {'apiKey': api_key, 'workflowId': workflow_id, 'nodeInfoList': node_info_list} + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=json_data) as resp: + response = await resp.json() + + return RunHubResponse.model_validate(response) + + +async def status(task_id: str) -> RunHubResponse: + # 查询状态 + url = 'https://www.runninghub.cn/task/openapi/status' + payload = {'apiKey': api_key, 'taskId': task_id} + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload) as resp: + response = await resp.json() + + # ["QUEUED","RUNNING","FAILED","SUCCESS"] + return RunHubResponse.model_validate(response) + + +async def outputs(task_id: str) -> dict: + # 获取结果 + url = 'https://www.runninghub.cn/task/openapi/outputs' + payload = {'apiKey': api_key, 'taskId': task_id} + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload) as resp: + response = await resp.json() + + return response + + +async def 花纹提取_api(img_path: str, save_path: str, prompt: str = '') -> bool: + """ + 异步花纹提取API + + Args: + img_path: 输入图片路径 + save_path: 输出图片路径 + prompt: 自定义提示词,为空则使用默认提示词 + + Returns: + bool: 处理是否成功 + """ + try: + upload_res = await upload(img_path=img_path) + if upload_res.code != 0 or not upload_res.data: + logger.error(f"Qwen上传失败: code={upload_res.code}, msg={upload_res.msg}") + return False + + # 确保 data 是 UploadInfo 类型 + if not hasattr(upload_res.data, 'fileName'): + logger.error(f"Qwen上传返回数据格式错误: {upload_res.data}") + return False + + logger.info(f"Qwen上传成功: {upload_res.data.fileName}") + + workflow_id = '1980864078929379330' + if len(prompt) == 0: + prompt = '提取桌布上的花纹,自动补全空白,使得所有位置饱满并且完美衔接,去除所有的皱纹和扭曲和凸凹不平,图案自动摆正对齐并且铺平,使直线变得笔直,平行的花纹更有规律,没有残缺的花纹和折痕和断痕,铺满画布,完整的图案。简单的纯色背景' + + node_info_list = [ + { + 'nodeId': '78', + 'fieldName': 'image', + 'fieldValue': upload_res.data.fileName, + }, + { + 'nodeId': '103', + 'fieldName': 'text', + 'fieldValue': prompt, + }, + ] + create_res = await create(workflow_id=workflow_id, node_info_list=node_info_list) + + if create_res.code != 0 or not create_res.data: + logger.error(f"Qwen任务创建失败: code={create_res.code}, msg={create_res.msg}") + return False + + # 确保 data 是 CreateInfo 类型 + if not hasattr(create_res.data, 'taskId'): + logger.error(f"Qwen任务创建返回数据格式错误: {create_res.data}") + return False + + task_id = create_res.data.taskId + logger.info(f"Qwen任务创建成功: {task_id}") + + # 轮询检查状态 + max_retries = 120 # 最多等待10分钟(120次 * 5秒) + retry_count = 0 + + while retry_count < max_retries: + status_res = await status(task_id=task_id) + if status_res.code == 0: + if status_res.data == 'QUEUED': + logger.info('Qwen队列排队中...') + elif status_res.data == 'RUNNING': + logger.info('Qwen正在处理中...') + elif status_res.data == 'FAILED': + logger.error(f'Qwen处理失败: {status_res}') + return False + elif status_res.data == 'SUCCESS': + logger.info('Qwen处理完成,开始下载结果') + outputs_res = await outputs(task_id=task_id) + img_url = outputs_res['data'][0]['fileUrl'] + + # 下载结果图片 + async with aiohttp.ClientSession() as session: + async with session.get(img_url) as resp: + img_data = await resp.read() + + with open(save_path, 'wb') as f: + f.write(img_data) + + logger.info(f"Qwen结果保存成功: {save_path}") + try: + from utils.api_cost_tracker import record + record("qwen_enhance", count=1) + except Exception: + pass + return True + + await asyncio.sleep(5) # 每5秒检查一次 + retry_count += 1 + else: + logger.error(f'Qwen处理失败: {status_res}') + return False + + logger.error(f"Qwen处理超时,超过{max_retries * 5}秒") + return False + + except Exception as e: + logger.error(f"Qwen花纹提取异常: {e}") + import traceback + logger.error(f"异常堆栈: {traceback.format_exc()}") + return False + + +async def 清晰化_api(img_path: str, save_path: str) -> bool: + """ + 高清增强:对透视矫正后的图案进行清晰化处理。 + 使用与花纹提取相同的 ComfyUI 工作流,但提示词聚焦于清晰度增强。 + + Args: + img_path: 输入图片路径(透视矫正后的结果) + save_path: 输出图片路径 + + Returns: + bool: 处理是否成功 + """ + prompt = ( + "对这张已展平的图案进行高清增强处理:" + "提升整体清晰度和锐利度,修复模糊边缘,补全细节纹理," + "使图案线条清晰笔直,颜色鲜艳均匀," + "去除噪点和压缩痕迹,输出印刷级高质量平面图," + "背景保持纯白色,不要改变图案内容和构图。" + ) + return await 花纹提取_api(img_path=img_path, save_path=save_path, prompt=prompt) + + +# 测试代码(注释掉) +# if __name__ == "__main__": +# asyncio.run(花纹提取_api(img_path=r'1.jpg', save_path='save1.png', prompt='')) \ No newline at end of file diff --git a/services/service_vectorizer.py b/services/service_vectorizer.py new file mode 100644 index 0000000..ff787d7 --- /dev/null +++ b/services/service_vectorizer.py @@ -0,0 +1,433 @@ +""" +矢量化服务模块 - 使用统一异常处理机制 +""" + +import aiohttp +import time +import urllib3 +from typing import Callable, Optional, Dict, Any +import logging +import os +import asyncio +from pathlib import Path + +# 导入基础服务类 +from utils.service_base import ( + BaseService, PollingMixin, ServiceError, ServiceTimeoutError, + ServiceNetworkError, RetryConfig, TimeoutConfig +) + +# 禁用SSL警告 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +logger = logging.getLogger(__name__) + + +class VectorizerServiceError(ServiceError): + """矢量化服务特定异常""" + pass + + +class VectorizerService(BaseService, PollingMixin): + """矢量化服务类 - 继承统一异常处理机制""" + + # def __init__(self, base_url: str = "https://frp-dad.com:50529"): + def __init__(self, base_url: str = "https://127.0.0.1:8090"): + # 配置重试和超时 + retry_config = RetryConfig( + max_retries=3, + base_delay=2.0, + max_delay=30.0 + ) + + timeout_config = TimeoutConfig( + connection_timeout=60.0, + read_timeout=240.0, + total_timeout=1200.0 + ) + + super().__init__( + name="VectorizerService", + base_url=base_url, + retry_config=retry_config, + timeout_config=timeout_config + ) + + async def image_to_eps(self, + image_path: str, + save_eps_path: Optional[str] = None, + timeout: int = 1200, + poll_interval: float = 2.0, + status_callback: Optional[Callable[[str, dict], None]] = None) -> str: + """ + 将图片转换为EPS矢量文件 + + Args: + image_path: 输入图片路径 + save_eps_path: 输出EPS文件路径(可选) + timeout: 最大等待时间(秒) + poll_interval: 轮询间隔(秒) + status_callback: 状态回调函数 + + Returns: + str: EPS文件保存路径 + + Raises: + VectorizerServiceError: 矢量化服务异常 + ServiceTimeoutError: 超时异常 + ServiceNetworkError: 网络异常 + """ + # 验证输入文件 + if not os.path.exists(image_path): + raise VectorizerServiceError(f"输入图片文件不存在: {image_path}") + + # 设置输出路径 + if save_eps_path is None: + save_eps_path = os.path.splitext(image_path)[0] + '.eps' + + # 确保输出目录存在 + output_dir = os.path.dirname(save_eps_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + try: + # 1. 上传图片 + task_id = await self.execute_with_retry( + self._upload_image, + image_path, + status_callback, + error_context=" - 上传图片" + ) + + # 2. 轮询等待处理完成 + await self.execute_with_retry( + self._wait_for_processing, + task_id, + timeout, + poll_interval, + status_callback, + error_context=" - 等待处理完成" + ) + + # 3. 下载结果文件 + await self.execute_with_retry( + self._download_result, + task_id, + save_eps_path, + status_callback, + error_context=" - 下载结果文件" + ) + + # 验证输出文件 + if not os.path.exists(save_eps_path) or os.path.getsize(save_eps_path) == 0: + raise VectorizerServiceError(f"输出文件创建失败或为空: {save_eps_path}") + + self.logger.info(f"矢量化转换成功: {image_path} -> {save_eps_path}") + + if status_callback: + status_callback('finished', { + 'message': '转换完成!', + 'input_path': image_path, + 'output_path': save_eps_path + }) + + return save_eps_path + + except (ServiceTimeoutError, ServiceNetworkError, VectorizerServiceError): + # 直接传递这些异常 + if status_callback: + status_callback('error', {'message': '处理失败,请稍后重试'}) + raise + except Exception as e: + error_msg = f"矢量化转换失败: {str(e)}" + self.logger.error(error_msg) + if status_callback: + status_callback('error', {'message': error_msg}) + raise VectorizerServiceError(error_msg) + + async def _upload_image(self, + image_path: str, + status_callback: Optional[Callable[[str, dict], None]] = None) -> str: + """上传图片到矢量化服务""" + if status_callback: + status_callback('uploading', {'message': '正在上传图片...', 'image_path': image_path}) + + async with await self.create_http_session() as session: + with open(image_path, 'rb') as f: + data = aiohttp.FormData() + data.add_field('file', f, filename=os.path.basename(image_path)) + + async with session.post(f"{self.base_url}/add_task", data=data, ssl=False) as resp: + if resp.status != 200: + error_text = await resp.text() + raise VectorizerServiceError(f"上传失败 - HTTP {resp.status}: {error_text}") + + response_data = await resp.json() + + if response_data.get('code') != 0: + error_msg = response_data.get('message', '未知错误') + raise VectorizerServiceError(f"上传失败: {error_msg}") + + task_id = response_data.get('id') or response_data.get('taskid') + if not task_id: + raise VectorizerServiceError("上传失败,未获取到任务ID") + + self.logger.info(f"图片上传成功,任务ID: {task_id}") + + if status_callback: + status_callback('uploaded', { + 'message': '图片上传成功,开始处理...', + 'taskid': task_id + }) + + return task_id + + async def _wait_for_processing(self, + task_id: str, + timeout: int, + poll_interval: float, + status_callback: Optional[Callable[[str, dict], None]] = None): + """等待处理完成""" + start_time = time.time() + poll_count = 0 + consecutive_failures = 0 + max_consecutive_failures = 5 + + async with await self.create_http_session() as session: + while True: + poll_count += 1 + elapsed_time = time.time() - start_time + + # 检查超时 + if elapsed_time > timeout: + await self.cleanup_failed_task(task_id) + raise ServiceTimeoutError(f"处理超时 (超过{timeout}秒) - 任务ID: {task_id}") + + if status_callback: + progress = min(90, int(90 * elapsed_time / timeout)) + status_callback('processing', { + 'message': f'正在处理中... (第{poll_count}次检查)', + 'taskid': task_id, + 'elapsed_time': elapsed_time, + 'poll_count': poll_count, + 'progress': progress + }) + + try: + async with session.get(f"{self.base_url}/try_get", + params={'taskid': task_id}, + ssl=False) as resp: + if resp.status != 200: + consecutive_failures += 1 + error_text = await resp.text() + self.logger.warning(f"状态查询失败 - HTTP {resp.status}: {error_text}") + + if consecutive_failures >= max_consecutive_failures: + await self.cleanup_failed_task(task_id) + raise VectorizerServiceError(f"连续{consecutive_failures}次状态查询失败") + + await asyncio.sleep(poll_interval * 2) # 失败时等待更长时间 + continue + + # 重置失败计数 + consecutive_failures = 0 + + result = await resp.json() + + if result.get('code') == 0: + # 处理完成 + self.logger.info(f"任务处理完成 - 任务ID: {task_id}, 耗时: {elapsed_time:.2f}秒") + if status_callback: + status_callback('completed', { + 'message': '处理完成,准备下载...', + 'taskid': task_id, + 'total_time': elapsed_time, + 'poll_count': poll_count + }) + return + + elif result.get('code') == -1: + # 处理失败 + error_msg = result.get('message', '处理失败') + await self.cleanup_failed_task(task_id) + raise VectorizerServiceError(f"服务器处理失败: {error_msg}") + + # 其他状态码,继续等待 + self.logger.debug(f"任务处理中 - 任务ID: {task_id}, 状态码: {result.get('code')}") + + except VectorizerServiceError: + # 直接传递服务异常 + raise + except Exception as e: + consecutive_failures += 1 + self.logger.warning(f"轮询检查异常 (第{consecutive_failures}次): {str(e)}") + + if consecutive_failures >= max_consecutive_failures: + await self.cleanup_failed_task(task_id) + raise VectorizerServiceError(f"连续{consecutive_failures}次轮询异常: {str(e)}") + + await asyncio.sleep(poll_interval) + + async def _download_result(self, + task_id: str, + save_path: str, + status_callback: Optional[Callable[[str, dict], None]] = None): + """下载处理结果""" + if status_callback: + status_callback('downloading', {'message': '正在下载EPS文件...', 'taskid': task_id}) + + async with await self.create_http_session() as session: + async with session.get(f"{self.base_url}/get_image", + params={'taskid': task_id}, + ssl=False) as resp: + if resp.status != 200: + error_text = await resp.text() + raise VectorizerServiceError(f"下载失败 - HTTP {resp.status}: {error_text}") + + # 检查内容长度 + content_length = resp.headers.get('Content-Length') + if content_length and int(content_length) == 0: + raise VectorizerServiceError("下载的文件为空") + + # 使用临时文件确保原子写入 + temp_path = save_path + '.tmp' + try: + with open(temp_path, 'wb') as f: + bytes_written = 0 + while True: + chunk = await resp.content.read(8192) + if not chunk: + break + f.write(chunk) + bytes_written += len(chunk) + + # 验证文件大小 + if bytes_written == 0: + raise VectorizerServiceError("下载的文件为空") + + # 原子移动到最终位置 + os.rename(temp_path, save_path) + + self.logger.info(f"文件下载成功: {save_path}, 大小: {bytes_written} 字节") + + except Exception as e: + # 清理临时文件 + if os.path.exists(temp_path): + os.remove(temp_path) + raise VectorizerServiceError(f"文件下载失败: {str(e)}") + + async def get_system_status(self) -> Dict[str, Any]: + """获取系统状态""" + return await self.execute_with_retry( + self._do_get_system_status, + error_context=" - 获取系统状态" + ) + + async def _do_get_system_status(self) -> Dict[str, Any]: + """执行系统状态查询""" + async with await self.create_http_session() as session: + async with session.get(f"{self.base_url}/status", ssl=False) as resp: + if resp.status != 200: + error_text = await resp.text() + raise VectorizerServiceError(f"获取系统状态失败 - HTTP {resp.status}: {error_text}") + + return await resp.json() + + async def _do_health_check(self) -> bool: + """执行健康检查""" + try: + status = await self._do_get_system_status() + return True + except Exception as e: + self.logger.warning(f"健康检查失败: {str(e)}") + return False + + async def cleanup_failed_task(self, task_id: str) -> None: + """清理失败的任务""" + try: + # 尝试取消任务(如果服务支持) + async with await self.create_http_session() as session: + async with session.delete(f"{self.base_url}/cancel_task", + params={'taskid': task_id}, + ssl=False) as resp: + if resp.status == 200: + self.logger.info(f"任务取消成功: {task_id}") + else: + self.logger.warning(f"任务取消失败: {task_id}, HTTP {resp.status}") + except Exception as e: + self.logger.warning(f"清理任务异常: {task_id}, 错误: {str(e)}") + + await super().cleanup_failed_task(task_id) + + async def _do_health_check(self) -> bool: + """执行健康检查""" + try: + async with self.create_http_session() as session: + # 测试系统状态端点 + async with session.get(f"{self.base_url}/system/status", ssl=False) as resp: + if resp.status == 200: + data = await resp.json() + self.logger.debug(f"矢量化服务健康检查成功: {data}") + return True + else: + self.logger.warning(f"矢量化服务健康检查失败: HTTP {resp.status}") + return False + except Exception as e: + self.logger.warning(f"矢量化服务健康检查异常: {e}") + return False + + @classmethod + async def test_connection(cls, base_url: Optional[str] = None) -> tuple[bool, str]: + """测试矢量化服务连接""" + service = cls(base_url) if base_url else cls() + + try: + is_available, message = await service.test_connection() + if is_available: + # 额外测试系统状态 + status = await service.get_system_status() + return True, f"连接成功: {status}" + else: + return False, message + except Exception as e: + logger.error(f"矢量化服务连接失败: {e}") + return False, f"连接失败: {str(e)}" + + # 实现 PollingMixin 的抽象方法 + def _is_task_complete(self, result: Any) -> bool: + """检查任务是否完成""" + return result.get('code') == 0 + + def _is_task_failed(self, result: Any) -> bool: + """检查任务是否失败""" + return result.get('code') == -1 + + def _get_error_message(self, result: Any) -> str: + """获取错误消息""" + return result.get('message', '未知错误') + + +# 为了保持向后兼容性,保留原有的简单接口 +async def vectorize_image(image_path: str, + save_eps_path: Optional[str] = None, + timeout: int = 1200, + progress_callback: Optional[Callable[[str, dict], None]] = None) -> str: + """ + 简单的矢量化接口(向后兼容) + + Args: + image_path: 输入图片路径 + save_eps_path: 输出EPS文件路径 + timeout: 超时时间 + progress_callback: 进度回调函数 + + Returns: + str: EPS文件路径 + """ + service = VectorizerService() + return await service.image_to_eps( + image_path=image_path, + save_eps_path=save_eps_path, + timeout=timeout, + status_callback=progress_callback + ) \ No newline at end of file diff --git a/skills/customer-service/SKILL.md b/skills/customer-service/SKILL.md new file mode 100644 index 0000000..0504aab --- /dev/null +++ b/skills/customer-service/SKILL.md @@ -0,0 +1,128 @@ +--- +name: customer-service +description: 找原图店客服 - 售前咨询、报价成交、售后处理 +--- + +# 客服技能文档 + +核心原则:**快、准、狠** + +> **像真人聊天,不要套模板。** 每次说法自然变化,跟着客户语气走,禁止背台词、固定句式。 + +--- + +## 【重要】业务背景 + +店铺类目是"找原图/高清图"。 + +对客户就一个逻辑:**拍下发你,不满意退** + +绝对不说: +- "原图" / "找到了" / "有原图" +- "做" / "处理" / "制作" / "修图" / "AI弄的" + +**擦边/黄色/敏感内容**:直接拒绝,不接单,不说「发图来看看」。回复如:这类不做/不接。 + +--- + +## ① 打招呼 / 问在不在 + +自然回应,不要上来就推销。换着说。 + +--- + +## ② 问价 / 问能不能找 / 问能不能做(没发图) + +没看到图不报价,自然引导发图。 + +--- + +## ③ 问"还可以做XX吗" / "可以高清修复吗" / "能弄XX吗" + +先肯定能做,再引导发图。不要只说"可以"就不管了。 + +--- + +## ④ 客户给 QQ / 微信号,叫加外部联系方式 + +**绝对不要去加**,自然引导留在平台发图,或发店铺邮箱 357805318@qq.com。 + +--- + +## ⑤ 收到图片 → 报价 → 推成交 + +等识别结果回来再报价,不要猜。等待时自然说稍等/我看看。 + +**报价标准**(价格必须为5的整数倍:10/15/20/25/30): +| 识别结果 | 价格 | +|---------|------| +| simple(简单) | 10-15元 | +| normal(一般) | 15-20元 | +| complex(复杂) | 20-25元 | +| hard(很复杂) | 25-30元 | + +**报价维度**(越平整越便宜):平整度 flat→便宜、rough→贵;大字不加价、小字加价;含人脸/阴影加价。 + +报价后直接推成交,话术自然多变。 + +**报价未成交客户**:若客户上次报价后未成交(算了/不要了等),本次遇到时适当降低5-10元促成交。 + +--- + +## ⑥ 问"有吗" / "能找到吗" / "是原图吗" / "看看效果" + +不解释,自然推下单发图、不满意退。说正面的。 + +**⚠️ 前后一致**:若上一句刚说了「这类不做」「不接」某张图,客户接着问「能找到吗」「可以吗」→ 必须明确区分:能做的是哪张(如第一张),不能做的是哪张。不可只说「放心拍」「可以」,否则会让客户以为刚才拒绝的那张也能做,前后矛盾。 + +--- + +## ⑦ 问"怎么找的" / "从哪来的" + +不解释,自然转移话题。 + +--- + +## ⑧ 要分层 / 问格式 / 改尺寸 + +- 普通服务只发 **jpg** +- 要分层(PSD)单独 35 元 +- 改尺寸:调用 resize_image,常用 1920x1080 / 1080x1920 / 2000x2000 + +自然回复,不要固定句式。 + +--- + +## ⑨ 多图打包 + +不逐张报价,给总价略优惠。自然说。 + +**多图有拒有接时**:若部分图能做、部分不能做(如敏感),必须说清:哪张可以、哪张不行。不可只说不做或只说可以做,前后要一致。 + +--- + +## ⑩ 压价 + +客户说「贵」「有点贵」「算了」「便宜点」→ **直接让价**,不问「什么问题」「说清楚点」 + +只让一次,不低于底线。话术自然,换着说。 + +--- + +## ⑪ 售后 + +- 催进度:自然说快了/马上好/好了发你 +- 说「安排一下」「处理一下」:调用 process_image_gemini,自然回复已安排 +- 不满意:自然问哪里要改 +- 退款/投诉:转人工 + +--- + +## 禁忌 + +- 说"原图""找到了""有原图" +- 说"做""处理""修图""制作" +- 说"不行""找不到""没有" +- 解释技术或来源 +- 给价格区间 +- 超过2句话 diff --git a/skills/gemini-api/SKILL.md b/skills/gemini-api/SKILL.md new file mode 100644 index 0000000..a96a730 --- /dev/null +++ b/skills/gemini-api/SKILL.md @@ -0,0 +1,21 @@ +--- +name: gemini-api +description: Gemini API 账号店铺 - 账号/Pro/续费/套餐咨询 +--- + +# Gemini API 店铺客服 + +店铺售卖 Gemini API 账号、Pro 套餐等。 + +## 常见问题 + +- **账号没有 pro 了**:引导续费/充值,说明套餐 +- **怎么续费**:说明续费方式、价格 +- **pro 和 nano 区别**:简单说明套餐差异 +- **不能用/报错**:排查账号状态,必要时转人工 + +## 回复原则 + +- 自然、简洁 +- 不清楚的转人工 +- 不承诺具体技术细节 diff --git a/skills/owner-style/SKILL.md b/skills/owner-style/SKILL.md new file mode 100644 index 0000000..5ee922e --- /dev/null +++ b/skills/owner-style/SKILL.md @@ -0,0 +1,106 @@ +--- +name: owner-style +description: 店主个人说话风格 +--- + +# 店主风格 + +> 像真人店主在手机上聊天,不是客服机器人背稿。 + +--- + +## 基本性格 + +- 干脆,不废话,不解释太多 +- 随意自然,不过度热情 +- 报价直接,给完价就推下单 +- 有底线,不随便让价 + +--- + +## 说话方式 + +- 短句为主,1-2句搞定 +- **同一个意思每次说法必须不一样,绝对不能连续两次说完全相同的话** +- 不堆感叹号,不堆 emoji +- 偶尔带"哈""呢""吧",自然一点 +- 不说"亲""您好""感谢惠顾"这类官方话 +- 开头不用每次都加"好的",直接说正事也行 +- 报价可以有多种说法:30块 / 30元 / 30 / 这张30 / 价格30,轮换着用 + +--- + +## 报价风格 + +直接说数字,给完立刻推下单,不等客户反应 + +✅ 对:20,拍下发你 +✅ 对:15,你下单吧 +✅ 对:这张25,拍下 +❌ 错:这张图大概在20元左右,您看可以接受吗? + +--- + +## 压价风格 + +第一次让一点点或不让: +- 已经很低了,拍下吧 +- 这价没法再少,我给你快点发 +- 再少5块,拍下 + +第二次直接顶回去: +- 最低了,真没法少 +- 这个价做不了哈 + +--- + +## 售后风格 + +- 催:马上发你 / 好了发你 / 快了 +- 要改:哪里不对说一下 +- 不满意:说具体哪里,我重新弄 + +--- + +## 示例(感受这个语气) + +客户:这张能找吗多少钱 +→ 发图来看看 + +客户:(发图) +→ 20,拍下发你 + +客户:能便宜点吗 +→ 已经很实在了,拍下吧 + +客户:在不在 +→ 在呢 + +客户:什么格式 +→ jpg的 + +客户:做好了吗 +→ 快了,好了发你 + +客户:还可以弄高清修复吗 +→ 可以,发图来看看 + +客户:1171103839,加一下吧 +→ 发图就行,直接这边发 + +客户:(给了QQ/微信) +→ 直接发图过来就好,或者发邮箱357805318@qq.com + +--- + +## 底价(不对外说) + +- 单张最低:10元 +- 3张以上:25元/3张 + +--- + +## 个性化(店主自己填) + +我常说的话: +- (待填写) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..a90eb03 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""配置中心测试""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +def test_config_paths(): + from config.config import ROOT, LOG_DIR, RESULTS_DIR, CONFIG_DIR + assert ROOT.exists() + assert ROOT.is_dir() + assert (ROOT / "config").samefile(CONFIG_DIR) + assert LOG_DIR == ROOT / "logs" + print("config paths OK") + + +def test_config_values(): + from config.config import ( + IMAGE_QUEUE_MAX_CONCURRENT, + IMAGE_QUEUE_MAX_SIZE, + LOG_MAX_BYTES, + LOG_BACKUP_COUNT, + ) + assert IMAGE_QUEUE_MAX_CONCURRENT >= 1 + assert IMAGE_QUEUE_MAX_SIZE >= 1 + assert LOG_MAX_BYTES > 0 + assert LOG_BACKUP_COUNT >= 1 + print("config values OK") + + +if __name__ == "__main__": + test_config_paths() + test_config_values() + print("All config tests passed") diff --git a/tests/test_designer_roster.py b/tests/test_designer_roster.py new file mode 100644 index 0000000..2555b68 --- /dev/null +++ b/tests/test_designer_roster.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +"""设计师派单数据库测试""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +def test_list_designers(): + """测试列出设计师(不修改数据)""" + from db.designer_roster_db import list_designers + designers = list_designers() + assert isinstance(designers, list) + print(f"designer roster: {len(designers)} designers") + print("designer roster OK") + + +if __name__ == "__main__": + test_list_designers() + print("All designer roster tests passed") diff --git a/tests/test_health_check.py b/tests/test_health_check.py new file mode 100644 index 0000000..82b24e5 --- /dev/null +++ b/tests/test_health_check.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +"""健康检查测试""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +def test_set_status(): + """测试状态设置""" + from utils.health_check import set_qingjian_connected, set_wechat_ok + set_qingjian_connected(True) + set_qingjian_connected(False) + set_wechat_ok(True) + print("health check status OK") + + +async def test_run_check(): + """测试执行健康检查(不实际发告警)""" + from utils.health_check import run_health_check + await run_health_check(lambda: True) + print("health check run OK") + + +if __name__ == "__main__": + import asyncio + test_set_status() + asyncio.run(test_run_check()) + print("All health check tests passed") diff --git a/tests/test_image_queue.py b/tests/test_image_queue.py new file mode 100644 index 0000000..5fa0622 --- /dev/null +++ b/tests/test_image_queue.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""图片队列测试""" +import sys +import asyncio +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +async def test_queue_semaphore(): + """测试队列并发限制""" + from utils.image_queue import init, run_with_queue, release + init(max_concurrent=2, max_queue=5) + running = 0 + max_running = 0 + + async def fake_task(): + nonlocal running, max_running + running += 1 + max_running = max(max_running, running) + await asyncio.sleep(0.1) + running -= 1 + return "ok" + + results = await asyncio.gather( + run_with_queue(fake_task()), + run_with_queue(fake_task()), + run_with_queue(fake_task()), + ) + assert all(r == "ok" for r in results) + assert max_running <= 2 + print("image queue OK") + + +if __name__ == "__main__": + asyncio.run(test_queue_semaphore()) + print("All image queue tests passed") diff --git a/tests/test_process.py b/tests/test_process.py new file mode 100644 index 0000000..823e8a3 --- /dev/null +++ b/tests/test_process.py @@ -0,0 +1,140 @@ +""" +端到端测试:模拟客户付款后的自动图片处理流程 +运行:python test_process.py +""" +import sys +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") + +import asyncio +import os +from dotenv import load_dotenv + +load_dotenv() + +# ── 测试参数 ──────────────────────────────────────────────── +TEST_CUSTOMER_ID = "test_user_001" +TEST_ACC_ID = "小威哥1216" +TEST_IMAGE_URL = ( + "https://img.alicdn.com/imgextra/i3/O1CN01tqQst21qIEOdcUOCQ" + "_!!4611686018427380880-0-amp.jpg" +) +# ──────────────────────────────────────────────────────────── + + +async def step1_analyze(): + """Step 1: 图片分析(模拟 analyze_image 工具调用)""" + print("\n" + "="*60) + print("Step 1: 图片分析") + print("="*60) + from image.image_analyzer import image_analyzer + result = await image_analyzer.analyze(TEST_IMAGE_URL) + print(f"分析结果: {result}") + return result + + +async def step2_create_task(analysis: dict): + """Step 2: 创建 Workflow 任务(模拟 image_analysis_result)""" + print("\n" + "="*60) + print("Step 2: 创建 Workflow 任务") + print("="*60) + from core.workflow import workflow + await workflow.image_analysis_result( + customer_id = TEST_CUSTOMER_ID, + image_url = TEST_IMAGE_URL, + complexity = analysis.get("complexity", "normal"), + acc_id = TEST_ACC_ID, + acc_type = "AliWorkbench", + gemini_prompt= analysis.get("gemini_prompt", ""), + aspect_ratio = analysis.get("aspect_ratio", "1:1"), + perspective = analysis.get("perspective", "no"), + proc_type = analysis.get("proc_type", ""), + subject = analysis.get("subject", ""), + quality = analysis.get("quality", ""), + ) + + task_id = workflow.customer_active_task.get(TEST_CUSTOMER_ID) + if task_id: + task = workflow.tasks[task_id] + print(f"任务已创建: {task_id[:8]}...") + print(f" requirements: {task.requirements}") + print(f" image: {task.original_image[:80]}...") + else: + print("⚠️ 任务创建失败!") + sys.exit(1) + return task_id + + +async def step3_trigger_payment(): + """Step 3: 模拟付款触发处理(等待 Gemini 完成)""" + print("\n" + "="*60) + print("Step 3: 模拟付款,触发 Gemini 处理(同步等待完成)") + print("="*60) + from core.workflow import workflow + + # 注入发送函数(避免真实发消息,只打印) + async def fake_send(**kw): + cid = kw.get("customer_id", kw.get("from_id", "?")) + content = kw.get("content", kw.get("msg", "")) + print(f"[FAKE SEND -> {cid}] {str(content)[:120]}") + + workflow._send_message = fake_send + + # 直接调用 _auto_process 同步等待,而非后台 task + task_id = workflow.customer_active_task.get(TEST_CUSTOMER_ID) + if not task_id: + print(" 找不到待处理任务!") + return + + print(f" 开始处理任务: {task_id[:8]}...") + await workflow._auto_process(task_id, acc_id=TEST_ACC_ID, acc_type="AliWorkbench") + + +async def step4_check_result(): + """Step 4: 检查结果""" + print("\n" + "="*60) + print("Step 4: 检查处理结果") + print("="*60) + result_dir = os.getenv("RESULT_IMAGE_DIR", "results") + if not os.path.exists(result_dir): + print(f"结果目录不存在: {result_dir}") + return + + files = sorted( + [f for f in os.listdir(result_dir) if f.startswith("result_")], + key=lambda f: os.path.getmtime(os.path.join(result_dir, f)), + reverse=True, + ) + if files: + latest = files[0] + path = os.path.join(result_dir, latest) + size = os.path.getsize(path) + print(f"最新结果文件: {latest}") + print(f" 大小: {size:,} bytes ({size/1024:.1f} KB)") + else: + print("结果目录为空,可能处理失败") + + +async def main(): + print("=" * 60) + print(" 图片处理流程端到端测试") + print("=" * 60) + print(f"测试图片: {TEST_IMAGE_URL[:80]}...") + + try: + analysis = await step1_analyze() + await step2_create_task(analysis) + await step3_trigger_payment() + await step4_check_result() + print("\n[OK] 测试完成") + except KeyboardInterrupt: + print("\n测试被中断") + except Exception as e: + import traceback + print(f"\n[FAIL] 测试失败: {e}") + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_transfer_flow.py b/tests/test_transfer_flow.py new file mode 100644 index 0000000..904dda7 --- /dev/null +++ b/tests/test_transfer_flow.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +"""测试转人工流程:设计师在线查询 + 派单 + 无人在线时企微提醒""" +import asyncio +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# 测试用 API 地址(文档中的) +os.environ.setdefault("DESIGNER_ROSTER_API", "http://huichang.online:8001/online") + + +async def main(): + from utils.designer_roster import poll_and_update_roster + from db.designer_roster_db import list_designers, get_transfer_group_for_shop + + print("1. 查询并同步设计师在线状态...") + await poll_and_update_roster() + print(" OK") + + print("\n2. 当前设计师状态:") + for d in list_designers(): + status = "在线" if d["is_online"] else "离线" + print(f" - {d['name']} ({d['wechat_user_id']}): {status}") + + print("\n3. 派单测试 (店铺: 小威哥1216):") + shop_id = "小威哥1216" + group_id = get_transfer_group_for_shop(shop_id) + if group_id: + print(f" 派单成功 -> group_id={group_id}") + else: + print(" 无人在线,将回退到静态配置") + print(" (转人工时会发企微「谁在线啊」)") + + print("\n4. 静态回退:") + from config.config import CONFIG_DIR + import json + cfg_path = CONFIG_DIR / "transfer_groups.json" + default = "20252916034" + if cfg_path.exists(): + with open(cfg_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + fallback = cfg.get(shop_id, cfg.get("default", default)) + else: + fallback = default + print(f" 回退分组: {fallback}") + + print("\n测试完成") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_transfer_groups.py b/tests/test_transfer_groups.py new file mode 100644 index 0000000..1784bf0 --- /dev/null +++ b/tests/test_transfer_groups.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +"""转接分组测试""" +import sys +import json +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +def test_get_transfer_group(): + """测试转接分组查找逻辑""" + from config.config import CONFIG_DIR + config_path = CONFIG_DIR / "transfer_groups.json" + default = "20252916034" + if not config_path.exists(): + print("transfer_groups.json 不存在,跳过") + return + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + got = cfg.get("default", default) + assert got + print(f"default group: {got}") + print("transfer groups OK") + + +if __name__ == "__main__": + test_get_transfer_group() + print("All transfer tests passed") diff --git a/tests/test_wechat_alert.py b/tests/test_wechat_alert.py new file mode 100644 index 0000000..5ccca1b --- /dev/null +++ b/tests/test_wechat_alert.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +"""测试企微「谁在线啊」消息发送""" +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +async def main(): + from config.config import WECHAT_WEBHOOK + import httpx + + if not WECHAT_WEBHOOK: + print("未配置 WECHAT_WEBHOOK,无法测试") + return + + print(f"发送测试消息到企微...") + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(WECHAT_WEBHOOK, json={ + "msgtype": "text", + "text": {"content": "谁在线啊"} + }) + print(f"状态码: {resp.status_code}") + print(f"响应: {resp.text}") + if resp.status_code == 200: + data = resp.json() + if data.get("errcode") == 0: + print("发送成功,请检查企微群是否收到") + else: + print(f"企微返回错误: {data}") + else: + print("发送失败") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/__pycache__/__init__.cpython-310.pyc b/utils/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..2e6809e Binary files /dev/null and b/utils/__pycache__/__init__.cpython-310.pyc differ diff --git a/utils/__pycache__/api_cost_tracker.cpython-310.pyc b/utils/__pycache__/api_cost_tracker.cpython-310.pyc new file mode 100644 index 0000000..51c3732 Binary files /dev/null and b/utils/__pycache__/api_cost_tracker.cpython-310.pyc differ diff --git a/utils/__pycache__/content_filter.cpython-310.pyc b/utils/__pycache__/content_filter.cpython-310.pyc new file mode 100644 index 0000000..4c0d79d Binary files /dev/null and b/utils/__pycache__/content_filter.cpython-310.pyc differ diff --git a/utils/__pycache__/daily_summary.cpython-310.pyc b/utils/__pycache__/daily_summary.cpython-310.pyc new file mode 100644 index 0000000..bef795c Binary files /dev/null and b/utils/__pycache__/daily_summary.cpython-310.pyc differ diff --git a/utils/__pycache__/designer_roster.cpython-310.pyc b/utils/__pycache__/designer_roster.cpython-310.pyc new file mode 100644 index 0000000..b16b4fa Binary files /dev/null and b/utils/__pycache__/designer_roster.cpython-310.pyc differ diff --git a/utils/__pycache__/health_check.cpython-310.pyc b/utils/__pycache__/health_check.cpython-310.pyc new file mode 100644 index 0000000..2e012d0 Binary files /dev/null and b/utils/__pycache__/health_check.cpython-310.pyc differ diff --git a/utils/__pycache__/image_queue.cpython-310.pyc b/utils/__pycache__/image_queue.cpython-310.pyc new file mode 100644 index 0000000..09a11bc Binary files /dev/null and b/utils/__pycache__/image_queue.cpython-310.pyc differ diff --git a/utils/__pycache__/intent_analyzer.cpython-310.pyc b/utils/__pycache__/intent_analyzer.cpython-310.pyc new file mode 100644 index 0000000..dc5276e Binary files /dev/null and b/utils/__pycache__/intent_analyzer.cpython-310.pyc differ diff --git a/utils/__pycache__/service_base.cpython-310.pyc b/utils/__pycache__/service_base.cpython-310.pyc new file mode 100644 index 0000000..9c67748 Binary files /dev/null and b/utils/__pycache__/service_base.cpython-310.pyc differ diff --git a/utils/__pycache__/wechat_chat_log.cpython-310.pyc b/utils/__pycache__/wechat_chat_log.cpython-310.pyc new file mode 100644 index 0000000..037e15e Binary files /dev/null and b/utils/__pycache__/wechat_chat_log.cpython-310.pyc differ diff --git a/utils/api_cost_tracker.py b/utils/api_cost_tracker.py new file mode 100644 index 0000000..2e17a08 --- /dev/null +++ b/utils/api_cost_tracker.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +API 成本统计 - 记录调用成本,超预算告警 +""" +import os +import json +import logging +from pathlib import Path +from datetime import datetime +from typing import Optional + +logger = logging.getLogger(__name__) + +ROOT = Path(__file__).resolve().parent.parent +COST_FILE = ROOT / "config" / ".api_cost.json" +BUDGET_DAILY = float(os.getenv("API_COST_BUDGET_DAILY", "0")) # 0=不限制 +BUDGET_MONTHLY = float(os.getenv("API_COST_BUDGET_MONTHLY", "0")) + +# 单次调用预估成本(元,可按实际调整) +COST_PER_CALL = { + "gemini_extract": 0.02, + "gemini_qa": 0.01, + "gemini_vision": 0.015, + "openai_chat": 0.02, + "openai_embedding": 0.001, + "qwen_enhance": 0.05, +} + + +def _load() -> dict: + if not COST_FILE.exists(): + return {"daily": {}, "monthly": {}} + try: + with open(COST_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {"daily": {}, "monthly": {}} + + +def _save(data: dict): + COST_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(COST_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def record(service: str, count: int = 1, cost: Optional[float] = None): + """记录一次 API 调用""" + c = cost if cost is not None else COST_PER_CALL.get(service, 0.01) * count + today = datetime.now().strftime("%Y-%m-%d") + month = datetime.now().strftime("%Y-%m") + data = _load() + data["daily"][today] = data["daily"].get(today, 0) + c + data["monthly"][month] = data["monthly"].get(month, 0) + c + _save(data) + return c + + +def get_today_cost() -> float: + today = datetime.now().strftime("%Y-%m-%d") + data = _load() + return data["daily"].get(today, 0) + + +def get_month_cost() -> float: + month = datetime.now().strftime("%Y-%m") + data = _load() + return data["monthly"].get(month, 0) + + +async def check_budget_alert(): + """检查是否超预算,超则企微告警""" + if BUDGET_DAILY <= 0 and BUDGET_MONTHLY <= 0: + return + try: + from config.config import WECHAT_WEBHOOK + if not WECHAT_WEBHOOK: + return + import httpx + today = get_today_cost() + month = get_month_cost() + msg_parts = [] + if BUDGET_DAILY > 0 and today >= BUDGET_DAILY: + msg_parts.append(f"⚠️ 今日 API 成本 {today:.2f} 元 ≥ 预算 {BUDGET_DAILY} 元") + if BUDGET_MONTHLY > 0 and month >= BUDGET_MONTHLY: + msg_parts.append(f"⚠️ 本月 API 成本 {month:.2f} 元 ≥ 预算 {BUDGET_MONTHLY} 元") + if not msg_parts: + return + async with httpx.AsyncClient(timeout=10) as client: + await client.post(WECHAT_WEBHOOK, json={ + "msgtype": "markdown", + "markdown": {"content": "\n".join(msg_parts)} + }) + logger.info(f"[成本] 告警已发送: {msg_parts}") + except Exception as e: + logger.warning(f"[成本] 告警失败: {e}") diff --git a/utils/content_filter.py b/utils/content_filter.py new file mode 100644 index 0000000..32575dd --- /dev/null +++ b/utils/content_filter.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +""" +敏感词过滤 - 党政/暴力/血腥/黄色 +敏感图片检测 - 暴力/血腥/色情/政治敏感 +合规、风险可控 +""" +import os +import re +import base64 +from typing import Tuple + +# 敏感词库(按类别,可扩展) +_SENSITIVE_PATTERNS = { + "党政": [ + r"习近平", r"共产党", r"党中央", r"政治局", r"六四", r"天安门", + r"法轮功", r"台独", r"藏独", r"疆独", r"邪教", + ], + "暴力": [ + r"杀人", r"砍人", r"捅人", r"枪击", r"爆炸", r"恐怖袭击", + r"肢解", r"碎尸", r"虐杀", r"血洗", + ], + "血腥": [ + r"断肢", r"残肢", r"内脏", r"脑浆", r"血淋淋", r"尸块", + r"开膛", r"割喉", r"爆头", + ], + "黄色": [ + r"裸体", r"裸照", r"裸聊", r"色情", r" porn", r"porn", + r"做爱", r"性交", r"约炮", r"约炮", r"嫖娼", r"卖淫", + r"av女", r"av男", r"av片", r"av资源", + ], + "擦边": [ + r"擦边", r"大尺度", r"性感图", r"露点", r"半裸", + ], +} + +_COMPILED: dict = {} + + +def _get_compiled(): + global _COMPILED + if not _COMPILED: + for cat, patterns in _SENSITIVE_PATTERNS.items(): + _COMPILED[cat] = [re.compile(p, re.I) for p in patterns] + return _COMPILED + + +def filter_sensitive(text: str) -> tuple[str, list[str]]: + """ + 检测文本中的敏感词。不直接替换(避免误伤),返回 (原文本, 命中的类别列表)。 + 若命中则调用方应使用兜底回复或转人工。 + + Returns: + (text, hit_categories) - hit_categories 如 ["暴力", "黄色"],空则无敏感 + """ + if not text or not text.strip(): + return text, [] + compiled = _get_compiled() + hit = [] + for cat, pats in compiled.items(): + for p in pats: + if p.search(text): + hit.append(cat) + break + return text, hit + + +def should_block_reply(text: str) -> tuple[bool, str]: + """ + 判断 AI 回复是否应拦截。 + Returns: + (should_block, fallback_reply) - 若应拦截,返回兜底话术 + """ + _, hit = filter_sensitive(text) + if not hit: + return False, "" + return True, "好的,您稍等,我帮您确认一下" + + +def should_block_customer(text: str) -> bool: + """判断客户消息是否应拒单(不处理图片、不回复)""" + _, hit = filter_sensitive(text) + return len(hit) > 0 + + +_SENSITIVE_IMAGE_PROMPT = """请判断这张图片是否包含以下任何敏感内容: +- 暴力(打斗、武器、伤害) +- 血腥(伤口、血迹、残肢等) +- 色情(裸露、性暗示) +- 政治敏感(旗帜、标语、敏感人物等) + +只回答「是」或「否」。若包含任一敏感内容则答「是」,否则答「否」。""" + + +async def is_sensitive_image(local_path: str) -> Tuple[bool, str]: + """ + 使用视觉模型检测图片是否包含敏感内容。 + 需配置 OPENAI_API_KEY、OPENAI_BASE_URL、VISION_MODEL(或 SENSITIVE_IMAGE_MODEL)。 + + Returns: + (is_sensitive, reason) - 若敏感则 (True, "拒单原因"),否则 (False, "") + 未配置或异常时返回 (False, ""),不拦截(fail open) + """ + if not os.path.exists(local_path): + return False, "" + api_key = os.getenv("OPENAI_API_KEY") + base_url = os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4") + model = os.getenv("SENSITIVE_IMAGE_MODEL") or os.getenv("VISION_MODEL", "glm-4v-flash") + if not api_key: + return False, "" + try: + with open(local_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + from openai import AsyncOpenAI + client = AsyncOpenAI(base_url=base_url, api_key=api_key) + resp = await client.chat.completions.create( + model=model, + messages=[{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}, + {"type": "text", "text": _SENSITIVE_IMAGE_PROMPT}, + ], + }], + ) + try: + from utils.api_cost_tracker import record + record("gemini_vision", count=1) + except Exception: + pass + text = (resp.choices[0].message.content or "").strip() + is_sensitive = "是" in text + return is_sensitive, "图片包含敏感内容,无法处理" if is_sensitive else "" + except Exception as e: + print(f"[ContentFilter] 敏感图片检测异常: {e},放行") + return False, "" diff --git a/utils/daily_summary.py b/utils/daily_summary.py new file mode 100644 index 0000000..526959f --- /dev/null +++ b/utils/daily_summary.py @@ -0,0 +1,298 @@ +# -*- coding: utf-8 -*- +""" +每日聊天汇总定时任务 +- 每天 23:50 自动统计当日各店铺数据 +- 用 AI 生成自然语言摘要 +- 发送到企业微信 Webhook + QQ 邮件 +""" + +import asyncio +import os +import json +from datetime import datetime, date, timedelta +from typing import Optional + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "") +SUMMARY_EMAIL = os.getenv("SUMMARY_EMAIL", "") # 收摘要的邮箱 +SEND_HOUR = int(os.getenv("SUMMARY_HOUR", "23")) +SEND_MINUTE = int(os.getenv("SUMMARY_MINUTE", "50")) + + +# ────────────────────────────────────────── +# 统计数据整理 +# ────────────────────────────────────────── + +def _build_stats_text(target_date: str = "") -> str: + """整理今日数据,返回给 AI 的原始统计文本""" + from db import chat_log_db as db + from db.deal_outcome_db import get_daily_summary + + if not target_date: + target_date = datetime.now().strftime("%Y-%m-%d") + + stats = db.get_daily_stats(target_date) + convs = db.get_daily_conversations(target_date) + deal_sum = get_daily_summary(target_date) + + if not stats: + return f"{target_date} 当日无任何聊天记录。" + + # 按 acc_id 分组对话片段 + conv_map: dict[str, list] = {} + for c in convs: + aid = c.get("acc_id") or "未知店铺" + conv_map.setdefault(aid, []).append(c) + + lines = [f"【{target_date} 各店铺数据】\n"] + + # 成交/未成交汇总(供 AI 摘要与数据分析) + lines.append("【成交与未成交】") + lines.append(f" 成交:{deal_sum['成交数']} 笔,金额 {deal_sum['成交金额']:.0f} 元") + lines.append(f" 未成交:{deal_sum['未成交数']} 笔") + if deal_sum["未成交原因分布"]: + for reason, cnt in deal_sum["未成交原因分布"].items(): + lines.append(f" - {reason}:{cnt} 笔") + if deal_sum["成交明细"]: + for o in deal_sum["成交明细"][:5]: + r = "让价后" if o.get("discount_given") else "直接" + lines.append(f" ✓ {o.get('customer_name', '')[:6]} {r}成交 {o.get('amount', 0):.0f}元") + if deal_sum["未成交明细"]: + for o in deal_sum["未成交明细"][:5]: + lines.append(f" ✗ {o.get('customer_name', '')[:6]} {o.get('reason', '')}") + lines.append("") + + for s in stats: + acc = s.get("acc_id") or "未知店铺" + plat = s.get("platform") or "" + label = f"{acc}({plat})" if plat else acc + + lines.append(f"▶ 店铺:{label}") + lines.append(f" 接待客户:{s['unique_customers']} 人,共 {s['total_msgs']} 条消息(收 {s['recv']} 发 {s['sent']})") + lines.append(f" 首条:{(s.get('first_msg') or '')[-8:-3]} 末条:{(s.get('last_msg') or '')[-8:-3]}") + + shop_convs = conv_map.get(acc, []) + for c in shop_convs[:6]: # 最多展示6个客户片段 + name = c.get("customer_name") or c.get("customer_id", "")[:8] + snippet = (c.get("snippet") or "")[:120] + lines.append(f" · {name}({c['msg_count']}条){snippet}") + if len(shop_convs) > 6: + lines.append(f" ... 还有 {len(shop_convs)-6} 位客户") + lines.append("") + + return "\n".join(lines) + + +# ────────────────────────────────────────── +# AI 生成摘要 +# ────────────────────────────────────────── + +async def _ai_summary(raw_text: str) -> str: + """调用 AI 把统计文本转成自然语言日报""" + try: + from openai import AsyncOpenAI + client = AsyncOpenAI( + api_key=os.getenv("OPENAI_API_KEY"), + base_url=os.getenv("OPENAI_BASE_URL"), + ) + model = os.getenv("OPENAI_MODEL", "doubao-seed-2-0-lite-260215") + resp = await client.chat.completions.create( + model=model, + messages=[ + { + "role": "system", + "content": ( + "你是一名电商运营助理。根据下面的客服聊天数据," + "为老板写一份简洁的当日运营日报(200字以内)。" + "要包含:接待总人数、各店铺情况、有无成交或异常情况。" + "语气轻松,像发给老板的微信消息,不需要标题。" + ), + }, + {"role": "user", "content": raw_text}, + ], + max_tokens=300, + temperature=0.5, + ) + return resp.choices[0].message.content.strip() + except Exception as e: + # AI 失败就直接返回原始统计 + return raw_text + + +# ────────────────────────────────────────── +# 推送:企业微信 +# ────────────────────────────────────────── + +async def _send_wechat(content: str): + """推送到企业微信群机器人(markdown 格式,单条 ≤4096 字节自动分段)""" + if not WECHAT_WEBHOOK: + print("[DailySummary] 未配置 WECHAT_WEBHOOK,跳过推送") + return + + # 企业微信单条 markdown 限 4096 字节,超长自动分段 + encoded = content.encode("utf-8") + chunks = [] + while encoded: + chunk = encoded[:3800].decode("utf-8", errors="ignore") + chunks.append(chunk) + encoded = encoded[len(chunk.encode("utf-8")):] + + async with httpx.AsyncClient(timeout=10) as client: + for i, chunk in enumerate(chunks): + payload = {"msgtype": "markdown", "markdown": {"content": chunk}} + try: + resp = await client.post(WECHAT_WEBHOOK, json=payload) + data = resp.json() + if data.get("errcode") == 0: + print(f"[DailySummary] 企业微信推送成功(第{i+1}段)") + else: + print(f"[DailySummary] 企业微信推送失败: {data}") + except Exception as e: + print(f"[DailySummary] 企业微信推送异常: {e}") + + +# ────────────────────────────────────────── +# 推送:邮件 +# ────────────────────────────────────────── + +def _send_email(subject: str, body: str): + """发送日报邮件""" + if not SUMMARY_EMAIL: + return + try: + from mail.email_sender import email_sender + import smtplib + from email.mime.text import MIMEText + from email.header import Header + + msg = MIMEText(body, "plain", "utf-8") + msg["Subject"] = Header(subject, "utf-8").encode() + msg["From"] = f"{Header(email_sender.sender_name, 'utf-8').encode()} <{email_sender.smtp_user}>" + msg["To"] = SUMMARY_EMAIL + + with smtplib.SMTP(email_sender.smtp_host, email_sender.smtp_port) as s: + s.starttls() + s.login(email_sender.smtp_user, email_sender.smtp_password) + s.sendmail(email_sender.smtp_user, [SUMMARY_EMAIL], msg.as_string()) + print(f"[DailySummary] 日报邮件已发送至 {SUMMARY_EMAIL}") + except Exception as e: + print(f"[DailySummary] 日报邮件发送失败: {e}") + + +# ────────────────────────────────────────── +# 企业微信 Markdown 排版 +# ────────────────────────────────────────── + +def _build_wechat_markdown(title: str, ai_text: str, raw_text: str, target_date: str = "") -> str: + """ + 构建符合企业微信规范的 markdown 内容。 + 支持:**bold**、、> 引用、``` 代码块、- 列表 + 不支持:
、HTML 标签(除 font/br) + """ + from db import chat_log_db as db + from db.deal_outcome_db import get_daily_summary + date = target_date or datetime.now().strftime("%Y-%m-%d") + stats = db.get_daily_stats(date) + deal_sum = get_daily_summary(date) + + lines = [f"## {title}\n"] + + # AI 摘要部分 + lines.append("> " + ai_text.replace("\n", "\n> ")) + lines.append("") + + # 成交/未成交 + lines.append("**📈 成交与未成交**") + lines.append(f"- 成交 **{deal_sum['成交数']}** 笔 · 金额 **{deal_sum['成交金额']:.0f}** 元") + lines.append(f"- 未成交 **{deal_sum['未成交数']}** 笔") + if deal_sum["未成交原因分布"]: + for reason, cnt in deal_sum["未成交原因分布"].items(): + lines.append(f" - {reason}:{cnt} 笔") + lines.append("") + + # 各店铺数据表格(企业微信不支持 | 表格,用列表代替) + if stats: + lines.append("**📋 各店铺明细**") + for s in stats: + acc = s.get("acc_id") or "未知店铺" + plat = s.get("platform") or "" + label = f"{acc}({plat})" if plat else acc + first = (s.get("first_msg") or "")[-8:-3] + last = (s.get("last_msg") or "")[-8:-3] + lines.append( + f"- {label} " + f"接待 **{s['unique_customers']}** 人 · " + f"消息 {s['total_msgs']} 条(收{s['recv']}/发{s['sent']})" + f" {first}~{last}" + ) + lines.append("") + lines.append(f"发送时间:{datetime.now().strftime('%H:%M:%S')}") + + return "\n".join(lines) + + +# ────────────────────────────────────────── +# 主入口:生成并推送日报 +# ────────────────────────────────────────── + +async def send_daily_summary(target_date: str = ""): + """生成并推送当日汇总""" + if not target_date: + target_date = datetime.now().strftime("%Y-%m-%d") + + print(f"[DailySummary] 开始生成 {target_date} 日报...") + + raw_text = _build_stats_text(target_date) + ai_text = await _ai_summary(raw_text) + title = f"📊 {target_date} 客服日报" + + # ── 企业微信 markdown(不支持
,用标准语法)── + wechat_md = _build_wechat_markdown(title, ai_text, raw_text, target_date) + await _send_wechat(wechat_md) + + # ── 邮件:纯文本 ── + email_body = f"{ai_text}\n\n{'='*40}\n\n{raw_text}" + _send_email(title, email_body) + + print(f"[DailySummary] 日报推送完成") + return ai_text + + +# ────────────────────────────────────────── +# 定时调度(由 websocket_client 启动) +# ────────────────────────────────────────── + +async def scheduler(): + """每天 SEND_HOUR:SEND_MINUTE 触发日报""" + print(f"[DailySummary] 定时日报已启动,发送时间 {SEND_HOUR:02d}:{SEND_MINUTE:02d}") + sent_today: Optional[str] = None # 记录已发日期,防重复 + + while True: + now = datetime.now() + today = now.strftime("%Y-%m-%d") + + if now.hour == SEND_HOUR and now.minute == SEND_MINUTE and sent_today != today: + sent_today = today + try: + await send_daily_summary(today) + except Exception as e: + print(f"[DailySummary] 日报生成出错: {e}") + + # 每 30 秒检查一次 + await asyncio.sleep(30) + + +# ────────────────────────────────────────── +# 命令行手动触发 +# ────────────────────────────────────────── + +if __name__ == "__main__": + import sys + target = sys.argv[1] if len(sys.argv) > 1 else "" + result = asyncio.run(send_daily_summary(target)) + print("\n=== AI 摘要 ===") + print(result) diff --git a/utils/designer_roster.py b/utils/designer_roster.py new file mode 100644 index 0000000..e72dc49 --- /dev/null +++ b/utils/designer_roster.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +""" +设计师在线状态 - 请求外部查询服务(转人工时按需调用) + +接口: GET /online 返回 {online_users: ["lz", "ZuoWei"], ...} +本端用 online_users 同步本地 designer_online 表后派单。 +""" +import os +import logging +from typing import List, Set + +logger = logging.getLogger(__name__) + +# 设计师在线查询服务,.env 配置 DESIGNER_ROSTER_API,如 http://huichang.online:8001/online +_API_URL = os.getenv("DESIGNER_ROSTER_API", "") + + +async def _fetch_online_users() -> Set[str]: + """请求 GET /online,返回当前在线用户的 user_id 集合""" + if not _API_URL: + return set() + try: + import httpx + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get(_API_URL) + r.raise_for_status() + data = r.json() + users = data.get("online_users", []) + return set(users) if isinstance(users, list) else set() + except Exception as e: + logger.debug(f"设计师在线查询失败: {e}") + return set() + + +async def poll_and_update_roster() -> None: + """请求查询服务,同步在线状态到本地数据库""" + from db.designer_roster_db import update_online, get_all_wechat_user_ids + online_users = await _fetch_online_users() + all_ids = get_all_wechat_user_ids() + for uid in all_ids: + update_online(uid, uid in online_users) diff --git a/utils/health_check.py b/utils/health_check.py new file mode 100644 index 0000000..e6b78f9 --- /dev/null +++ b/utils/health_check.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +""" +健康检查 - 定时检测轻简/企微连接,断线时告警 +""" +import asyncio +import logging +import time +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + +# 状态 +_qingjian_connected = False +_wechat_ok = True +_last_alert_at: dict[str, float] = {} +_ALERT_COOLDOWN = 300 +_start_ts = time.time() + + +def set_qingjian_connected(ok: bool): + """设置轻简连接状态(由 websocket_client 在连接/断开时调用)""" + global _qingjian_connected + _qingjian_connected = ok + + +def set_wechat_ok(ok: bool): + """设置企微可达状态""" + global _wechat_ok + _wechat_ok = ok + + +async def _check_wechat() -> bool: + """检测企微 Webhook 是否可达""" + import httpx + from config.config import WECHAT_WEBHOOK, HEALTH_CHECK_WECHAT_PING + if not WECHAT_WEBHOOK: + return True + if not HEALTH_CHECK_WECHAT_PING: + return True # 不主动 ping,避免刷屏 + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.post(WECHAT_WEBHOOK, json={"msgtype": "text", "text": {"content": "ok"}}) + return resp.status_code == 200 + except Exception as e: + logger.warning(f"企微健康检查失败: {e}") + return False + + +async def _send_alert(title: str, content: str): + """发送告警到企微""" + from config.config import WECHAT_WEBHOOK + import time + global _last_alert_at + now = time.time() + if now - _last_alert_at.get(title, 0) < _ALERT_COOLDOWN: + return + _last_alert_at[title] = now + if not WECHAT_WEBHOOK: + logger.warning(f"[健康检查] {title}: {content}") + return + try: + import httpx + async with httpx.AsyncClient(timeout=10) as client: + await client.post(WECHAT_WEBHOOK, json={ + "msgtype": "markdown", + "markdown": {"content": f"⚠️ **{title}**\n{content}"} + }) + logger.info(f"[健康检查] 已发送告警: {title}") + except Exception as e: + logger.warning(f"[健康检查] 告警发送失败: {e}") + + +async def run_health_check(get_qingjian_status: Optional[Callable[[], bool]] = None): + """ + 执行一次健康检查。 + get_qingjian_status: 返回轻简是否已连接的函数 + """ + from config.config import HEALTH_CHECK_INTERVAL, WECHAT_WEBHOOK, HEALTH_CHECK_STARTUP_GRACE, HEALTH_CHECK_QINGJIAN_ALERTS_ENABLED + global _qingjian_connected, _wechat_ok + + # 轻简 + if get_qingjian_status: + qj_ok = get_qingjian_status() + if not qj_ok: + if time.time() - _start_ts >= HEALTH_CHECK_STARTUP_GRACE and HEALTH_CHECK_QINGJIAN_ALERTS_ENABLED: + if _qingjian_connected: + await _send_alert("轻简连接断开", "WebSocket 已断开,请检查轻简软件是否运行。") + else: + await _send_alert("轻简未连接", "无法连接轻简 API,请确认轻简软件已启动在 ws://127.0.0.1:9528") + _qingjian_connected = qj_ok + + # 企微 + wechat_ok = await _check_wechat() + if not wechat_ok and _wechat_ok and WECHAT_WEBHOOK: + await _send_alert("企微不可达", "企业微信 Webhook 无法访问,告警将无法送达。") + _wechat_ok = wechat_ok + + # API 成本预算告警 + try: + from utils.api_cost_tracker import check_budget_alert + await check_budget_alert() + except Exception as e: + logger.debug(f"[健康检查] 成本告警跳过: {e}") + + +async def health_check_loop(get_qingjian_status: Optional[Callable[[], bool]] = None): + """健康检查循环""" + from config.config import HEALTH_CHECK_INTERVAL + while True: + try: + await run_health_check(get_qingjian_status) + except Exception as e: + logger.warning(f"[健康检查] 异常: {e}") + await asyncio.sleep(HEALTH_CHECK_INTERVAL) diff --git a/utils/image_queue.py b/utils/image_queue.py new file mode 100644 index 0000000..8e52723 --- /dev/null +++ b/utils/image_queue.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +""" +图片处理队列 - 高并发时排队,避免打满 API +""" +import asyncio +import logging +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + +# 信号量限制并发数 +_semaphore: Optional[asyncio.Semaphore] = None +_queue_size = 0 +_max_concurrent = 2 +_max_queue = 20 + + +def init(max_concurrent: int = None, max_queue: int = None): + """初始化队列(在 workflow 使用前调用)""" + global _semaphore, _max_concurrent, _max_queue + try: + from config.config import IMAGE_QUEUE_MAX_CONCURRENT, IMAGE_QUEUE_MAX_SIZE + _max_concurrent = max_concurrent or IMAGE_QUEUE_MAX_CONCURRENT + _max_queue = max_queue or IMAGE_QUEUE_MAX_SIZE + except Exception: + _max_concurrent = max_concurrent or 2 + _max_queue = max_queue or 20 + _semaphore = asyncio.Semaphore(_max_concurrent) + + +async def acquire(): + """获取处理槽位,队列满时等待""" + global _queue_size + if _semaphore is None: + init() + if _queue_size >= _max_queue: + logger.warning(f"[图片队列] 队列已满({_queue_size}),等待空位...") + _queue_size += 1 + await _semaphore.acquire() + _queue_size -= 1 + + +def release(): + """释放槽位""" + if _semaphore: + _semaphore.release() + + +async def run_with_queue(coro): + """在队列中执行协程""" + await acquire() + try: + return await coro + finally: + release() diff --git a/utils/intent_analyzer.py b/utils/intent_analyzer.py new file mode 100644 index 0000000..a2f2513 --- /dev/null +++ b/utils/intent_analyzer.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +""" +语义匹配 - 用 embedding 做意图/情绪识别 +配置 EMBEDDING_MODEL 后启用,否则回退到关键词 +""" +import os +import logging +from typing import Optional, Tuple + +logger = logging.getLogger(__name__) + +# 意图模板(用于 embedding 相似度匹配) +INTENT_TEMPLATES = { + "询价": "我想问一下价格多少钱", + "发图": "我发图给你看看", + "砍价": "能不能便宜点太贵了", + "批量": "我要做很多张图批量", + "加急": "能不能快点很急", + "售后": "已经付款了什么时候好", + "修改": "不满意要改一下", + "转接": "我要退款投诉", + "打招呼": "你好在吗有人吗", +} +EMOTION_TEMPLATES = { + "平静": "好的谢谢", + "着急": "快点啊很急", + "不满": "怎么这么慢不满意", + "砍价": "太贵了便宜点", +} + + +_template_embeddings: dict = {} + +def _get_embedding(text: str, cache_key: str = None) -> Optional[list]: + """调用 embedding API,失败返回 None。cache_key 用于缓存模板向量""" + model = os.getenv("EMBEDDING_MODEL", "") + if not model: + return None + if cache_key and cache_key in _template_embeddings: + return _template_embeddings[cache_key] + try: + from openai import OpenAI + client = OpenAI( + api_key=os.getenv("OPENAI_API_KEY"), + base_url=os.getenv("OPENAI_BASE_URL"), + ) + resp = client.embeddings.create(model=model, input=text[:2000]) + emb = resp.data[0].embedding + if cache_key: + _template_embeddings[cache_key] = emb + return emb + except Exception as e: + logger.debug(f"embedding 失败: {e}") + return None + + +def _cosine_sim(a: list, b: list) -> float: + if not a or not b or len(a) != len(b): + return 0.0 + dot = sum(x * y for x, y in zip(a, b)) + na = sum(x * x for x in a) ** 0.5 + nb = sum(y * y for y in b) ** 0.5 + if na == 0 or nb == 0: + return 0.0 + return dot / (na * nb) + + +def detect_intent_embedding(msg: str) -> Optional[str]: + """用 embedding 检测意图,未配置或失败返回 None""" + msg_emb = _get_embedding(msg) + if not msg_emb: + return None + best_intent, best_score = "", 0.0 + for intent, template in INTENT_TEMPLATES.items(): + tpl_emb = _get_embedding(template, cache_key=f"intent_{intent}") + if not tpl_emb: + continue + sim = _cosine_sim(msg_emb, tpl_emb) + if sim > best_score: + best_score = sim + best_intent = intent + return best_intent if best_score > 0.6 else None + + +def detect_emotion_embedding(msg: str) -> Optional[str]: + """用 embedding 检测情绪""" + msg_emb = _get_embedding(msg) + if not msg_emb: + return None + best_emotion, best_score = "", 0.0 + for emotion, template in EMOTION_TEMPLATES.items(): + tpl_emb = _get_embedding(template, cache_key=f"emotion_{emotion}") + if not tpl_emb: + continue + sim = _cosine_sim(msg_emb, tpl_emb) + if sim > best_score: + best_score = sim + best_emotion = emotion + return best_emotion if best_score > 0.55 else None + + +def detect_intent_keywords(msg: str) -> str: + """关键词回退:无 embedding 时使用""" + m = (msg or "").strip().lower() + if any(k in m for k in ["退款", "退货", "投诉"]): + return "转接" + if any(k in m for k in ["多张", "批量", "很多", "几十张"]): + return "批量" + if any(k in m for k in ["快点", "加急", "很急", "着急"]): + return "加急" + if any(k in m for k in ["便宜", "贵", "少点", "打折"]): + return "砍价" + if any(k in m for k in ["改", "修改", "不满意"]): + return "修改" + if any(k in m for k in ["多少钱", "价格", "报价", "多钱"]): + return "询价" + if any(k in m for k in ["在吗", "你好", "有人"]): + return "打招呼" + return "" + diff --git a/utils/service_base.py b/utils/service_base.py new file mode 100644 index 0000000..d186182 --- /dev/null +++ b/utils/service_base.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" +服务基类 - 供 VectorizerService 等异步服务使用 +""" +import logging +import asyncio +from dataclasses import dataclass +from typing import Any, Callable, TypeVar, Optional +import aiohttp + +T = TypeVar("T") + + +@dataclass +class RetryConfig: + max_retries: int = 3 + base_delay: float = 2.0 + max_delay: float = 30.0 + + +@dataclass +class TimeoutConfig: + connection_timeout: float = 60.0 + read_timeout: float = 240.0 + total_timeout: float = 1200.0 + + +class ServiceError(Exception): + """服务异常基类""" + pass + + +class ServiceTimeoutError(ServiceError): + """超时异常""" + pass + + +class ServiceNetworkError(ServiceError): + """网络异常""" + pass + + +class PollingMixin: + """轮询混入 - 子类需实现 _is_task_complete, _is_task_failed, _get_error_message""" + def _is_task_complete(self, result: Any) -> bool: + raise NotImplementedError + def _is_task_failed(self, result: Any) -> bool: + raise NotImplementedError + def _get_error_message(self, result: Any) -> str: + raise NotImplementedError + + +class BaseService: + """异步服务基类""" + def __init__( + self, + name: str = "BaseService", + base_url: str = "", + retry_config: Optional[RetryConfig] = None, + timeout_config: Optional[TimeoutConfig] = None, + ): + self.name = name + self.base_url = base_url.rstrip("/") + self.retry_config = retry_config or RetryConfig() + self.timeout_config = timeout_config or TimeoutConfig() + self.logger = logging.getLogger(name) + self._session: Optional[aiohttp.ClientSession] = None + + async def create_http_session(self) -> aiohttp.ClientSession: + """创建 aiohttp 会话(不验证 SSL)""" + connector = aiohttp.TCPConnector(ssl=False) + timeout = aiohttp.ClientTimeout( + connect=self.timeout_config.connection_timeout, + total=self.timeout_config.total_timeout, + ) + return aiohttp.ClientSession(connector=connector, timeout=timeout) + + async def execute_with_retry( + self, + func: Callable[..., Any], + *args, + error_context: str = "", + **kwargs, + ) -> Any: + """带重试的执行""" + last_error = None + for attempt in range(self.retry_config.max_retries + 1): + try: + return await func(*args, **kwargs) + except (ServiceTimeoutError, ServiceNetworkError): + raise + except Exception as e: + last_error = e + self.logger.warning(f"第{attempt + 1}次尝试失败{error_context}: {e}") + if attempt < self.retry_config.max_retries: + delay = min( + self.retry_config.base_delay * (2 ** attempt), + self.retry_config.max_delay, + ) + await asyncio.sleep(delay) + raise last_error or ServiceError("未知错误") + + async def cleanup_failed_task(self, task_id: str) -> None: + """清理失败任务(子类可覆盖)""" + self.logger.debug(f"清理任务: {task_id}") diff --git a/utils/wechat_chat_log.py b/utils/wechat_chat_log.py new file mode 100644 index 0000000..b9b1bab --- /dev/null +++ b/utils/wechat_chat_log.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +客服对话推送到企业微信群 - 客户消息与AI回复成对发送,保持上下文 +""" +import asyncio +import os +from datetime import datetime + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +_last_push: dict[tuple[str, str], tuple[str, str, float]] = {} + +def _get_webhook() -> str: + """优先从 config 读取,与健康检查/日报保持一致""" + try: + from config.config import WECHAT_WEBHOOK + return WECHAT_WEBHOOK or os.getenv("WECHAT_WEBHOOK", "") + except Exception: + return os.getenv("WECHAT_WEBHOOK", "") + + +def _truncate(text: str, max_len: int = 200) -> str: + """截断过长内容""" + if not text: + return "" + text = str(text).strip() + if len(text) > max_len: + return text[:max_len] + "..." + return text + + +def _get_recent_conversation(customer_id: str, acc_id: str, last_n: int = 8) -> list: + """获取近期对话(同店铺),保持连贯上下文""" + try: + from db.chat_log_db import get_recent_conversation + return get_recent_conversation(customer_id, acc_id, limit=last_n) + except Exception: + return [] + + +async def push_chat_to_wechat( + customer_name: str, + customer_id: str, + acc_id: str, + customer_msg: str, + reply_msg: str, + goods_name: str = "", +): + """ + 将客户消息与AI回复推送到企业微信群,附带近期对话保持连贯。 + """ + webhook = _get_webhook() + if not webhook: + return + # 去重:同一客户+店铺,若客户消息与回复完全相同且在窗口期内,则跳过 + try: + import time + key = (customer_id or "", acc_id or "") + now = time.time() + last = _last_push.get(key) + if last: + last_customer_msg, last_reply_msg, last_ts = last + if (last_customer_msg or "") == (customer_msg or "") and (last_reply_msg or "") == (reply_msg or ""): + if now - last_ts < 30: + return + _last_push[key] = ((customer_msg or ""), (reply_msg or ""), now) + except Exception: + pass + reply_msg = _truncate(reply_msg, 300) + ts = datetime.now().strftime("%H:%M") + shop = acc_id or "未知店铺" + name = (customer_name or customer_id or "客户")[:12] + + lines = [f"**📩 {ts} | {shop}**"] + if goods_name: + lines.append(f"**商品** {_truncate(goods_name, 80)}") + if customer_id: + lines.append(f"**客户ID** {customer_id}") + lines.append("") + + # 附带近期对话,保持连贯 + recent = _get_recent_conversation(customer_id, acc_id, last_n=8) + for m in recent: + role = customer_id if m.get("direction") == "in" else "客服" + msg = _truncate((m.get("message") or "").strip(), 120) + if msg: + lines.append(f"{role}:{msg}") + # 当前回复(可能已在 recent 中有客户消息,客服回复是新的) + lines.append(f"客服:{reply_msg or '(无回复)'}") + + content = "\n".join(lines) + enc = content.encode("utf-8") + if len(enc) > 3800: + content = enc[:3750].decode("utf-8", errors="ignore") + "\n...(略)" + try: + async with httpx.AsyncClient(timeout=8) as client: + resp = await client.post( + webhook, + json={"msgtype": "markdown", "markdown": {"content": content}}, + ) + data = resp.json() + if data.get("errcode") == 0: + pass # 成功静默 + else: + print(f"[WechatChatLog] 推送失败: {data}") + except Exception as e: + print(f"[WechatChatLog] 推送异常: {e}") + + +async def send_morning_startup(): + """每天早上8点发送客服启动消息到企微群""" + webhook = _get_webhook() + if not webhook: + return + ts = datetime.now().strftime("%Y-%m-%d %H:%M") + content = f"**☀️ 客服已启动**\n{ts}" + try: + async with httpx.AsyncClient(timeout=8) as client: + await client.post( + webhook, + json={"msgtype": "markdown", "markdown": {"content": content}}, + ) + print(f"[WechatChatLog] 早8点启动消息已发送") + except Exception as e: + print(f"[WechatChatLog] 启动消息发送失败: {e}") + + +async def morning_startup_scheduler(): + """每天 8:00 发送启动消息""" + print("[WechatChatLog] 早8点启动消息定时任务已启动") + sent_today = None + while True: + now = datetime.now() + today = now.strftime("%Y-%m-%d") + if now.hour == 8 and now.minute == 0 and sent_today != today: + sent_today = today + await send_morning_startup() + await asyncio.sleep(30)