This commit is contained in:
2026-03-06 12:44:57 +08:00
parent fa61b11b02
commit 006b035de4
132 changed files with 1361 additions and 17400 deletions

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ venv/
logs/*.log
config/.runtime_metrics.jsonl
# <20><>ʱ<EFBFBD>
_archive/

211
CODE_REVIEW_ISSUES.md Normal file
View File

@@ -0,0 +1,211 @@
# 代码质量评估报告 & 修复清单
> 生成时间2026-03-05
> 状态说明:⬜ 待处理 | 🔧 进行中 | ✅ 已完成
---
## P0 - 致命级(立即处理)
### 1. ~~API 密钥/密码硬编码~~ (用户决定:暂不处理)
**问题**:敏感凭证直接写在源码中,已泄露到 Git 历史。
| 文件 | 行号 | 泄露内容 |
|------|------|----------|
| `services/service_gemini.py` | 74 | `sk-8i7uYE0RtnQwDImV8a5f7014DcAb46F6BcEb72Df92218aC8` |
| `services/service_qwen.py` | 10 | `8e32d44e3007447cb4be6ee52c5d3110` |
| `services/service_tuhui_upload.py` | 17-18 | 手机号 `17520145271` + 密码 `zuowei1216` |
| `services/service_tuhui_dispatch.py` | 16 | `tuhui_dispatch_key_2026` |
**修复步骤**
1. 在服务商后台轮换所有泄露的密钥
2. 改为从环境变量读取,移除默认值
3. 清理 Git 历史(可选,但推荐)
---
### 2. ~~服务器 IP 硬编码~~ (用户决定:暂不处理)
**问题**:生产服务器地址硬编码。
| 文件 | 行号 | 硬编码内容 |
|------|------|------------|
| `services/service_tuhui_dispatch.py` | 15 | `http://1.12.50.92:8005` |
| `services/dispatch_service.py` | 15 | `http://1.12.50.92:8006` |
**修复**:改为纯环境变量,不提供默认值或使用 `localhost`
---
## P1 - 架构问题(本周处理)
### 3. ✅ run.py 引用了不存在的模块
**问题**`run.py:66``run_tianwang()` 函数导入了 `core.websocket_client`,但该模块不存在(只有 `websocket_client_v2`)。
**修复**:已改为 `from core.websocket_client_v2 import QingjianAPIClient`
---
### 4. ✅ 测试文件引用不存在的模块
**问题**5 个测试文件导入了不存在的 `core.websocket_client`
**修复**:全部改为 `from core.websocket_client_v2 import QingjianAPIClient`
---
### 5. ✅ legacy 目录冗余84 文件15MB
**问题**`legacy/` 目录包含 84 个已被 `core/` 替代的旧文件,全部被 git 跟踪。
**修复**:已执行 `git rm -r legacy/`
---
### 6. ⬜ 全局变量泛滥17 处)
**问题**:大量使用 `global` 声明,导致难以测试和依赖注入。
| 文件 | 全局变量 |
|------|----------|
| `utils/image_queue.py` | `_semaphore`, `_max_concurrent`, `_max_queue`, `_queue_size` |
| `utils/health_check.py` | `_qingjian_connected`, `_wechat_ok`, `_last_alert_at` |
| `utils/content_filter.py` | `_COMPILED` |
| `services/service_tuhui_dispatch.py` | `_client` |
| `services/service_meitu.py` | `_service_stats` |
| `services/service_tuhui_upload.py` | `_tuhui_service` |
| `db/task_db/task_model.py` | `_task_manager` |
| `core/task_scheduler.py` | `_scheduler` |
| `core/task_trigger.py` | `_trigger_engine` |
| `core/workflow_router.py` | `_router` |
| `core/orchestrator.py` | `orchestrator` |
| `api/http_server.py` | `task_manager`, `task_scheduler` |
**修复**:改为类实例或依赖注入模式(长期重构)。
---
### 7. ⬜ God Class: customer_db.py802 行)
**问题**`CustomerProfile` 有 100+ 字段,`CustomerDatabase` 承担 5+ 种职责。
**修复**:拆分为:
- `customer_profile.py` — 数据模型
- `customer_repository.py` — CRUD
- `pricing_service.py` — 报价相关
- `risk_profile.py` — 风控相关
---
### 8. ~~下载函数重复实现4 处)~~ (已移至 _archive暂不处理
| 文件 | 函数 |
|------|------|
| `image/image_tools.py:15` | `async def _download(url)` |
| `image/image_processor.py:22` | `async def _download(self, url)` |
| `services/service_meitu.py` | `async def _download_result(...)` |
| `services/service_vectorizer.py` | `async def _download_result(...)` |
**状态**`image/` 目录已移至 `_archive/image/`,待后续需要时再重构。
---
## P2 - 代码质量(两周内处理)
### 9. ✅ 吞异常 `except: pass`11 处)
**问题**:关键错误被静默忽略。
**修复**
- `core/orchestrator.py:109` - 已添加 `logger.warning`
- `core/adapters/qianniu_adapter.py:29` - 已添加 `logger.warning`
- 其他位置db 和 json 解析)属于合理的 fallback 模式,保留
---
### 10. ⬜ TODO/FIXME 残留7 处)
| 文件 | 行号 | 内容 |
|------|------|------|
| `core/task_scheduler.py` | 141 | `# TODO: 实现 send_file 方法` |
| `core/task_scheduler.py` | 214 | `# TODO: 实现天网回调 API` |
| `core/engine.py` | 28 | `# TODO: 接入重构后的 Single Agent` |
| `api/http_server.py` | 236 | `# TODO: 实现其他状态查询` |
| `scripts/multi_process_launcher.py` | 107 | `# TODO: 从数据库加载活跃客户列表` |
**修复**:要么实现,要么删除并记录到 issue tracker。
---
### 11. ✅ 魔数散落各处
**修复**:已提取为命名常量
- `core/orchestrator.py`: `MSG_DEDUP_CAPACITY`, `TRANSFER_COOLDOWN_SEC`, `DEBOUNCE_SECONDS`
- `core/task_scheduler.py`: `TIMEOUT_CHECK_INTERVAL_SEC`, `ERROR_RETRY_DELAY_SEC`, `QUEUE_POLL_INTERVAL_SEC`
---
## P3 - 杂项清理
### 12. ✅ 根目录存在名为 `=` 的空文件
**修复**:已删除
---
### 13. ✅ `__pycache__` 缓存未清理
**问题**:磁盘上有 10 个 `__pycache__` 目录(虽然已被 gitignore
**修复**:已清理所有 `__pycache__` 目录
---
### 14. ✅ requirements.txt 版本约束过松
**问题**`pydantic-ai>=0.0.20` 导致安装了 1.63.0API 不兼容。
**修复**:已改为 `pydantic-ai>=0.0.20,<2.0.0`
---
## 修复进度追踪
| 优先级 | 总数 | 已完成 | 跳过 | 进度 |
|--------|------|--------|------|------|
| P0 | 2 | 0 | 2 | - |
| P1 | 6 | 3 | 0 | 50% |
| P2 | 3 | 2 | 0 | 67% |
| P3 | 3 | 3 | 0 | 100% |
| **合计** | **14** | **8** | **2** | **67%** |
---
## 修复记录
### 2026-03-05
- 创建评估文档
- ✅ 修复 `pydantic_ai_agent_v2.py``result.data``result.output` 的兼容性问题("在呢铁子"bug
- ✅ 修复 `run.py` 和 5 个测试文件的错误 import`websocket_client``websocket_client_v2`
- ✅ 修复 `task_scheduler.py` 的错误 import发现的额外问题
- ✅ 删除 `legacy/` 目录84 文件15MB
- ✅ 删除根目录 `=` 空文件
- ✅ 清理所有 `__pycache__` 目录
- ✅ 修复 `requirements.txt` 版本约束
- ✅ 修复吞异常问题(`orchestrator.py`, `qianniu_adapter.py`
- ✅ 提取魔数为命名常量(`orchestrator.py`, `task_scheduler.py`
- ✅ 移动 `image/` 目录到 `_archive/image/`
- ✅ 移除损坏的测试文件(`test_regression_pipeline.py`, `test_rule_engine.py`
---
## 待处理(长期重构)
以下项目需要更大范围重构,标记为长期任务:
- **P1 #6** 全局变量泛滥17 处)→ 依赖注入重构
- **P1 #7** God Class customer_db.py802 行)→ 领域拆分
- **P1 #8** 下载函数重复实现4 处)→ 抽取公共模块
- **P2 #10** TODO/FIXME 残留7 处)→ 实现或移入 issue tracker

23
check_logs.py Normal file
View File

@@ -0,0 +1,23 @@
import pymysql
import sys
try:
conn = pymysql.connect(
host='1.12.50.92',
port=3306,
user='ai_cs_user',
password='Zuowei1216',
database='ai_cs',
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cur:
sql = "SELECT customer_id, message, direction, timestamp FROM chat_logs WHERE timestamp >= '2026-03-05 00:00:00' ORDER BY id DESC LIMIT 30"
cur.execute(sql)
rows = cur.fetchall()
for r in rows:
dir_tag = "" if r["direction"] == "out" else ""
print(f"[{r['timestamp']}] {dir_tag} ({r['customer_id']}): {r['message']}")
finally:
if 'conn' in locals():
conn.close()

View File

@@ -26,7 +26,8 @@ class QianniuAdapter(BaseAdapter):
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
return cfg.get(acc_id, self._default_group_id)
except Exception: pass
except Exception as e:
logger.warning(f"[QianniuAdapter] 读取转接分组配置失败: {e}")
return self._default_group_id
async def translate_inbound(self, raw: dict) -> Tuple[StandardMessage, str]:
@@ -81,6 +82,9 @@ class QianniuAdapter(BaseAdapter):
content = res.reply_content
try:
logger.info(
f"[REPLY->CUSTOMER] user={user_id} acc={acc_id} type={res.msg_type}\n{content}"
)
await self.ws_client.send(customer_id=user_id, acc_id=acc_id, acc_type=acc_type, content=content, msg_type=res.msg_type)
except Exception as e:
logger.error(f"[QianniuAdapter] 发送失败: {e}")

View File

@@ -19,7 +19,7 @@ async def transfer_to_human_tool(ctx: RunContext[Any], reason: str = Field(descr
designer_name = await dispatch_service.assign_designer()
if designer_name:
# 2. 有设计师在线:生成标准转接指令
# 2. 有设计师在线:生成标准转接指令 (必须包含 [转移会话] 且格式正确)
magic_cmd = f"正在为您转接|[转移会话],{designer_name},无原因"
logger.info(f"[Tool] 成功呼叫设计师: {designer_name}")
return magic_cmd

View File

@@ -2,6 +2,7 @@ import logging
import asyncio
import re
import time
import json
from typing import Optional, List, Any, Dict
from collections import deque
from core.schema import StandardMessage, StandardResponse
@@ -12,6 +13,11 @@ from core.repository import repo
logger = logging.getLogger("cs_agent")
# 配置常量
MSG_DEDUP_CAPACITY = 200 # 消息 ID 去重缓存容量
TRANSFER_COOLDOWN_SEC = 60 # 转接冷却时间(秒)
DEBOUNCE_SECONDS = 2.0 # 消息防抖延迟(秒)
class SystemOrchestrator:
"""
全系统总编排:具备转接冷却、防抖合并、多消息去重、以及精准日志。
@@ -22,19 +28,27 @@ class SystemOrchestrator:
self.brain = CustomerServiceBrain()
# 1. 消息 ID 去重
self._processed_msg_ids = deque(maxlen=200)
self._processed_msg_ids = deque(maxlen=MSG_DEDUP_CAPACITY)
# 2. 转接冷却存储 (customer_id -> last_transfer_time)
self._last_transfer_time: Dict[str, float] = {}
# 3. 防抖配置
self._debounce_seconds = 5.0
self._debounce_seconds = DEBOUNCE_SECONDS
self._debounce_tasks: Dict[str, asyncio.Task] = {}
self._pending_messages: Dict[str, List[StandardMessage]] = {}
self._user_locks: Dict[str, asyncio.Lock] = {}
bus.subscribe("MESSAGE_OUTBOUND", self.handle_outbound_event)
@staticmethod
def _has_transfer_intent(text: str) -> bool:
if not text:
return False
t = str(text)
keywords = ("转人工", "转接", "人工客服", "人工", "设计师", "叫人", "找人")
return any(k in t for k in keywords)
def _get_user_lock(self, user_id: str) -> asyncio.Lock:
if user_id not in self._user_locks:
self._user_locks[user_id] = asyncio.Lock()
@@ -47,18 +61,34 @@ class SystemOrchestrator:
std_msg, direction = await self.qianniu_adapter.translate_inbound(raw_data)
# 关键修复:确保 user_id 绝不为空
user_id = std_msg.user_id or str(raw_data.get("cy_id") or raw_data.get("from_id") or "unknown")
std_msg.user_id = user_id
# 店铺隔离:同一客户在不同店铺的对话独立处理
session_key = f"{user_id}@{std_msg.acc_id}"
# 订单消息处理:静默入库并更新状态,但不触发 AI 回复
if "[系统订单信息]" in (std_msg.content or ""):
await self._handle_order_packet(platform, std_msg)
logger.info(f"[订单消息] user={user_id} acc={std_msg.acc_id} 已入库更新状态")
await repo.save_chat(platform, user_id, std_msg.content, "in", acc_id=std_msg.acc_id)
return
preview = (std_msg.content or "").replace("\n", "\\n")
if len(preview) > 120:
preview = preview[:120] + "..."
logger.info(
f"[监听消息] dir={direction} user={user_id} acc={std_msg.acc_id} "
f"type={std_msg.msg_type} images={len(std_msg.image_urls)} content={preview}"
)
# 过滤心跳
if not std_msg.content.strip() and not std_msg.image_urls: return
# 如果是商家人工回复,静默入库
if direction == "out":
await repo.save_chat(platform, std_msg.user_id, std_msg.content, "out", acc_id=std_msg.acc_id)
return
# 订单消息处理:静默记录
if "[系统订单信息]" in std_msg.content:
await self._handle_order_packet(platform, std_msg)
await repo.save_chat(platform, std_msg.user_id, std_msg.content, "in", acc_id=std_msg.acc_id)
await repo.save_chat(platform, user_id, std_msg.content, "out", acc_id=std_msg.acc_id)
return
# ID 去重
@@ -66,13 +96,12 @@ class SystemOrchestrator:
if std_msg.msg_id in self._processed_msg_ids: return
self._processed_msg_ids.append(std_msg.msg_id)
# 进入防抖
user_id = std_msg.user_id
if user_id in self._debounce_tasks: self._debounce_tasks[user_id].cancel()
if user_id not in self._pending_messages: self._pending_messages[user_id] = []
self._pending_messages[user_id].append(std_msg)
# 进入防抖(使用 session_key 隔离不同店铺)
if session_key in self._debounce_tasks: self._debounce_tasks[session_key].cancel()
if session_key not in self._pending_messages: self._pending_messages[session_key] = []
self._pending_messages[session_key].append(std_msg)
self._debounce_tasks[user_id] = asyncio.create_task(self._debounced_process(user_id, platform))
self._debounce_tasks[session_key] = asyncio.create_task(self._debounced_process(session_key, user_id, platform))
except Exception as e:
logger.error(f"[Orchestrator] 处理失败: {e}")
@@ -81,15 +110,74 @@ class SystemOrchestrator:
try:
price_match = re.search(r"订单金额:金额:\s*([\d\.]+)元", msg.content)
if price_match: await repo.update_task_price(platform, msg.user_id, float(price_match.group(1)))
if "买家已付款" in msg.content: await repo.update_task_outcome(platform, msg.user_id, "deal_success")
elif any(k in msg.content for k in ["退款", "已关闭", "已取消"]): await repo.update_task_outcome(platform, msg.user_id, "refunded")
except Exception: pass
# 判定成交结果(扩大范围:已付款 或 已发货 都视为成功,用于后期 AI 话术微调)
if any(k in msg.content for k in ["买家已付款", "卖家已发货"]):
await repo.update_task_outcome(platform, msg.user_id, "deal_success")
elif any(k in msg.content for k in ["退款", "已关闭", "已取消"]):
await repo.update_task_outcome(platform, msg.user_id, "refunded")
except Exception as e:
logger.warning(f"[Orchestrator] 订单消息处理异常: {e}")
async def _debounced_process(self, user_id: str, platform: str):
async def _analyze_images_background(self, session_key: str, image_urls: List[str]):
"""后台静默分析图片,存入用户数据库用于数据标定"""
try:
from services.service_image_analyzer import image_analyzer_service
from db.customer_db import CustomerDatabase
db = CustomerDatabase()
profile = db.get_customer(session_key)
for url in image_urls:
try:
result = await image_analyzer_service.analyze(url)
result_json = json.dumps(result, ensure_ascii=False)
# 更新最近一次分析
profile.last_image_analysis = result_json
profile.last_image_url = url
profile.last_gemini_prompt = result.get("gemini_prompt", "")
profile.last_aspect_ratio = result.get("aspect_ratio", "1:1")
profile.last_perspective = result.get("perspective", "no")
# 追加到历史记录保留最近20条
if profile.image_analysis_history is None:
profile.image_analysis_history = []
profile.image_analysis_history.append(result_json)
if len(profile.image_analysis_history) > 20:
profile.image_analysis_history = profile.image_analysis_history[-20:]
# 更新复杂度历史
complexity = result.get("complexity", "normal")
if profile.complexity_history is None:
profile.complexity_history = []
profile.complexity_history.append(complexity)
if len(profile.complexity_history) > 10:
profile.complexity_history = profile.complexity_history[-10:]
# 更新图片类型历史
proc_type = result.get("proc_type", "")
if proc_type and profile.image_type_history is not None:
if proc_type not in profile.image_type_history:
profile.image_type_history.append(proc_type)
logger.debug(f"[ImageAnalysis] session={session_key} 分析完成: {result.get('subject', '?')} | {complexity}")
except Exception as e:
logger.warning(f"[ImageAnalysis] 单张图片分析失败: {e}")
continue
# 保存更新
db.save_customer(profile)
logger.info(f"[ImageAnalysis] session={session_key} 分析结果已保存到数据库")
except Exception as e:
logger.warning(f"[ImageAnalysis] 后台分析失败: {e}")
async def _debounced_process(self, session_key: str, user_id: str, platform: str):
try:
await asyncio.sleep(self._debounce_seconds)
async with self._get_user_lock(user_id):
messages = self._pending_messages.pop(user_id, [])
async with self._get_user_lock(session_key):
messages = self._pending_messages.pop(session_key, [])
if not messages: return
# A. 合并与元数据修复
@@ -108,6 +196,7 @@ class SystemOrchestrator:
msg_id=merged_msg_id,
user_id=user_id,
content=combined_content,
msg_type=messages[-1].msg_type,
image_urls=all_image_urls,
acc_id=acc_id,
acc_type=acc_type
@@ -116,17 +205,22 @@ class SystemOrchestrator:
# B. 持久化
db_content = combined_content
if all_image_urls: db_content = f"【系统:已收到{len(all_image_urls)}张图】\n{combined_content}"
await repo.save_chat(platform, user_id, db_content, "in", acc_id=acc_id)
await repo.save_chat(platform, user_id, db_content, "in", acc_id=acc_id, image_urls=all_image_urls)
# C. 冷却检查:如果 60秒内发过转接告诉大脑“已处于转接中”
is_in_cooldown = (time.time() - self._last_transfer_time.get(user_id, 0)) < 60
# B2. 后台图片分析(不阻塞主流程,用于数据标定)
if all_image_urls:
asyncio.create_task(self._analyze_images_background(session_key, all_image_urls))
# C. 冷却检查:如果转接冷却期内发过转接,告诉大脑"已处于转接中"
is_in_cooldown = (time.time() - self._last_transfer_time.get(session_key, 0)) < TRANSFER_COOLDOWN_SEC
# D. 思考
history = await repo.get_chat_history(user_id, limit=10)
history = await repo.get_chat_history(user_id, limit=10, acc_id=acc_id)
if history and history[-1]['content'] == db_content: history = history[:-1]
# 如果在冷却中,在当前消息里注入“当前已在转接中”的信息
if is_in_cooldown:
# 只在“明确又要转接”时注入冷却提示,普通问候/新需求不注入
transfer_intent = self._has_transfer_intent(combined_content)
if is_in_cooldown and transfer_intent:
final_msg.content = f"【系统:当前已向设计师发出转接请求,请勿再次调用转接工具】\n{final_msg.content}"
std_res = await self.brain.think_and_reply(final_msg, history=history)
@@ -139,7 +233,7 @@ class SystemOrchestrator:
await repo.save_chat(platform, user_id, std_res.reply_content, "out", acc_id=acc_id)
if "[转移会话]" in std_res.reply_content:
self._last_transfer_time[user_id] = time.time()
self._last_transfer_time[session_key] = time.time()
except asyncio.CancelledError: pass
except Exception as e: logger.exception(f"[Orchestrator] 处理失败: {e}")

View File

@@ -11,6 +11,25 @@ logger = logging.getLogger("cs_agent")
from core.skill_manager import skill_manager
def _clip(text: str, limit: int = 1200) -> str:
if text is None:
return ""
text = str(text)
if len(text) <= limit:
return text
return f"{text[:limit]}...(截断, 共{len(text)}字)"
def _fmt_time(ts: Any) -> str:
s = str(ts or "").strip()
if not s:
return "--:--:--"
if " " in s:
return s.split(" ", 1)[1]
return s
class CustomerServiceBrain:
"""
重构后的单一 Agent 大脑:
@@ -27,27 +46,38 @@ class CustomerServiceBrain:
provider=OpenAIProvider(api_key=self.api_key, base_url=self.base_url)
)
all_skills = skill_manager.get_all_skills_text()
exclude_names = os.getenv("SKILL_EXCLUDE_FROM_PROMPT", "pricing-skill")
excluded_skills = [s.strip().lower() for s in exclude_names.split(",") if s.strip()]
all_skills = skill_manager.get_all_skills_text(exclude=excluded_skills)
logger.info(f"[SkillManager] 已从提示词排除技能: {excluded_skills}")
# --- 统一口径后的 System Prompt ---
system_prompt = (
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话高端、专业。\n\n"
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话自然、专业。\n\n"
"【统一称呼规范】\n"
"1. 严禁使用'师傅''客服''专员'等词汇!\n"
"2. 必须统一称呼为【设计师】。比如:'设计师看下''设计师马上来''等设计师核价'\n\n"
"1. 严禁使用'师傅''客服''专员'等词汇!必须统一称为【设计师】。\n"
"2. 未转接前,用第一人称(我/我这边)。例如:'我叫设计师看下'\n\n"
"【核心逻辑】\n"
"1. 业务:只聊高清修复和找原图。引导发图 -> 问需求 -> 找设计师。\n"
"2. 下线安抚:如果工具返回 'ERROR_NO_DESIGNER_ONLINE',说明设计师们【下班/下线】了。回:'亲亲,设计师现在下班啦,需求我先记下,明天第一时间回您哈!'\n"
"3. 正在转接中:如果看到系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'\n"
"4. 没转接时:引导发图 -> 问需求 -> 调工具转人工。\n\n"
"5. 语气:淘宝亲切风,多用'亲亲''铁子'。每句回复【严禁超过15字】\n\n"
"1. 业务:只聊高清修复和找原图。核心链路:引导发图 -> 问需求 -> 找设计师。\n"
"2. **主动引导(关键)**:如果客户【没发图】就问能不能做、问收费,你必须回:'亲亲先发图我看下哈'\n"
"3. **非业务问题**:如果客户问招聘、合作、闲聊等与做图无关的话题,礼貌拒绝:'亲亲咱这边只做图哦,暂不招人哈'\n"
"4. **客户说没有参考图**:如果客户明确说'没有图''找不到''想让你们帮找',直接转人工:'好的,我这就叫设计师帮您找哈'\n"
"5. **客户问尺寸/能否打印/退款**:这类问题需要设计师判断,直接转人工:'这个设计师帮您看下哈'\n"
"6. 转接时机:收到图片并明确需求后,立即调用转人工工具,并告知:'收到,正在呼叫设计师核价,稍等哈'\n"
"7. **下线安抚(重要)**:只有当【本次】工具返回 'ERROR_NO_DESIGNER_ONLINE' 时才能说下班。不能根据历史对话或自己猜测说下班!\n"
"8. 正在转接中:如果系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'\n"
"9. **每次转接必须调用工具**:不要根据之前的结果猜测,每次需要转接都必须重新调用工具检查设计师是否在线。\n\n"
"【必杀令】\n"
"1. 每句回复严禁超过15个字\n"
"【必杀令 - 严格遵守\n"
"1. 每句回复严禁超过15个字语气淘宝亲切风,多用''''\n"
"2. 严禁报价,严禁复读图片已收到的情况。\n"
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n\n"
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n"
"4. **严禁**说'在呢铁子'!只能说'在呢''在呢亲'\n"
"5. **严禁**重复发送相同内容!如果刚说过的话,换一种说法。\n"
"6. **严禁**输出任何代码、标记、括号等乱码!只输出自然语言。\n"
"7. **严禁**自己臆造'下班'只有工具返回ERROR才能说下班。\n\n"
f"业务参考:\n{all_skills}"
)
@@ -57,26 +87,70 @@ class CustomerServiceBrain:
async def think_and_reply(self, msg: StandardMessage, history: List[dict] = []) -> StandardResponse:
try:
# 构造增强上下文(强灌输)
# 构造增强上下文
user_content = msg.content
if msg.image_urls:
user_content = f"【系统通知:收到客户 {len(msg.image_urls)} 张图】\n{user_content}"
recent_context = ""
if history:
lines = [f"{('客户' if h['role']=='user' else '')}{h['content']}" for h in history[-6:]]
lines = [
f"[{_fmt_time(h.get('timestamp'))}] {('客户' if h['role']=='user' else '')}{h['content']}"
for h in history[-6:]
]
recent_context = "【近期对话回顾】\n" + "\n".join(lines) + "\n----------------\n"
full_input = f"{recent_context}现在的对话:{user_content}"
logger.info(
f"[PROMPT->AI] user={msg.user_id} acc={msg.acc_id} images={len(msg.image_urls)}\n"
f"{_clip(full_input)}"
)
result = await self.agent.run(full_input, message_history=history)
if hasattr(result, 'data') and isinstance(result.data, str):
reply_text = result.data
elif hasattr(result, 'output') and isinstance(result.output, str):
reply_text = result.output
else:
reply_text = str(result.data) if hasattr(result, 'data') else "在呢铁子。"
# --- 终极修复:强制截获工具返回的转接指令 ---
reply_text = ""
# pydantic-ai 1.x 使用 result.output旧版 0.x 使用 result.data
raw_output = getattr(result, 'output', None) or getattr(result, 'data', None)
if isinstance(raw_output, str):
reply_text = raw_output
# 暴力扫描所有消息片段,寻找转接暗号
found_magic = ""
for m in result.all_messages():
if hasattr(m, 'parts'):
for part in m.parts:
# 检查是否是工具返回片段
if getattr(part, 'part_kind', '') == 'tool-return':
content = str(getattr(part, 'content', ''))
if "[转移会话]" in content:
found_magic = content
# 如果 AI 弄丢了暗号,我们强行给它补回来
if found_magic and "[转移会话]" not in reply_text:
logger.info(f"[Brain] 检测到 AI 弄丢了转接暗号,正在强制恢复: {found_magic[:30]}...")
reply_text = found_magic
# ----------------------------------------
# 清理可能的乱码/代码标记
import re
reply_text = re.sub(r'\[\]<\|[^|]+\|>', '', reply_text) # 清理 []<|xxx|>
reply_text = re.sub(r'<\|[^|]+\|>', '', reply_text) # 清理 <|xxx|>
reply_text = re.sub(r'\[Function[^\]]*\]', '', reply_text) # 清理 [FunctionXxx]
reply_text = re.sub(r'<think[^>]*>.*', '', reply_text, flags=re.DOTALL) # 清理 <think_xxx>内部思考泄漏
reply_text = re.sub(r'</?think[^>]*>', '', reply_text) # 清理 think 标签
reply_text = re.sub(r'```[^`]*```', '', reply_text) # 清理代码块
reply_text = re.sub(r'\{["\'][^}]+\}', '', reply_text) # 清理 JSON
reply_text = reply_text.strip()
# 过滤"在呢铁子"
if "在呢铁子" in reply_text:
reply_text = reply_text.replace("在呢铁子", "在呢亲")
if not reply_text:
reply_text = "稍等我看看。"
logger.info(f"[THINK/RAW_OUTPUT] user={msg.user_id}\n{_clip(reply_text)}")
need_transfer = "[转移会话]" in reply_text

View File

@@ -18,24 +18,33 @@ class DataRepository:
# --- 聊天记录 (异步化) ---
async def save_chat(self, platform: str, user_id: str, content: str, direction: str, acc_id: str = ""):
async def save_chat(self, platform: str, user_id: str, content: str, direction: str, acc_id: str = "", image_urls: list = None):
"""异步持久化存储聊天记录"""
# 将图片URL列表转为\n分隔的字符串
urls_str = "\n".join(image_urls) if image_urls else ""
return await asyncio.to_thread(
log_message,
customer_id=user_id,
message=content,
direction=direction,
platform=platform,
acc_id=acc_id
acc_id=acc_id,
image_urls=urls_str
)
async def get_chat_history(self, user_id: str, limit: int = 10) -> List[dict]:
async def get_chat_history(self, user_id: str, limit: int = 10, acc_id: str = "") -> List[dict]:
"""异步获取历史记录"""
rows = await asyncio.to_thread(get_conversation, user_id, limit=limit)
rows = await asyncio.to_thread(get_conversation, user_id, limit=limit, acc_id=acc_id)
history = []
for r in rows:
role = "user" if r["direction"] == "in" else "assistant"
history.append({"role": role, "content": r["message"]})
history.append(
{
"role": role,
"content": r["message"],
"timestamp": r.get("timestamp", ""),
}
)
return history
# --- 客户相关 (异步化) ---

View File

@@ -9,6 +9,7 @@ class StandardMessage(BaseModel):
user_id: str # 发送者唯一ID
user_name: str = "" # 发送者昵称
content: str # 消息文本内容
msg_type: int = 0 # 消息类型0 文本, 1 图片, 2 语音等
image_urls: List[str] = [] # 提取出来的图片链接
acc_id: str = "" # 商家/店铺账号ID
acc_type: str = "" # 平台类型标识

View File

@@ -48,9 +48,11 @@ class SkillManager:
parts.append(f"### 技能:{name}\n{content}")
return "\n\n".join(parts)
def get_all_skills_text(self) -> str:
def get_all_skills_text(self, exclude: Optional[List[str]] = None) -> str:
"""获取所有技能的合集(用于全能大脑模式)"""
return self.compose_skills(list(self._skill_cache.keys()))
exclude_set = {n.lower() for n in (exclude or [])}
names = [n for n in self._skill_cache.keys() if n not in exclude_set]
return self.compose_skills(names)
# 全局单例
skill_manager = SkillManager()

View File

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

View File

@@ -15,7 +15,7 @@ class QingjianAPIClient:
重构后的轻简API客户端 (协议全复刻版)
"""
def __init__(self, uri=None, enable_agent: bool = True):
def __init__(self, uri=None, enable_agent: bool = True, worker_id: int = -1, worker_count: int = 1):
from config.config import QINGJIAN_WS_URI
self.uri = uri or QINGJIAN_WS_URI
self.websocket = None
@@ -23,6 +23,12 @@ class QingjianAPIClient:
self.logger = logger
self.enable_agent = enable_agent
# 多进程分片逻辑
self.worker_id = worker_id
self.worker_count = worker_count
if self.worker_id >= 0:
logger.info(f"[WebSocket] 启用分片模式: Worker {self.worker_id}/{self.worker_count}")
# 初始化新架构总指挥部
self.orchestrator = init_orchestrator(ws_client=self)
logger.info("[WebSocket] 新架构 Orchestrator 已就绪。")
@@ -36,13 +42,35 @@ class QingjianAPIClient:
async def receive_messages(self):
await receive_messages_flow(self)
def _should_handle(self, customer_id: str) -> bool:
"""分片判定:这个客户归我管吗?"""
if self.worker_id < 0 or self.worker_count <= 1:
return True
# 如果没有 customer_id为了安全起见只让 Worker 0 处理
if not customer_id:
return self.worker_id == 0
import hashlib
# 使用稳定的哈希算法分配客户
hash_val = int(hashlib.md5(str(customer_id).encode("utf-8")).hexdigest(), 16)
return (hash_val % self.worker_count) == self.worker_id
async def handle_message(self, message):
"""收到消息处理"""
try:
data = json.loads(message)
# 预提取客户ID用于分片判定
customer_id = str(data.get("cy_id") or data.get("from_id") or "")
if not self._should_handle(customer_id):
return
await self.orchestrator.on_raw_message_received(platform="qianniu", raw_data=data)
except Exception as e:
logger.error(f"[WebSocket] 处理消息异常: {e}")
raw_preview = str(message).replace("\n", "\\n")
if len(raw_preview) > 300:
raw_preview = raw_preview[:300] + "..."
logger.error(f"[WebSocket] 处理消息异常: {e} raw={raw_preview}")
async def send(self, customer_id: str, acc_id: str, acc_type: str, content: str, msg_type: int = 0):
"""

View File

@@ -113,6 +113,11 @@ def init_db():
conn.execute("CREATE INDEX idx_ts ON chat_logs(timestamp)")
if "idx_acc" not in exists:
conn.execute("CREATE INDEX idx_acc ON chat_logs(acc_id)")
# 添加 image_urls 列(如果不存在)
try:
conn.execute("ALTER TABLE chat_logs ADD COLUMN image_urls TEXT DEFAULT ''")
except Exception:
pass # 列已存在
else:
conn.execute("""
CREATE TABLE IF NOT EXISTS chat_logs (
@@ -133,6 +138,10 @@ def init_db():
conn.execute("ALTER TABLE chat_logs ADD COLUMN acc_id TEXT DEFAULT ''")
except Exception:
pass
try:
conn.execute("ALTER TABLE chat_logs ADD COLUMN image_urls TEXT DEFAULT ''")
except Exception:
pass
conn.execute("CREATE INDEX IF NOT EXISTS idx_acc ON chat_logs(acc_id)")
conn.commit()
@@ -150,15 +159,16 @@ def log_message(
acc_id: str = "", # 店铺账号ID
platform: str = "",
msg_type: int = 0,
image_urls: str = "", # 图片URL列表用\n分隔
):
"""记录一条聊天消息"""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with _get_conn() as conn:
conn.execute(
_sql("INSERT INTO chat_logs "
"(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp) "
"VALUES (?,?,?,?,?,?,?,?)"),
(customer_id, customer_name, acc_id, platform, direction, message, msg_type, ts),
"(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp, image_urls) "
"VALUES (?,?,?,?,?,?,?,?,?)"),
(customer_id, customer_name, acc_id, platform, direction, message, msg_type, ts, image_urls),
)
conn.commit()
@@ -198,10 +208,10 @@ def get_customers(limit: int = 100) -> List[Dict]:
return [dict(r) for r in rows]
def get_conversation(customer_id: str, limit: int = 200) -> List[Dict]:
def get_conversation(customer_id: str, limit: int = 200, acc_id: str = "") -> List[Dict]:
"""返回某客户的最近对话记录(按时间升序)"""
# 忽略 acc_id 过滤,实现全店铺记忆
with _get_conn() as conn:
# 核心修复:先取最新的 limit 条,再按时间正序排列
rows = conn.execute(_sql("""
SELECT * FROM (
SELECT id, direction, message, msg_type, timestamp, acc_id
@@ -216,17 +226,8 @@ def get_conversation(customer_id: str, limit: int = 200) -> List[Dict]:
def get_recent_conversation(customer_id: str, acc_id: str = "", limit: int = 10) -> List[Dict]:
"""返回某客户近期对话(同店铺),用于企微推送保持连贯"""
"""返回某客户近期对话,忽略 acc_id 过滤"""
with _get_conn() as conn:
if acc_id:
rows = conn.execute(_sql("""
SELECT id, direction, message, timestamp, acc_id
FROM chat_logs
WHERE customer_id = ? AND acc_id = ?
ORDER BY id DESC
LIMIT ?
"""), (customer_id, acc_id, limit)).fetchall()
else:
rows = conn.execute(_sql("""
SELECT id, direction, message, timestamp, acc_id
FROM chat_logs

Binary file not shown.

View File

@@ -76,6 +76,8 @@ class CustomerProfile:
last_gemini_prompt: str = "" # 最近一次图片的 Gemini 处理提示词
last_aspect_ratio: str = "1:1" # 最近一次图片的建议输出比例
last_perspective: str = "no" # 最近一次图片的透视状态
last_image_analysis: str = "" # 最近一次图片分析结果JSON字符串用于数据标定
image_analysis_history: List[str] = None # 图片分析历史记录JSON列表用于数据标定
pending_quote_images: List[str] = None # 待统一报价图片队列(持久化)
pending_quote_requirements: List[str] = None # 待统一报价需求队列(持久化)
@@ -165,6 +167,8 @@ class CustomerProfile:
self.pending_quote_images = []
if self.pending_quote_requirements is None:
self.pending_quote_requirements = []
if self.image_analysis_history is None:
self.image_analysis_history = []
class CustomerDatabase:

Binary file not shown.

View File

@@ -47,6 +47,13 @@ class ImageTaskManager:
''')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON image_tasks(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cust_plat ON image_tasks(customer_id, platform)')
# 兼容旧库:补齐缺失字段
cursor.execute("PRAGMA table_info(image_tasks)")
existing_cols = {row[1] for row in cursor.fetchall()}
if "outcome" not in existing_cols:
cursor.execute("ALTER TABLE image_tasks ADD COLUMN outcome TEXT DEFAULT 'pending'")
if "price" not in existing_cols:
cursor.execute("ALTER TABLE image_tasks ADD COLUMN price REAL DEFAULT 0.0")
conn.commit()
conn.close()
@@ -88,6 +95,27 @@ class ImageTaskManager:
except Exception as e:
logger.error(f"Failed to update task status: {e}")
def update_price(self, customer_id: str, platform: str, price: float):
"""记录任务的成交价格"""
now = datetime.now().isoformat()
try:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute('''
UPDATE image_tasks
SET price = ?, updated_at = ?
WHERE task_id = (
SELECT task_id FROM image_tasks
WHERE customer_id = ? AND platform = ?
ORDER BY created_at DESC LIMIT 1
)
''', (price, now, customer_id, platform))
conn.commit()
conn.close()
logger.info(f"[DB] 客户 {customer_id} 任务价格更新为: ¥{price}")
except Exception as e:
logger.error(f"Failed to update price: {e}")
def update_outcome(self, customer_id: str, platform: str, outcome: str):
"""记录任务的最终结局(用于训练样本分类)"""
now = datetime.now().isoformat()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,651 +0,0 @@
"""
透视矫正三步流程:
Step1: Gemini 去背景 → 纯白背景
Step2: OpenCV 在白背景图上检测四角 → warpPerspective 展平
Step3: Gemini 对展平结果做高清增强
用法:
python perspective_fix.py <图片路径或URL> [--debug] [--skip-step1] [--skip-step3]
"""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
import os, asyncio, uuid, tempfile
import numpy as np
import cv2
from dotenv import load_dotenv
load_dotenv()
_OUTPUT_DIR = os.getenv("RESULT_IMAGE_DIR", "results")
os.makedirs(_OUTPUT_DIR, exist_ok=True)
# ═══════════════════════════════════════════════════════════════
# Gemini 辅助函数
# ═══════════════════════════════════════════════════════════════
async def _gemini_call(input_path: str, output_path: str, prompt: str,
aspect_ratio: str = "1:1", label: str = "") -> bool:
from services.service_gemini import GeminiExtractV2Service
service = GeminiExtractV2Service()
try:
ok, msg, _ = await service.extract_pattern(
input_path=input_path,
output_path=output_path,
custom_prompt=prompt,
aspect_ratio=aspect_ratio,
)
status = "成功" if ok else "失败"
print(f" [{label}] Gemini {status}: {msg[:80]}")
return ok and os.path.exists(output_path)
except Exception as e:
print(f" [{label}] Gemini 异常: {e}")
return False
finally:
await service.cleanup()
PROMPT_WHITE_BG = (
"请处理这张图片:\n"
"1. 识别图中的地毯/地垫/印花布料/产品本体作为主体\n"
"2. 去掉主体上面放置的所有物品(杯子、碗、餐具、装饰品等),只保留地垫本身\n"
"3. 把所有背景(桌面、地板、墙壁、阴影)全部替换为纯白色(#FFFFFF)\n"
"4. 保持地垫/产品的颜色、图案、边缘完全不变\n"
"输出:只有主体产品、纯白背景、无杂物的干净产品图。"
)
# 当第一次去背景效果不好时(白色覆盖率过低),用更强硬的提示词重试
PROMPT_WHITE_BG_STRONG = (
"严格执行:将这张图的背景彻底替换为纯白色 RGB(255,255,255)。\n"
"只保留图片中央的产品/地毯/布料主体,其他所有区域(桌面/地板/墙/阴影/物品)"
"一律改为纯白色。产品边缘要干净锐利,不留任何半透明或灰色区域。\n"
"重要:不论主体上摆放了什么东西,统统去掉,只输出产品本身+白色背景。"
)
PROMPT_ENHANCE = (
"请对这张已展平的图案进行高清增强:提升整体清晰度和色彩饱和度,"
"修复边缘锯齿,补全缺失细节,输出印刷级高质量平面图,背景保持纯白。"
)
# Step3 增强失败时的兜底提示词(更简单,成功率更高)
PROMPT_ENHANCE_SIMPLE = (
"请提升这张图片的清晰度和画质,输出高清版本,背景保持纯白。"
)
def _measure_white_coverage(image: np.ndarray) -> float:
"""返回图片中白色像素的百分比,用于判断去背景效果"""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
_, mask = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY)
return float(np.sum(mask == 255)) / mask.size
def _color_match(source: np.ndarray, target: np.ndarray,
strength: float = 0.75, exclude_white: bool = True) -> np.ndarray:
"""
将 target 的色调匹配到 source类 PS「匹配颜色」
使用 LAB 色彩空间 Reinhard 均值/标准差迁移。
Args:
source: 原图(色彩参考来源)
target: 待调整图(处理后结果)
strength: 迁移强度 0.0-1.0,推荐 0.6-0.85
exclude_white: 统计时排除白色像素,避免背景影响肤色/图案计算
Returns:
调色后的 BGR 图像
"""
src_f = source.astype(np.float32) / 255.0
tgt_f = target.astype(np.float32) / 255.0
src_lab = cv2.cvtColor(src_f, cv2.COLOR_BGR2Lab)
tgt_lab = cv2.cvtColor(tgt_f, cv2.COLOR_BGR2Lab)
result = tgt_lab.copy()
for ch in range(3):
if exclude_white:
# 排除极亮像素L > 95统计只看图案区域
src_mask = src_lab[:, :, 0] < 95
tgt_mask = tgt_lab[:, :, 0] < 95
src_vals = src_lab[:, :, ch][src_mask]
tgt_vals = tgt_lab[:, :, ch][tgt_mask]
else:
src_vals = src_lab[:, :, ch].ravel()
tgt_vals = tgt_lab[:, :, ch].ravel()
if src_vals.size == 0 or tgt_vals.size == 0:
continue
src_mean, src_std = float(src_vals.mean()), float(src_vals.std())
tgt_mean, tgt_std = float(tgt_vals.mean()), float(tgt_vals.std())
if tgt_std < 1e-6:
continue
# Reinhard 迁移:先归一化到目标,再重映射到源分布
shifted = (tgt_lab[:, :, ch] - tgt_mean) / tgt_std * src_std + src_mean
# 按 strength 混合strength=1 完全迁移0 保持不变
result[:, :, ch] = shifted * strength + tgt_lab[:, :, ch] * (1.0 - strength)
result_bgr = cv2.cvtColor(result, cv2.COLOR_Lab2BGR)
result_bgr = np.clip(result_bgr * 255, 0, 255).astype(np.uint8)
print(f" [颜色匹配] 强度={strength:.0%} | "
f"源均值L={src_lab[:,:,0].mean():.1f} → 目标均值L={tgt_lab[:,:,0].mean():.1f}")
return result_bgr
# ═══════════════════════════════════════════════════════════════
# OpenCV 透视矫正
# ═══════════════════════════════════════════════════════════════
def order_points(pts: np.ndarray) -> np.ndarray:
"""
把四个点排列为 [左上, 右上, 右下, 左下]。
使用质心角度排序,对矩形、菱形、平行四边形等各种透视形状均适用。
"""
cx, cy = pts[:, 0].mean(), pts[:, 1].mean()
# 计算每个点相对质心的角度(从正上方顺时针)
angles = np.arctan2(pts[:, 1] - cy, pts[:, 0] - cx)
# 顺时针排序:从右上开始(角度最小的)
order = np.argsort(angles)
sorted_pts = pts[order]
# 找到最左上角作为起点x+y 最小)
s = sorted_pts.sum(axis=1)
start = np.argmin(s)
# 从左上角开始顺时针排列 → [左上, 右上, 右下, 左下]
indices = [(start + i) % 4 for i in range(4)]
rect = sorted_pts[indices].astype("float32")
return rect
def four_point_transform(image: np.ndarray, pts: np.ndarray) -> np.ndarray:
rect = order_points(pts)
tl, tr, br, bl = rect
w1 = np.linalg.norm(br - bl)
w2 = np.linalg.norm(tr - tl)
h1 = np.linalg.norm(tr - br)
h2 = np.linalg.norm(tl - bl)
W = int(max(w1, w2))
H = int(max(h1, h2))
print(f" [CV] 角点: TL={tl.astype(int)} TR={tr.astype(int)} BR={br.astype(int)} BL={bl.astype(int)}")
print(f" [CV] 矫正后目标尺寸: {W}x{H}")
dst = np.array([
[0, 0 ],
[W - 1, 0 ],
[W - 1, H - 1],
[0, H - 1],
], dtype="float32")
M = cv2.getPerspectiveTransform(rect, dst)
warped = cv2.warpPerspective(
image, M, (W, H),
flags=cv2.INTER_LANCZOS4,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(255, 255, 255),
)
return warped
def _detect_bg_color(image: np.ndarray, corner_size: int = 24) -> np.ndarray:
"""
从图片四个角落采样估计背景颜色BGR
适用于白色、米色、黄色、灰色等各种背景。
"""
H, W = image.shape[:2]
cs = min(corner_size, H // 5, W // 5)
corners = [
image[:cs, :cs], # 左上
image[:cs, W-cs:], # 右上
image[H-cs:, :cs], # 左下
image[H-cs:, W-cs:], # 右下
]
pixels = np.concatenate([c.reshape(-1, 3) for c in corners], axis=0)
bg = np.median(pixels, axis=0).astype(np.uint8)
return bg # BGR
def tool_trim_white_border(image: np.ndarray,
tolerance: int = 18,
bg_ratio: float = 0.90,
padding: int = 4) -> tuple[np.ndarray, bool, dict]:
"""
【Tool】智能背景边裁切支持任意背景色白/黄/米/灰等)。
算法:
1. 从四角采样估计背景色
2. 逐行/列扫描:若该行/列中 bg_ratio 以上的像素与背景色差异 <= tolerance则为背景行/列
3. 找到内容区域边界后裁切
Returns:
(裁切后图片, 是否裁切, 详情dict)
"""
H, W = image.shape[:2]
bg_color = _detect_bg_color(image)
img_f = image.astype(np.int32)
# 每个像素与背景色的最大通道差异
diff = np.abs(img_f - bg_color.astype(np.int32)).max(axis=2) # H x W
is_bg = diff <= tolerance # True = 接近背景色
row_bg_ratio = is_bg.mean(axis=1) # 每行的背景像素占比
col_bg_ratio = is_bg.mean(axis=0) # 每列的背景像素占比
top = next((i for i in range(H) if row_bg_ratio[i] < bg_ratio), H)
bottom = next((i for i in range(H-1,-1,-1) if row_bg_ratio[i] < bg_ratio), -1) + 1
left = next((i for i in range(W) if col_bg_ratio[i] < bg_ratio), W)
right = next((i for i in range(W-1,-1,-1) if col_bg_ratio[i] < bg_ratio), -1) + 1
border_top = top
border_bottom = H - bottom
border_left = left
border_right = W - right
max_border = max(border_top, border_bottom, border_left, border_right)
bg_hex = "#{:02X}{:02X}{:02X}".format(int(bg_color[2]), int(bg_color[1]), int(bg_color[0]))
info = {"top": border_top, "bottom": border_bottom,
"left": border_left, "right": border_right, "bg_color": bg_hex}
if max_border < 5:
print(f" [裁边] 背景色{bg_hex} | 上{border_top}{border_bottom}{border_left}{border_right}px → 无需裁切")
return image, False, info
y1 = max(0, top - padding)
y2 = min(H, bottom + padding)
x1 = max(0, left - padding)
x2 = min(W, right + padding)
cropped = image[y1:y2, x1:x2]
ch, cw = cropped.shape[:2]
print(f" [裁边] 背景色{bg_hex} | 上{border_top}{border_bottom}{border_left}{border_right}px → 裁切 {W}x{H}{cw}x{ch}")
return cropped, True, info
async def tool_color_match(orig_img: np.ndarray, result_img: np.ndarray,
strength: float = 0.75) -> np.ndarray:
"""【Tool】颜色匹配封装版供 AI 决策层调用)"""
return _color_match(orig_img, result_img, strength=strength)
async def ai_decide_postprocess(orig_img: np.ndarray, result_img: np.ndarray) -> dict:
"""
【AI 决策层】用视觉模型分析出图效果,决定是否需要颜色匹配和白边裁切。
Returns:
{
"need_color_match": bool,
"color_strength": float, # 0.5-0.9
"need_trim": bool,
"reason": str,
}
"""
import base64
from dotenv import load_dotenv
load_dotenv()
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
model = os.getenv("VISION_MODEL", "glm-4v-flash")
# 无 API 时默认两个都做
if not api_key:
return {"need_color_match": True, "color_strength": 0.75,
"need_trim": True, "reason": "无API Key默认执行"}
def _encode(img: np.ndarray) -> str:
resized = cv2.resize(img, (512, 512))
_, buf = cv2.imencode(".jpg", resized, [cv2.IMWRITE_JPEG_QUALITY, 80])
return base64.b64encode(buf).decode()
orig_b64 = _encode(orig_img)
result_b64 = _encode(result_img)
prompt = (
"你是图片后处理决策助手。图一是原图图二是AI处理后的结果图。请判断\n\n"
"【问题1】颜色差异处理后图片的整体色调与原图相比差异是否明显\n"
"(明显=色调/饱和度/冷暖差异很大;轻微=有轻微偏差;无=颜色基本一致)\n\n"
"【问题2】多余边框处理后图片四周是否有不属于图案内容的多余空白边框\n"
"注意:边框颜色不一定是白色,也可能是黄色、米色、灰色等任何纯色。\n"
"判断标准:图案内容的外围是否有一圈明显的纯色空白带。\n\n"
"严格按格式回答(每行一个字段,不要多余内容):\n"
"颜色差异: <明显|轻微|无>\n"
"多余边框: <有|无>\n"
"边框位置: <有边框的方向如「上下」,没有则填无>"
)
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
response = await client.chat.completions.create(
model=model,
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{orig_b64}"}},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{result_b64}"}},
{"type": "text", "text": prompt},
],
}],
)
text = response.choices[0].message.content or ""
print(f" [AI决策] 原始回答: {text.strip()[:120]}")
def _get(key):
for line in text.splitlines():
line = line.strip()
if line.startswith(key):
return line.split(":", 1)[-1].strip()
return ""
color_level = _get("颜色差异")
has_border = "" in _get("多余边框")
border_pos = _get("边框位置")
strength_map = {"明显": 0.80, "轻微": 0.55, "": 0.0}
color_strength = strength_map.get(color_level, 0.75)
need_color = color_strength > 0
reason = f"颜色差异={color_level or '?'}, 边框={'有('+border_pos+')' if has_border else ''}"
print(f" [AI决策] {reason} → 颜色匹配={'' if need_color else ''}(强度{color_strength:.0%}), 裁边={'' if has_border else ''}")
return {
"need_color_match": need_color,
"color_strength": color_strength,
"need_trim": has_border,
"reason": reason,
}
except Exception as e:
print(f" [AI决策] 调用失败({e}),默认执行颜色匹配+裁边")
return {"need_color_match": True, "color_strength": 0.75,
"need_trim": True, "reason": f"AI决策失败: {e}"}
def _points_are_unique(pts: np.ndarray, min_dist: float = 20.0) -> bool:
"""检查4个角点两两之间距离都大于 min_dist防止重复点导致退化变换"""
for i in range(len(pts)):
for j in range(i + 1, len(pts)):
if np.linalg.norm(pts[i] - pts[j]) < min_dist:
return False
return True
def find_quad(image: np.ndarray):
"""
在白背景图上检测主体四边形角点。
策略(按优先级):
1. 二值化 + approxPolyDPepsilon 从小到大尝试)
2. 凸包取极值四点(最左/最右/最上/最下)
3. minAreaRect 四角
"""
h, w = image.shape[:2]
img_area = h * w
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# ── 获取主体轮廓 ──────────────────────────────────────────
_, thresh = cv2.threshold(gray, 245, 255, cv2.THRESH_BINARY_INV)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 20))
closed = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)
cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not cnts:
edges = cv2.Canny(gray, 30, 100)
k2 = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 10))
closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, k2)
cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not cnts:
print(" [CV] 无法检测轮廓")
return None
c = max(cnts, key=cv2.contourArea)
area = cv2.contourArea(c)
print(f" [CV] 主体轮廓面积: {area:.0f} / {img_area} ({area/img_area*100:.1f}%)")
if area < img_area * 0.05:
print(" [CV] 面积太小,背景可能去除不完全")
return None
peri = cv2.arcLength(c, True)
# ── 策略1approxPolyDPepsilon 逐步放大直到得到4个唯一角点 ──
for eps_ratio in [0.02, 0.03, 0.04, 0.05, 0.06]:
approx = cv2.approxPolyDP(c, eps_ratio * peri, True)
pts = approx.reshape(-1, 2).astype("float32")
if len(pts) == 4 and _points_are_unique(pts):
print(f" [CV] approxPolyDP 成功 (eps={eps_ratio}), 4个唯一角点")
return pts
print(f" [CV] approxPolyDP eps={eps_ratio}: {len(pts)} 顶点,唯一={_points_are_unique(pts) if len(pts)==4 else 'N/A'}")
# ── 策略2凸包极值四点最左/最上/最右/最下)─────────────
hull = cv2.convexHull(c).reshape(-1, 2).astype("float32")
if len(hull) >= 4:
# 取4个极值方向的点
left = hull[np.argmin(hull[:, 0])] # 最左
right = hull[np.argmax(hull[:, 0])] # 最右
top = hull[np.argmin(hull[:, 1])] # 最上
bottom = hull[np.argmax(hull[:, 1])] # 最下
pts = np.array([left, top, right, bottom], dtype="float32")
if _points_are_unique(pts):
print(f" [CV] 使用凸包极值四点: L={left.astype(int)} T={top.astype(int)} R={right.astype(int)} B={bottom.astype(int)}")
return pts
# ── 策略3minAreaRect 四角(兜底)─────────────────────────
print(f" [CV] 兜底:使用 minAreaRect")
rect = cv2.minAreaRect(c)
box = cv2.boxPoints(rect).astype("float32")
return box
def save_debug_img(image: np.ndarray, pts, path: str):
"""保存带角点标注的调试图"""
dbg = image.copy()
if pts is not None:
rect = order_points(pts)
labels = ["TL", "TR", "BR", "BL"]
colors = [(0,0,255), (0,255,0), (255,0,0), (0,165,255)]
for i, (px, py) in enumerate(rect):
cv2.circle(dbg, (int(px), int(py)), 12, colors[i], -1)
cv2.putText(dbg, labels[i], (int(px)+15, int(py)),
cv2.FONT_HERSHEY_SIMPLEX, 1.2, colors[i], 3)
box = rect.reshape((-1,1,2)).astype(np.int32)
cv2.polylines(dbg, [box], True, (0,0,255), 3)
cv2.imwrite(path, dbg, [cv2.IMWRITE_JPEG_QUALITY, 90])
print(f" [Debug] 调试图: {path}")
# ═══════════════════════════════════════════════════════════════
# 主流程
# ═══════════════════════════════════════════════════════════════
async def process(src: str, debug: bool = False,
skip_step1: bool = False, skip_step3: bool = False) -> str | None:
uid = uuid.uuid4().hex
tmp = [] # 临时文件列表,最后统一清理
# ── 下载URL 情况)──────────────────────────────────────
if src.startswith("http"):
import aiohttp
dl = os.path.join(tempfile.gettempdir(), f"pfix_dl_{uid}.jpg")
tmp.append(dl)
print("[下载] 原图中...")
async with aiohttp.ClientSession(headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Referer": "https://www.taobao.com/",
}) as sess:
async with sess.get(src, timeout=aiohttp.ClientTimeout(total=30)) as r:
if r.status != 200:
print(f"[下载] 失败: HTTP {r.status}")
return None
with open(dl, "wb") as f:
f.write(await r.read())
local_src = dl
else:
local_src = src
current = local_src # 当前处理中的文件
orig_img = cv2.imread(local_src) # 保留原图用于颜色匹配
# 记录原图宽高比,用于检测 Gemini 旋转问题
orig_ratio = (orig_img.shape[1] / orig_img.shape[0]) if orig_img is not None else 1.0
try:
# ── Step 1: Gemini 去背景 → 白背景 ──────────────────
if not skip_step1:
print("\n" + ""*50)
print("Step 1 / 3 | Gemini 去背景 → 白色背景")
print(""*50)
s1_out = os.path.join(tempfile.gettempdir(), f"pfix_s1_{uid}.jpg")
tmp.append(s1_out)
ok = await _gemini_call(current, s1_out, PROMPT_WHITE_BG,
aspect_ratio="auto", label="去背景")
if ok:
# 检查白色覆盖率,判断背景去除是否充分
s1_img = cv2.imread(s1_out)
white_pct = _measure_white_coverage(s1_img) if s1_img is not None else 0.0
print(f" [去背景] 白色覆盖率: {white_pct:.1%}", end="")
if white_pct < 0.20:
# 背景去除太差,用强化提示词重试
print(" → 太低,强化提示词重试...")
s1_retry = os.path.join(tempfile.gettempdir(), f"pfix_s1r_{uid}.jpg")
tmp.append(s1_retry)
ok2 = await _gemini_call(current, s1_retry, PROMPT_WHITE_BG_STRONG,
aspect_ratio="auto", label="去背景(强化)")
if ok2:
r_img = cv2.imread(s1_retry)
retry_pct = _measure_white_coverage(r_img) if r_img is not None else 0.0
print(f" [去背景] 重试白色覆盖率: {retry_pct:.1%}", end="")
if retry_pct >= white_pct:
print(" → 效果更好,采用重试结果")
current = s1_retry
else:
print(" → 效果未提升,保留首次结果")
current = s1_out
else:
print(" [去背景] 重试失败,保留首次结果")
current = s1_out
else:
print(" → 合格")
current = s1_out
else:
print(" Step1 失败,用原图继续")
else:
print("\n[跳过 Step1] 直接用原图")
# ── Step 2: OpenCV 在白背景图上检测+透视矫正 ─────────
print("\n" + ""*50)
print("Step 2 / 3 | OpenCV 轮廓检测 + 透视矫正")
print(""*50)
img = cv2.imread(current)
if img is None:
print(f" 无法读取: {current}")
return None
h, w = img.shape[:2]
print(f" 输入尺寸: {w}x{h}")
pts = find_quad(img)
if debug:
dbg_path = os.path.join(_OUTPUT_DIR, f"debug_{uid}.jpg")
save_debug_img(img, pts, dbg_path)
if pts is not None:
warped = four_point_transform(img, pts)
# ── 方向校正Gemini 可能把图旋转 90°需要纠正 ──
wh2, ww2 = warped.shape[:2]
warped_ratio = ww2 / wh2 # 宽/高
# 若原图横竖方向与矫正结果相反(比例差异超过 1.5 倍),旋转 90°
if orig_ratio > 1.0 and warped_ratio < 1.0 / 1.5:
# 原图横,结果竖 → 顺时针转 90°
warped = cv2.rotate(warped, cv2.ROTATE_90_CLOCKWISE)
print(f" [方向校正] 原图横({orig_ratio:.2f}) vs 矫正竖({warped_ratio:.2f}) → 旋转90°")
elif orig_ratio < 1.0 and warped_ratio > 1.5:
# 原图竖,结果横 → 逆时针转 90°
warped = cv2.rotate(warped, cv2.ROTATE_90_COUNTERCLOCKWISE)
print(f" [方向校正] 原图竖({orig_ratio:.2f}) vs 矫正横({warped_ratio:.2f}) → 旋转-90°")
else:
print(f" [方向校正] 方向一致,无需旋转 (原图比例={orig_ratio:.2f}, 矫正比例={warped_ratio:.2f})")
s2_out = os.path.join(tempfile.gettempdir(), f"pfix_s2_{uid}.jpg")
tmp.append(s2_out)
cv2.imwrite(s2_out, warped, [cv2.IMWRITE_JPEG_QUALITY, 95])
current = s2_out
wh2, ww2 = warped.shape[:2]
print(f" 透视矫正完成 → {ww2}x{wh2}")
else:
print(" 角点检测失败,跳过透视矫正,继续用白背景图")
# ── Step 3: Qwen 高清增强 ─────────────────────────────
if not skip_step3:
print("\n" + ""*50)
print("Step 3 / 5 | Qwen 高清增强RunningHub")
print(""*50)
final_out = os.path.join(_OUTPUT_DIR, f"pfix_final_{uid}.jpg")
from services.service_qwen import 清晰化_api
ok = await 清晰化_api(img_path=current, save_path=final_out)
if ok:
print(f" [高清增强] Qwen 成功")
else:
# Qwen 失败,用 Gemini 简化提示词兜底
print(" Qwen 失败Gemini 兜底重试...")
ok = await _gemini_call(current, final_out, PROMPT_ENHANCE_SIMPLE,
aspect_ratio="auto", label="高清增强(Gemini兜底)")
if not ok:
print(" Step3 全部失败,直接保存矫正结果")
import shutil
shutil.copy2(current, final_out)
else:
final_out = os.path.join(_OUTPUT_DIR, f"pfix_final_{uid}.jpg")
import shutil
shutil.copy2(current, final_out)
print("\n[跳过 Step3] 直接保存矫正结果")
# ── Step 4: AI 决策 + 后处理(颜色匹配 & 白边裁切)────
print("\n" + ""*50)
print("Step 4 / 4 | AI 决策后处理(颜色匹配 / 白边裁切)")
print(""*50)
final_img = cv2.imread(final_out)
if final_img is not None and orig_img is not None:
decision = await ai_decide_postprocess(orig_img, final_img)
# Tool 1: 颜色匹配
if decision["need_color_match"]:
final_img = await tool_color_match(orig_img, final_img,
strength=decision["color_strength"])
cv2.imwrite(final_out, final_img, [cv2.IMWRITE_JPEG_QUALITY, 95])
else:
print(" [颜色匹配] AI 判断无需调色,跳过")
# Tool 2: 白边裁切
if decision["need_trim"]:
trimmed, did_trim, _ = tool_trim_white_border(final_img)
if did_trim:
cv2.imwrite(final_out, trimmed, [cv2.IMWRITE_JPEG_QUALITY, 95])
else:
print(" [裁边] AI 判断无白边,跳过")
else:
print(" [Step4] 图片读取失败,跳过后处理")
size_kb = os.path.getsize(final_out) / 1024
print(f"\n{'='*50}")
print(f" 完成!输出文件: {final_out}")
print(f" 文件大小: {size_kb:.0f} KB")
print(f"{'='*50}")
return final_out
finally:
for f in tmp:
if os.path.exists(f):
os.remove(f)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: python perspective_fix.py <图片路径或URL> [--debug] [--skip-step1] [--skip-step3]")
sys.exit(1)
src_arg = sys.argv[1]
debug_arg = "--debug" in sys.argv
skip1_arg = "--skip-step1" in sys.argv
skip3_arg = "--skip-step3" in sys.argv
asyncio.run(process(src_arg, debug=debug_arg, skip_step1=skip1_arg, skip_step3=skip3_arg))

View File

@@ -1,201 +0,0 @@
from __future__ import annotations
import logging
import random
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from core.rules import Rule, RuleContext, RuleEngine, RuleResult
from services.risk_service import RiskService
if TYPE_CHECKING:
from core.pydantic_ai_agent import (
AgentResponse,
ConversationState,
CustomerMessage,
CustomerServiceAgent,
)
class AgentPreRuleService:
"""Pre-processing rule chain for short replies, cooldown, and text risk."""
def __init__(self, agent: "CustomerServiceAgent", risk_service: RiskService):
self.agent = agent
self.risk_service = risk_service
self.engine = self._build_engine()
async def run(
self,
*,
message: "CustomerMessage",
state: "ConversationState",
trace_id: str,
) -> Optional["AgentResponse"]:
ctx = RuleContext(data={"message": message, "state": state, "trace_id": trace_id})
result = await self.engine.run(ctx)
if not result.stop:
return None
response = result.payload.get("response")
return response
def _build_engine(self) -> RuleEngine:
return RuleEngine(
rules=[
Rule(
name="meaningless_short_text",
priority=10,
predicate=self._rule_pred_meaningless_short_text,
action=self._rule_act_meaningless_short_text,
),
Rule(
name="cooldown_silent",
priority=20,
predicate=self._rule_pred_cooldown_silent,
action=self._rule_act_cooldown_silent,
),
Rule(
name="manual_risk_block",
priority=30,
predicate=self._rule_pred_manual_risk_block,
action=self._rule_act_manual_risk_block,
),
Rule(
name="text_risk_block",
priority=40,
predicate=self._rule_pred_text_risk_block,
action=self._rule_act_text_risk_block,
),
]
)
async def _rule_pred_meaningless_short_text(self, ctx: RuleContext) -> bool:
message = ctx.get("message")
state = ctx.get("state")
return self.agent._should_handle_as_meaningless_short_text(state, message.msg)
async def _rule_act_meaningless_short_text(self, ctx: RuleContext) -> RuleResult:
from core.pydantic_ai_agent import AgentResponse
message = ctx.get("message")
state = ctx.get("state")
trace_id = ctx.get("trace_id", "")
ping = random.choice(("嗯咯", "嗯啦", "", ""))
state.last_reply_at = datetime.now()
self.agent._activity_log(
"agent_ping_reply",
trace_id=trace_id,
customer_id=message.from_id,
msg=message.msg,
reply=ping,
)
return RuleResult(
matched=True,
stop=True,
action="agent_ping_reply",
payload={"response": AgentResponse(reply=ping, should_reply=True, need_transfer=False)},
)
async def _rule_pred_cooldown_silent(self, ctx: RuleContext) -> bool:
message = ctx.get("message")
state = ctx.get("state")
return self.agent._in_cooldown(state, message.msg)
async def _rule_act_cooldown_silent(self, ctx: RuleContext) -> RuleResult:
from core.pydantic_ai_agent import AgentResponse
message = ctx.get("message")
state = ctx.get("state")
trace_id = ctx.get("trace_id", "")
elapsed = int((datetime.now() - state.last_reply_at).total_seconds()) if state.last_reply_at else 0
logger.info("[Agent] 冷却期静默(距上次回复 %ss%r", elapsed, message.msg)
self.agent._activity_log(
"agent_cooldown_silent",
trace_id=trace_id,
customer_id=message.from_id,
elapsed_s=elapsed,
)
return RuleResult(
matched=True,
stop=True,
action="agent_cooldown_silent",
payload={"response": AgentResponse(reply="", should_reply=False, need_transfer=False)},
)
async def _rule_pred_manual_risk_block(self, ctx: RuleContext) -> bool:
message = ctx.get("message")
decision = self.risk_service.check_manual_block(message.from_id)
ctx.set("manual_risk_decision", decision)
return decision.blocked
async def _rule_act_manual_risk_block(self, ctx: RuleContext) -> RuleResult:
from core.pydantic_ai_agent import AgentResponse, TRANSFER_MESSAGE
message = ctx.get("message")
trace_id = ctx.get("trace_id", "")
decision = ctx.get("manual_risk_decision")
self.agent._activity_log(
"agent_manual_risk_reject",
trace_id=trace_id,
customer_id=message.from_id,
risk=(decision.profile if decision else {}),
)
return RuleResult(
matched=True,
stop=True,
action="agent_manual_risk_reject",
payload={
"response": AgentResponse(
reply="这边无法继续为你处理该类需求,给你转人工专员对接。",
should_reply=True,
need_transfer=True,
transfer_msg=TRANSFER_MESSAGE,
)
},
)
async def _rule_pred_text_risk_block(self, ctx: RuleContext) -> bool:
message = ctx.get("message")
decision = await self.risk_service.check_text_block(
message.msg,
political_detector=self.agent._is_political_inquiry,
map_detector=self.agent._is_map_inquiry,
)
ctx.set("text_risk_decision", decision)
return decision.blocked
async def _rule_act_text_risk_block(self, ctx: RuleContext) -> RuleResult:
from core.pydantic_ai_agent import AgentResponse
message = ctx.get("message")
state = ctx.get("state")
trace_id = ctx.get("trace_id", "")
decision = ctx.get("text_risk_decision")
state.pending_image_urls.clear()
state.pending_requirements.clear()
self.agent._sync_pending_quote_state(message.from_id, state)
reject_text = self.risk_service.build_reject_text(decision.category if decision else "other")
reply = await self.agent._rewrite_reply_with_ai(
message=message,
state=state,
reply=reject_text,
scene="risk_reject",
)
state.last_reply_at = datetime.now()
logger.info("[REPLY->CUSTOMER] %s", reply)
self.agent._activity_log(
"agent_risk_reject",
trace_id=trace_id,
customer_id=message.from_id,
risk_category=(decision.category if decision else "other"),
risk_source=(decision.source if decision else "unknown"),
reply=reply,
)
return RuleResult(
matched=True,
stop=True,
action="agent_risk_reject",
payload={"response": AgentResponse(reply=reply, should_reply=True, need_transfer=False)},
)
logger = logging.getLogger("cs_agent")

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,300 +0,0 @@
# -*- coding: utf-8 -*-
"""
每日聊天汇总定时任务
- 每天 23:50 自动统计当日各店铺数据
- 用 AI 生成自然语言摘要
- 发送到企业微信 Webhook + QQ 邮件
"""
import asyncio
import os
import json
import logging
from datetime import datetime, date, timedelta
from typing import Optional
import httpx
from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger("cs_agent")
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:
logger.info("[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:
logger.info("[DailySummary] 企业微信推送成功(第%s段)", i + 1)
else:
logger.warning("[DailySummary] 企业微信推送失败: %s", data)
except Exception as e:
logger.exception("[DailySummary] 企业微信推送异常: %s", 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())
logger.info("[DailySummary] 日报邮件已发送至 %s", SUMMARY_EMAIL)
except Exception as e:
logger.exception("[DailySummary] 日报邮件发送失败: %s", e)
# ──────────────────────────────────────────
# 企业微信 Markdown 排版
# ──────────────────────────────────────────
def _build_wechat_markdown(title: str, ai_text: str, raw_text: str, target_date: str = "") -> str:
"""
构建符合企业微信规范的 markdown 内容。
支持:**bold**、<font color="...">、> 引用、``` 代码块、- 列表
不支持:<details>、<summary>、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"- <font color=\"info\">{label}</font> "
f"接待 **{s['unique_customers']}** 人 · "
f"消息 {s['total_msgs']} 条(收{s['recv']}/发{s['sent']}"
f" {first}~{last}"
)
lines.append("")
lines.append(f"<font color=\"comment\">发送时间:{datetime.now().strftime('%H:%M:%S')}</font>")
return "\n".join(lines)
# ──────────────────────────────────────────
# 主入口:生成并推送日报
# ──────────────────────────────────────────
async def send_daily_summary(target_date: str = ""):
"""生成并推送当日汇总"""
if not target_date:
target_date = datetime.now().strftime("%Y-%m-%d")
logger.info("[DailySummary] 开始生成 %s 日报...", target_date)
raw_text = _build_stats_text(target_date)
ai_text = await _ai_summary(raw_text)
title = f"📊 {target_date} 客服日报"
# ── 企业微信 markdown不支持 <details>,用标准语法)──
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)
logger.info("[DailySummary] 日报推送完成")
return ai_text
# ──────────────────────────────────────────
# 定时调度(由 websocket_client 启动)
# ──────────────────────────────────────────
async def scheduler():
"""每天 SEND_HOUR:SEND_MINUTE 触发日报"""
logger.info("[DailySummary] 定时日报已启动,发送时间 %02d:%02d", SEND_HOUR, SEND_MINUTE)
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:
logger.exception("[DailySummary] 日报生成出错: %s", 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))
logger.info("\n=== AI 摘要 ===")
logger.info(result)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,159 +0,0 @@
# -*- coding: utf-8 -*-
"""
语义匹配 - 用 embedding 做意图/情绪识别
配置 EMBEDDING_MODEL 后启用,否则回退到关键词
"""
import os
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
# 意图模板(用于 embedding 相似度匹配)
INTENT_TEMPLATES = {
"询价": "我想问一下价格多少钱",
"发图": "我发图给你看看",
"砍价": "能不能便宜点太贵了",
"批量": "我要做很多张图批量",
"加急": "能不能快点很急",
"售后": "已经付款了什么时候好",
"修改": "不满意要改一下",
"转接": "我要退款投诉",
"打招呼": "你好在吗有人吗",
}
EMOTION_TEMPLATES = {
"平静": "好的谢谢",
"着急": "快点啊很急",
"不满": "怎么这么慢不满意",
"砍价": "太贵了便宜点",
}
_template_embeddings: dict = {}
@dataclass
class IntentDecision:
intent: str = ""
source: str = "none" # embedding / keyword / none
score: float = 0.0
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。"""
decision = detect_intent_embedding_decision(msg)
return decision.intent or None
def detect_intent_embedding_decision(msg: str) -> IntentDecision:
"""返回 embedding 意图决策(含分值)。"""
msg_emb = _get_embedding(msg)
if not msg_emb:
return IntentDecision()
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
if best_score > 0.6:
return IntentDecision(intent=best_intent, source="embedding", score=float(best_score))
return IntentDecision()
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 ""
def detect_intent(msg: str) -> IntentDecision:
"""
AI 意图判定 + 规则兜底:
1) 有 embedding 配置时先走 embedding。
2) 失败/低置信时回退关键词规则。
"""
text = (msg or "").strip()
if not text:
return IntentDecision()
try:
emb_decision = detect_intent_embedding_decision(text)
except Exception:
emb_decision = IntentDecision()
if emb_decision.intent:
return emb_decision
kw_intent = detect_intent_keywords(text)
if kw_intent:
return IntentDecision(intent=kw_intent, source="keyword", score=0.0)
return IntentDecision()

View File

@@ -1,331 +0,0 @@
"""
邮件接收模块 - 监控收件箱,客户发图询价/下单自动处理
流程:
客户发邮件(含图片附件)→ 自动分析图片复杂度 → 回复报价
客户回复"拍了"/"确认" → 创建处理任务 → Gemini 作图 → 发结果
"""
import asyncio
import imaplib
import email
import email.header
import os
import tempfile
import logging
from datetime import datetime
from email.header import decode_header
from typing import Optional
logger = logging.getLogger(__name__)
# 支持的图片格式
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
def _decode_str(value: str) -> str:
"""解码邮件头部字段(处理中文编码)"""
if not value:
return ""
parts = decode_header(value)
result = []
for part, charset in parts:
if isinstance(part, bytes):
try:
result.append(part.decode(charset or "utf-8", errors="replace"))
except Exception:
result.append(part.decode("utf-8", errors="replace"))
else:
result.append(part)
return "".join(result)
class EmailReceiver:
"""IMAP 邮件接收器,轮询新邮件并自动处理图片询价"""
def __init__(
self,
imap_host: str = "imap.qq.com",
imap_port: int = 993,
username: str = "",
password: str = "",
poll_interval: int = 30,
):
self.imap_host = imap_host
self.imap_port = imap_port
self.username = username
self.password = password
self.poll_interval = poll_interval
self._running = False
self._send_reply = None # 注入的回复函数
def register_reply_callback(self, callback):
"""注入回复函数(直接用 email_sender 回复)"""
self._send_reply = callback
# ========== 主循环 ==========
async def start(self):
"""启动轮询(作为后台任务运行)"""
self._running = True
logger.info(f"[EmailReceiver] 启动,每 {self.poll_interval}s 检查一次收件箱")
while self._running:
try:
await self._check_inbox()
except Exception as e:
logger.error(f"[EmailReceiver] 轮询异常: {e}")
await asyncio.sleep(self.poll_interval)
def stop(self):
self._running = False
# ========== 收件箱检查 ==========
async def _check_inbox(self):
"""连接 IMAP检查未读邮件"""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._check_inbox_sync)
def _check_inbox_sync(self):
"""同步版收件箱检查(在线程池里跑,避免阻塞事件循环)"""
try:
conn = imaplib.IMAP4_SSL(self.imap_host, self.imap_port)
conn.login(self.username, self.password)
conn.select("INBOX")
# 搜索未读邮件
_, msg_ids = conn.search(None, "UNSEEN")
ids = msg_ids[0].split()
if not ids:
conn.logout()
return
logger.info(f"[EmailReceiver] 发现 {len(ids)} 封未读邮件")
for msg_id in ids:
try:
_, data = conn.fetch(msg_id, "(RFC822)")
raw = data[0][1]
msg = email.message_from_bytes(raw)
self._process_email_sync(msg)
# 标记为已读
conn.store(msg_id, "+FLAGS", "\\Seen")
except Exception as e:
logger.error(f"[EmailReceiver] 处理邮件 {msg_id} 失败: {e}")
conn.logout()
except Exception as e:
logger.error(f"[EmailReceiver] IMAP 连接失败: {e}")
# ========== 邮件处理 ==========
def _process_email_sync(self, msg):
"""处理单封邮件:提取发件人、附件图片,触发分析和回复"""
sender = _decode_str(msg.get("From", ""))
subject = _decode_str(msg.get("Subject", "(无主题)"))
# 提取发件人邮箱地址
sender_email = self._extract_email_addr(sender)
if not sender_email:
logger.warning(f"[EmailReceiver] 无法解析发件人地址: {sender}")
return
logger.info(f"[EmailReceiver] 处理邮件 | 来自: {sender_email} | 主题: {subject}")
# 提取正文
body_text = self._extract_body(msg)
# 提取图片附件
image_paths = self._extract_images(msg)
# 异步触发处理(把同步上下文切回事件循环)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
self._handle_email(sender_email, subject, body_text, image_paths)
)
finally:
loop.close()
# 清理临时图片
for p in image_paths:
try:
os.remove(p)
except Exception:
pass
async def _handle_email(
self,
sender_email: str,
subject: str,
body: str,
image_paths: list,
):
"""根据邮件内容决定如何处理"""
body_lower = (body or "").lower()
# ① 有图片附件 → 分析图片,回复报价
if image_paths:
await self._handle_image_inquiry(sender_email, subject, image_paths)
return
# ② 纯文字邮件 → 引导发图
await self._reply_email(
to=sender_email,
subject=f"Re: {subject}",
body=self._html(
"您好!收到您的邮件。<br><br>"
"请将您需要处理的图片作为<b>附件</b>发送过来,我们会尽快为您报价。<br><br>"
"支持格式JPG、PNG、WEBP 等常见图片格式。"
),
)
async def _handle_image_inquiry(
self, sender_email: str, subject: str, image_paths: list
):
"""分析图片,回复报价"""
from image.image_analyzer import image_analyzer
quotes = []
for idx, img_path in enumerate(image_paths, 1):
try:
# image_analyzer 支持本地路径
result = await image_analyzer.analyze(img_path)
price = result.get("price_suggest", 30)
reason = result.get("reason", "")
label = {
"simple": "画面简洁",
"normal": "一般复杂度",
"complex": "细节较多",
"hard": "非常复杂",
}.get(result.get("complexity", ""), "")
quotes.append(
f"图片{idx}{label},建议报价 <b>{price} 元</b>"
+ (f"{reason}" if reason else "")
)
except Exception as e:
logger.error(f"[EmailReceiver] 图片分析失败: {e}")
quotes.append(f"图片{idx}:分析失败,建议报价 30 元")
# 多图打包优惠
n = len(image_paths)
if n >= 5:
tip = f"<br><br>📦 您共发来 <b>{n} 张</b>图片,支持打包优惠,欢迎咨询。"
elif n >= 3:
tip = f"<br><br>📦 您共发来 <b>{n} 张</b>图片3张以上可享9折优惠。"
else:
tip = ""
quote_html = "<br>".join(quotes)
body = self._html(
f"您好!感谢您发来图片,已为您完成分析:<br><br>"
f"{quote_html}{tip}<br><br>"
f"如需处理,请直接在淘宝店铺下单,付款后我们会尽快为您完成制作并发回。<br>"
f"如有疑问欢迎回复此邮件。"
)
await self._reply_email(
to=sender_email,
subject=f"Re: {subject}" if subject else "您的图片报价",
body=body,
)
logger.info(f"[EmailReceiver] 已向 {sender_email} 回复报价")
# ========== 工具方法 ==========
async def _reply_email(self, to: str, subject: str, body: str):
"""发送回复邮件"""
try:
from mail.email_sender import email_sender
result = email_sender.send(to_email=to, subject=subject, body=body)
if not result.get("success"):
logger.error(f"[EmailReceiver] 回复发送失败: {result.get('message')}")
except Exception as e:
logger.error(f"[EmailReceiver] 回复异常: {e}")
def _extract_email_addr(self, from_field: str) -> Optional[str]:
"""从 From 字段提取邮箱地址"""
import re
m = re.search(r'[\w\.\+\-]+@[\w\.\-]+\.\w+', from_field)
return m.group(0) if m else None
def _extract_body(self, msg) -> str:
"""提取邮件纯文本正文"""
body = ""
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
charset = part.get_content_charset() or "utf-8"
try:
body += part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
else:
charset = msg.get_content_charset() or "utf-8"
try:
body = msg.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
return body.strip()
def _extract_images(self, msg) -> list:
"""提取邮件中的图片附件,保存到临时文件,返回路径列表"""
paths = []
for part in msg.walk():
content_disposition = part.get("Content-Disposition", "")
content_type = part.get_content_type()
is_attachment = "attachment" in content_disposition
is_image_type = content_type.startswith("image/")
filename = part.get_filename()
if filename:
filename = _decode_str(filename)
# 判断是否是图片
if not (is_image_type or (filename and any(
filename.lower().endswith(ext) for ext in IMAGE_EXTS
))):
continue
try:
data = part.get_payload(decode=True)
if not data:
continue
suffix = ".jpg"
if filename:
ext = os.path.splitext(filename)[1].lower()
if ext in IMAGE_EXTS:
suffix = ext
fd, tmp_path = tempfile.mkstemp(suffix=suffix, prefix="email_img_")
with os.fdopen(fd, "wb") as f:
f.write(data)
paths.append(tmp_path)
logger.info(f"[EmailReceiver] 提取图片附件: {filename}{tmp_path}")
except Exception as e:
logger.error(f"[EmailReceiver] 提取附件失败: {e}")
return paths
@staticmethod
def _html(content: str) -> str:
return f"""
<html><body style="font-family:Arial,sans-serif;font-size:14px;color:#333">
{content}
<br><br>
<hr style="border:none;border-top:1px solid #eee">
<p style="color:#999;font-size:12px">修图客服 · 自动回复</p>
</body></html>
"""
# ========== 全局实例(从 .env 读取配置)==========
from dotenv import load_dotenv
load_dotenv()
email_receiver = EmailReceiver(
imap_host="imap.qq.com",
imap_port=993,
username=os.getenv("SMTP_USER", ""),
password=os.getenv("SMTP_PASSWORD", ""),
poll_interval=int(os.getenv("EMAIL_POLL_INTERVAL", "30")),
)

View File

@@ -1,112 +0,0 @@
"""邮件发送模块"""
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from email.header import Header
from typing import Optional, List
from dotenv import load_dotenv
load_dotenv()
class EmailSender:
"""邮件发送"""
def __init__(self):
self.smtp_host = os.getenv("SMTP_HOST", "")
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.smtp_user = os.getenv("SMTP_USER", "")
self.smtp_password = os.getenv("SMTP_PASSWORD", "")
self.sender_name = os.getenv("SENDER_NAME", "修图客服")
def send(
self,
to_email: str,
subject: str,
body: str,
images: Optional[List[str]] = None
) -> dict:
"""
发送邮件
Args:
to_email: 收件人邮箱
subject: 邮件主题
body: 邮件正文
images: 图片路径列表
Returns:
{"success": bool, "message": str}
"""
if not self.smtp_host or not self.smtp_user:
return {"success": False, "message": "未配置邮件SMTP"}
try:
# 创建邮件
msg = MIMEMultipart('related')
msg['From'] = f"{Header(self.sender_name, 'utf-8').encode()} <{self.smtp_user}>"
msg['To'] = to_email
msg['Subject'] = subject
# 添加正文
msg.attach(MIMEText(body, 'html', 'utf-8'))
# 添加图片
if images:
for idx, img_path in enumerate(images):
if os.path.exists(img_path):
with open(img_path, 'rb') as f:
img = MIMEImage(f.read())
img.add_header('Content-ID', f'<image{idx}>')
msg.attach(img)
# 发送邮件(失败时重试 1 次)
import time
last_err = None
for attempt in range(2):
try:
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
server.starttls()
server.login(self.smtp_user, self.smtp_password)
server.sendmail(self.smtp_user, to_email, msg.as_string())
server.quit()
return {"success": True, "message": "发送成功"}
except Exception as e:
last_err = e
if attempt == 0:
time.sleep(2)
return {"success": False, "message": f"发送失败: {str(last_err)}"}
except Exception as e:
return {"success": False, "message": f"发送失败: {str(e)}"}
def send_completed_work(
self,
to_email: str,
customer_name: str,
image_description: str,
result_images: List[str]
) -> dict:
"""发送完成的作品"""
subject = f"您的修图作品已完成 - {image_description}"
body = f"""
<html>
<body>
<h2>您好 {customer_name},您的修图作品已完成!</h2>
<p>感谢您选择我们的服务。以下是您处理后的图片:</p>
<p><b>处理内容:</b> {image_description}</p>
<br>
<p>如有任何问题,请随时联系我们。</p>
<br>
<p>祝您生活愉快!</p>
</body>
</html>
"""
return self.send(to_email, subject, body, result_images)
# 全局实例
email_sender = EmailSender()

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 855 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 810 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

View File

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

View File

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

View File

@@ -1,362 +0,0 @@
"""
聊天记录查看器
用法:
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:
rows = db.get_latest_messages(20)
new_rows = [r for r in rows if r["id"] not in seen_ids]
if new_rows:
new_rows.reverse()
for r in new_rows:
seen_ids.add(r["id"])
cid = r["customer_id"]
name = r.get("customer_name") or ""
label = f"{CYAN}{cid}{RESET}" + (f" {DIM}({name}){RESET}" if name else "")
print(f"\n{label}")
print_bubble(r["direction"], r["message"], r["timestamp"])
else:
print(f"\r {DIM}等待新消息... {datetime.now().strftime('%H:%M:%S')}{RESET}", end="", flush=True)
time.sleep(refresh)
except KeyboardInterrupt:
print(f"\n{DIM}已退出监听。{RESET}")
def _extract_urls(msg: str) -> list:
if not msg:
return []
parts = [p.strip() for p in msg.split("#*#") if p.strip()]
urls = []
for p in parts:
if p.startswith("http://") or p.startswith("https://"):
urls.append(p)
if not urls and ("http://" in msg or "https://" in msg):
import re as _re
tokens = _re.findall(r'(https?://\S+)', msg)
for t in tokens:
tl = t.lower()
if any(ext in tl for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]):
urls.append(t)
return urls
def _msg_refers_images(msg: str) -> bool:
if not msg:
return False
refs = ("图一", "图二", "第一张", "第二张", "这张", "那张", "上面那张", "下面那张", "刚才那张", "上一张", "下一张")
return any(r in msg for r in refs)
def _parse_ts(ts: str):
try:
from datetime import datetime as _dt
return _dt.fromisoformat(ts.replace("Z",""))
except Exception:
return None
def analyze_conversation(messages: list) -> list:
issues = []
n = len(messages)
for i, m in enumerate(messages):
msg = m.get("message") or ""
dir = m.get("direction")
ts = _parse_ts(m.get("timestamp",""))
# 图片后未及时回复
if dir == "in" and _extract_urls(msg):
replied = False
delay_ok = True
for j in range(i+1, min(i+6, n)):
mj = messages[j]
if mj.get("direction") == "out":
replied = True
tsj = _parse_ts(mj.get("timestamp",""))
if ts and tsj and (tsj - ts).total_seconds() > 180:
delay_ok = False
break
if not replied:
issues.append("图片消息后未回复")
elif not delay_ok:
issues.append("图片消息后回复延迟超过3分钟")
# 引用图片但找不到历史图片
if dir == "in" and _msg_refers_images(msg):
has_prev_img = False
for k in range(max(0, i-10), i):
if messages[k].get("direction") == "in" and _extract_urls(messages[k].get("message","")):
has_prev_img = True
break
if not has_prev_img:
issues.append("引用图片但历史中未找到对应图片")
# 订单后未确认/引导
if dir == "in" and ("买家已付款" in msg or "[系统订单信息]" in msg):
confirmed = False
for j in range(i+1, min(i+6, n)):
if messages[j].get("direction") == "out":
confirmed = True
break
if not confirmed:
issues.append("订单消息后未进行确认或引导付款")
# 合成需求未报价格
if dir == "in" and any(k in msg for k in ("抓到", "放到", "合成", "融合", "嵌到", "替换", "P到", "抠出来放到")):
priced = False
for j in range(i+1, min(i+6, n)):
mj = messages[j]
if mj.get("direction") == "out":
rm = mj.get("message","")
if "" in rm:
priced = True
break
if not priced:
issues.append("客户提出合成需求但未给出价格")
# 去重
dedup = []
seen = set()
for it in issues:
if it not in seen:
seen.add(it)
dedup.append(it)
return dedup
def cmd_analyze_all():
customers = db.get_customers(limit=200)
if not customers:
print(f"{YELLOW}暂无聊天记录。{RESET}")
return
header("聊天记录上下文分析")
total_issues = 0
for c in customers:
cid = c["customer_id"]
msgs = db.get_conversation(cid, limit=500)
issues = analyze_conversation(msgs)
if issues:
total_issues += len(issues)
print(f"{CYAN}{cid}{RESET} {c.get('customer_name','')}")
for s in issues:
print(f" - {RED}{s}{RESET}")
print()
if total_issues == 0:
print(f"{GREEN}未发现明显异常。{RESET}")
else:
print(f"{YELLOW}共发现 {total_issues} 项问题(按客户汇总)。{RESET}")
def print_help():
print(f"""
{BOLD}聊天记录查看器{RESET}
{CYAN}python chat_log_viewer.py{RESET} 列出所有客户
{CYAN}python chat_log_viewer.py <客户ID>{RESET} 查看该客户全部对话
{CYAN}python chat_log_viewer.py -t <客户ID>{RESET} 只看今天的对话
{CYAN}python chat_log_viewer.py -s <关键词>{RESET} 全局搜索
{CYAN}python chat_log_viewer.py -l{RESET} 实时监听新消息
{CYAN}python chat_log_viewer.py -a{RESET} 分析上下文,输出异常项
{CYAN}python chat_log_viewer.py -h{RESET} 显示帮助
""")
if __name__ == "__main__":
args = sys.argv[1:]
if not args:
cmd_list_customers()
elif args[0] in ("-h", "--help"):
print_help()
elif args[0] == "-s":
keyword = args[1] if len(args) > 1 else ""
if not keyword:
print(f"{RED}请提供搜索关键词python chat_log_viewer.py -s <关键词>{RESET}")
else:
cmd_search(keyword)
elif args[0] == "-t":
cid = args[1] if len(args) > 1 else ""
if not cid:
print(f"{RED}请提供客户IDpython chat_log_viewer.py -t <客户ID>{RESET}")
else:
cmd_show_conversation(cid, today_only=True)
elif args[0] == "-l":
cmd_live()
elif args[0] == "-a":
cmd_analyze_all()
else:
cmd_show_conversation(args[0])

View File

@@ -1,520 +0,0 @@
# -*- coding: utf-8 -*-
"""
聊天记录 Web UI
运行: python scripts/chat_ui.py
访问: http://localhost:5678
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from flask import Flask, jsonify, render_template_string, request
import asyncio
from core.pydantic_ai_agent import CustomerServiceAgent, AgentDeps
from db import chat_log_db as db
app = Flask(__name__)
pricing_agent = None
try:
pricing_agent = CustomerServiceAgent()
except Exception as e:
print(f"[ChatUI] 初始化报价Agent失败: {e}")
HTML = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>聊天记录</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
/* ── 顶栏 ── */
.topbar {
background: #16213e; border-bottom: 1px solid #0f3460;
padding: 12px 20px; display: flex; align-items: center; gap: 16px; flex-shrink: 0;
}
.topbar h1 { font-size: 16px; color: #4cc9f0; font-weight: 600; letter-spacing: 1px; }
.search-box {
flex: 1; max-width: 320px;
background: #0f3460; border: 1px solid #1a5276;
border-radius: 20px; padding: 6px 14px;
color: #e0e0e0; font-size: 13px; outline: none;
}
.search-box::placeholder { color: #6b7a99; }
.live-badge {
margin-left: auto; font-size: 11px; background: #0d7377;
color: #14ffec; padding: 3px 10px; border-radius: 10px;
}
/* ── 主体 ── */
.main { display: flex; flex: 1; overflow: hidden; }
/* ── 左侧客户列表 ── */
.sidebar {
width: 280px; background: #16213e;
border-right: 1px solid #0f3460;
display: flex; flex-direction: column; flex-shrink: 0;
}
.sidebar-header {
padding: 10px 14px; font-size: 12px; color: #6b7a99;
border-bottom: 1px solid #0f3460; flex-shrink: 0;
display: flex; justify-content: space-between;
}
.customer-list { overflow-y: auto; flex: 1; }
.customer-item {
padding: 12px 14px; cursor: pointer; border-bottom: 1px solid #0f3460;
transition: background .15s; position: relative;
}
.customer-item:hover { background: #1e3a5f; }
.customer-item.active { background: #0f3460; border-left: 3px solid #4cc9f0; }
.customer-item .name { font-size: 13px; font-weight: 500; color: #cce; }
.customer-item .cid { font-size: 11px; color: #6b7a99; margin-top: 2px; }
.customer-item .meta { font-size: 11px; color: #8899aa; margin-top: 4px;
display: flex; justify-content: space-between; }
.badge-plat {
font-size: 10px; padding: 1px 6px; border-radius: 8px;
background: #1a3a5c; color: #4cc9f0;
}
.badge-plat.ali { background: #3d1a00; color: #ff9f43; }
.badge-plat.email { background: #0a2e1a; color: #55efc4; }
/* ── 右侧对话区 ── */
.chat-panel {
flex: 1; display: flex; flex-direction: column; overflow: hidden;
}
.chat-header {
padding: 12px 20px; background: #16213e;
border-bottom: 1px solid #0f3460; flex-shrink: 0;
display: flex; align-items: center; gap: 10px;
}
.chat-header .cname { font-size: 15px; font-weight: 600; color: #e0e0e0; }
.chat-header .cid { font-size: 12px; color: #6b7a99; }
.chat-header .stats { margin-left: auto; font-size: 12px; color: #6b7a99; }
.chat-messages {
flex: 1; overflow-y: auto; padding: 20px;
display: flex; flex-direction: column; gap: 12px;
}
.day-divider {
text-align: center; font-size: 11px; color: #6b7a99;
position: relative; margin: 8px 0;
}
.day-divider::before, .day-divider::after {
content: ""; position: absolute; top: 50%;
width: 38%; height: 1px; background: #0f3460;
}
.day-divider::before { left: 0; }
.day-divider::after { right: 0; }
/* 消息气泡 */
.msg-row { display: flex; align-items: flex-end; gap: 8px; max-width: 72%; }
.msg-row.in { align-self: flex-start; }
.msg-row.out { align-self: flex-end; flex-direction: row-reverse; }
.avatar {
width: 34px; height: 34px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600; flex-shrink: 0;
}
.avatar.buyer { background: #2d4a7a; color: #90caf9; }
.avatar.seller { background: #1a6644; color: #a8e6cf; }
.bubble-wrap { display: flex; flex-direction: column; gap: 3px; }
.msg-row.out .bubble-wrap { align-items: flex-end; }
.bubble {
padding: 9px 13px; border-radius: 16px;
font-size: 13px; line-height: 1.55; word-break: break-word;
max-width: 480px;
}
.bubble.in { background: #1e3a5f; color: #dce8f8; border-bottom-left-radius: 4px; }
.bubble.out { background: #1a6644; color: #d4f5e7; border-bottom-right-radius: 4px; }
.bubble img { max-width: 200px; border-radius: 8px; display: block; margin-top: 4px; }
.msg-time { font-size: 10px; color: #6b7a99; padding: 0 4px; }
/* 空状态 */
.empty-state {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #6b7a99; gap: 10px;
}
.empty-state .icon { font-size: 48px; opacity: .3; }
.empty-state p { font-size: 14px; }
/* 搜索结果覆盖层 */
#search-overlay {
display: none; position: absolute; top: 52px; left: 0; right: 0; bottom: 0;
background: #1a1a2e; z-index: 10; overflow-y: auto; padding: 16px 20px;
}
.search-hit {
padding: 10px 14px; margin-bottom: 8px;
background: #16213e; border-radius: 10px; cursor: pointer;
border-left: 3px solid #4cc9f0;
}
.search-hit:hover { background: #1e3a5f; }
.search-hit .hit-cid { font-size: 11px; color: #4cc9f0; }
.search-hit .hit-msg { font-size: 13px; color: #e0e0e0; margin-top: 4px; }
.search-hit .hit-time { font-size: 11px; color: #6b7a99; margin-top: 3px; }
mark { background: transparent; color: #f9ca24; font-weight: 600; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 2px; }
</style>
</head>
<body>
<div class="topbar">
<h1>💬 聊天记录</h1>
<input id="searchInput" class="search-box" placeholder="搜索消息内容..." autocomplete="off">
<span class="live-badge" id="liveBadge">● 实时</span>
</div>
<div class="main" style="position:relative;">
<!-- 搜索覆盖层 -->
<div id="search-overlay"></div>
<!-- 左侧客户列表 -->
<div class="sidebar">
<div class="sidebar-header">
<span id="customerCount">客户</span>
<span id="lastRefresh"></span>
</div>
<div class="customer-list" id="customerList"></div>
</div>
<!-- 右侧对话 -->
<div class="chat-panel" id="chatPanel">
<div class="empty-state" id="emptyState">
<div class="icon">💬</div>
<p>选择一位客户查看对话记录</p>
</div>
<div id="chatHeader" class="chat-header" style="display:none;">
<div>
<div class="cname" id="headerName"></div>
<div class="cid" id="headerId"></div>
</div>
<div class="stats" id="headerStats"></div>
</div>
<div class="chat-messages" id="chatMessages" style="display:none;"></div>
</div>
</div>
<script>
let currentCid = null;
let autoRefresh = null;
let allCustomers = [];
// ── 时间格式化 ──
function fmtTime(ts) {
if (!ts) return '';
const today = new Date().toISOString().slice(0,10);
return ts.startsWith(today) ? ts.slice(11,16) : ts.slice(5,16);
}
// ── 平台徽章 ──
function platBadge(p) {
const map = {
AliWorkbench: ['ali','淘宝'],
taobao: ['ali','淘宝'],
pinduoduo: ['','拼多多'],
jd: ['','京东'],
email: ['email','邮件'],
};
const [cls, label] = map[p] || ['', p || ''];
return label ? `<span class="badge-plat ${cls}">${label}</span>` : '';
}
// ── 加载客户列表 ──
async function loadCustomers() {
const r = await fetch('/api/customers');
allCustomers = await r.json();
renderCustomers(allCustomers);
document.getElementById('customerCount').textContent = `客户 ${allCustomers.length} 人`;
document.getElementById('lastRefresh').textContent = new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
}
function renderCustomers(list) {
const el = document.getElementById('customerList');
el.innerHTML = list.map(c => {
const active = c.customer_id === currentCid ? 'active' : '';
const name = c.customer_name || c.customer_id.slice(-8);
return `<div class="customer-item ${active}" onclick="openChat('${c.customer_id}','${(c.customer_name||'').replace(/'/g,"\\'")}','${c.platform||''}',${c.total_msgs},${c.recv},${c.sent})">
<div class="name">${name} ${platBadge(c.platform)}</div>
<div class="cid">${c.customer_id}</div>
<div class="meta"><span>${c.total_msgs} 条消息</span><span>${fmtTime(c.last_time)}</span></div>
</div>`;
}).join('');
}
// ── 打开对话 ──
async function openChat(cid, name, platform, total, recv, sent) {
currentCid = cid;
renderCustomers(allCustomers);
document.getElementById('emptyState').style.display = 'none';
document.getElementById('chatHeader').style.display = 'flex';
document.getElementById('chatMessages').style.display = 'flex';
document.getElementById('headerName').textContent = name || cid;
document.getElementById('headerId').textContent = cid;
document.getElementById('headerStats').textContent = `共 ${total} 条 收 ${recv} 发 ${sent}`;
await loadConversation(cid);
if (autoRefresh) clearInterval(autoRefresh);
autoRefresh = setInterval(() => loadConversation(cid), 4000);
}
// ── 加载对话 ──
async function loadConversation(cid) {
const r = await fetch(`/api/conversation/${encodeURIComponent(cid)}`);
const msgs = await r.json();
renderMessages(msgs);
}
function renderMessages(msgs) {
const el = document.getElementById('chatMessages');
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
let lastDate = '';
const html = msgs.map(m => {
const date = (m.timestamp || '').slice(0,10);
let divider = '';
if (date && date !== lastDate) { divider = `<div class="day-divider">${date}</div>`; lastDate = date; }
const dir = m.direction;
const avatarChar = dir === 'in' ? '' : '';
const avatarCls = dir === 'in' ? 'buyer' : 'seller';
const content = renderMsgContent(m.message, m.msg_type);
return `${divider}
<div class="msg-row ${dir}">
<div class="avatar ${avatarCls}">${avatarChar}</div>
<div class="bubble-wrap">
<div class="bubble ${dir}">${content}</div>
<div class="msg-time">${fmtTime(m.timestamp)}</div>
</div>
</div>`;
}).join('');
el.innerHTML = html;
if (atBottom) el.scrollTop = el.scrollHeight;
}
function renderMsgContent(msg, msgType) {
if (!msg) return '';
const urlRegGlobal = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/gi;
const urlRegSingle = /(https?:\/\/[^\s]+?\.(jpg|jpeg|png|gif|webp)(\?[^\s]*)?)/i;
const parts = msg.split('#*#').map(s => s.trim()).filter(Boolean);
if (parts.length > 1) {
const segs = parts.map(p => {
const m = p.match(urlRegSingle);
if (m) {
const url = m[0];
return `<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`;
}
const esc = p.replace(/</g,'&lt;').replace(/>/g,'&gt;');
return esc;
});
return segs.join('<br>');
}
const escaped = msg.replace(/</g,'&lt;').replace(/>/g,'&gt;');
return escaped.replace(urlRegGlobal, (url) =>
`<a href="${url}" target="_blank"><img src="${url}" onerror="this.style.display='none'"></a>`
);
}
// ── 搜索 ──
let searchTimer = null;
document.getElementById('searchInput').addEventListener('input', function() {
clearTimeout(searchTimer);
const kw = this.value.trim();
if (!kw) { closeSearch(); return; }
searchTimer = setTimeout(() => doSearch(kw), 300);
});
async function doSearch(kw) {
const r = await fetch(`/api/search?q=${encodeURIComponent(kw)}`);
const results = await r.json();
const overlay = document.getElementById('search-overlay');
overlay.style.display = 'block';
if (!results.length) {
overlay.innerHTML = `<p style="color:#6b7a99;text-align:center;margin-top:60px;">未找到匹配消息</p>`;
return;
}
const hi = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(hi, 'gi');
overlay.innerHTML = results.map(r => {
const dir = r.direction === 'in' ? '买家' : '客服';
const msg = r.message.replace(/</g,'&lt;').replace(re, m => `<mark>${m}</mark>`);
return `<div class="search-hit" onclick="closeSearch(); openChat('${r.customer_id}','','','','','')">
<div class="hit-cid">${r.customer_id} ${r.customer_name||''} · ${dir}</div>
<div class="hit-msg">${msg}</div>
<div class="hit-time">${(r.timestamp||'').slice(0,16)}</div>
</div>`;
}).join('');
}
function closeSearch() {
document.getElementById('search-overlay').style.display = 'none';
document.getElementById('searchInput').value = '';
}
// ── 初始化 ──
loadCustomers();
setInterval(loadCustomers, 10000);
</script>
</body>
</html>
"""
PRICING_HTML = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 报价测试</title>
<style>
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #1a1a2e; color: #e0e0e0; padding: 20px; }
.card { background:#16213e; border:1px solid #0f3460; border-radius:12px; padding:16px; max-width:880px; margin:0 auto; }
.title { font-size:16px; color:#4cc9f0; margin-bottom:12px; }
.row { display:flex; gap:12px; margin-bottom:10px; }
.row .col { flex:1; }
.input { width:100%; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:10px 12px; color:#e0e0e0; font-size:13px; outline:none; }
.input::placeholder { color:#6b7a99; }
.btn { background:#0d7377; color:#14ffec; border:none; border-radius:10px; padding:10px 16px; cursor:pointer; font-size:13px; }
.btn:disabled { opacity:.5; cursor:not-allowed; }
.result { margin-top:14px; background:#0f3460; border:1px solid #1a5276; border-radius:10px; padding:12px; font-size:13px; white-space:pre-wrap; }
.tip { font-size:12px; color:#6b7a99; margin-top:6px; }
</style>
</head>
<body>
<div class="card">
<div class="title">🧪 AI 报价测试</div>
<div class="row">
<div class="col">
<input id="cid" class="input" placeholder="客户ID如 tb7518056865:小林">
</div>
<div class="col">
<input id="acc" class="input" placeholder="店铺ID可留空">
</div>
</div>
<div class="row">
<div class="col">
<textarea id="msg" class="input" rows="4" placeholder="输入消息文本或图片URL多张用 #*# 分隔)。示例:这两张有原图吗#*#https://...jpg#*#https://...png"></textarea>
</div>
</div>
<div class="row">
<button class="btn" id="runBtn" onclick="runPricing()">测试报价</button>
</div>
<div id="result" class="result" style="display:none;"></div>
<div class="tip">提示含图片URL时Agent会自动调用图片分析并结合复杂度、尺寸、人脸与风险给出建议价文本砍价低于最近图片底线会被礼貌拒绝。</div>
</div>
<script>
async function runPricing() {
const cid = document.getElementById('cid').value.trim();
const acc = document.getElementById('acc').value.trim();
const msg = document.getElementById('msg').value.trim();
const btn = document.getElementById('runBtn');
const res = document.getElementById('result');
if (!cid || !msg) { alert('请填写客户ID与消息'); return; }
btn.disabled = true; res.style.display = 'none'; res.textContent = '';
try {
const r = await fetch('/api/pricing/run', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ from_id: cid, acc_id: acc, msg })
});
const data = await r.json();
res.style.display = 'block';
res.textContent = data.error ? ('错误:'+data.error) : (
`回复:${data.reply}\n\n【调试】目标Agent${data.agent}\n最低价${data.floor}\n应答${data.should_reply?'':''}`
);
} catch(e) {
res.style.display = 'block';
res.textContent = '请求失败:'+e;
} finally {
btn.disabled = false;
}
}
</script>
</body>
</html>
"""
@app.route("/")
def index():
return render_template_string(HTML)
@app.route("/pricing")
def pricing_index():
return render_template_string(PRICING_HTML)
@app.route("/api/customers")
def api_customers():
return jsonify(db.get_customers(limit=200))
@app.route("/api/conversation/<customer_id>")
def api_conversation(customer_id):
return jsonify(db.get_conversation(customer_id, limit=500))
@app.route("/api/search")
def api_search():
kw = request.args.get("q", "").strip()
if not kw:
return jsonify([])
return jsonify(db.search_messages(kw, limit=60))
if __name__ == "__main__":
print("聊天记录 UI 启动中...")
print("访问 → http://localhost:5678")
app.run(host="0.0.0.0", port=5678, debug=False)
@app.route("/api/pricing/run", methods=["POST"])
def api_pricing_run():
global pricing_agent
if pricing_agent is None:
return jsonify({"error":"报价Agent未初始化"})
data = request.get_json(force=True) or {}
from_id = (data.get("from_id") or "").strip()
acc_id = (data.get("acc_id") or "").strip()
msg = (data.get("msg") or "").strip()
if not from_id or not msg:
return jsonify({"error":"缺少参数 from_id 或 msg"})
# 构造提示词:直接使用用户输入,保持与正式场景一致
user_prompt = msg
deps = AgentDeps(
msg_id="pricing-test",
acc_id=acc_id or "TEST_SHOP",
from_id=from_id,
platform="taobao"
)
try:
# 强制使用报价Agent
result = asyncio.run(pricing_agent.agent_pricing.run(user_prompt, deps=deps, message_history=[]))
# 读取底线
try:
from config.config import MIN_PRICE_FLOOR
st = pricing_agent._get_conversation_state(from_id)
floor = st.last_min_price if isinstance(st.last_min_price,int) and st.last_min_price>0 else MIN_PRICE_FLOOR
except Exception:
floor = None
return jsonify({
"reply": result.output,
"should_reply": True,
"agent": "pricing",
"floor": floor
})
except Exception as e:
return jsonify({"error": str(e)})

View File

@@ -1,95 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Self-evolution MVP cycle runner.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
load_dotenv(dotenv_path=PROJECT_ROOT / ".env")
from evolution.mvp import ChatSourceConfig, DEFAULT_CANDIDATE_PATH, DEFAULT_POLICY_PATH, run_cycle
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run self-evolution MVP cycle")
parser.add_argument(
"--source",
type=str,
default="mysql",
choices=["auto", "sqlite", "mysql"],
help="Chat data source, default mysql (online)",
)
parser.add_argument("--hours", type=int, default=24, help="Lookback window for chat samples")
parser.add_argument("--max-customers", type=int, default=200, help="Max customers sampled")
parser.add_argument(
"--max-messages-per-customer",
type=int,
default=80,
help="Max messages loaded per customer",
)
parser.add_argument("--runtime-hours", type=int, default=24, help="Runtime metric window")
parser.add_argument(
"--publish",
action="store_true",
help="Write config/evolution_candidate.json when gate passes",
)
parser.add_argument(
"--policy-path",
type=str,
default=str(DEFAULT_POLICY_PATH),
help="Path to evolution gate policy file",
)
parser.add_argument(
"--candidate-path",
type=str,
default=str(DEFAULT_CANDIDATE_PATH),
help="Path to candidate output file",
)
parser.add_argument("--db-path", type=str, default="", help="SQLite path when --source sqlite")
parser.add_argument("--mysql-host", type=str, default=os.getenv("MYSQL_HOST", "127.0.0.1"))
parser.add_argument("--mysql-port", type=int, default=int(os.getenv("MYSQL_PORT", "3306")))
parser.add_argument("--mysql-user", type=str, default=os.getenv("MYSQL_USER", "root"))
parser.add_argument("--mysql-password", type=str, default=os.getenv("MYSQL_PASSWORD", ""))
parser.add_argument("--mysql-database", type=str, default=os.getenv("MYSQL_DATABASE", "ai_cs"))
return parser.parse_args()
def main() -> int:
args = parse_args()
os.environ.setdefault("PYTHONUTF8", "1")
chat_source = ChatSourceConfig(
source=args.source,
sqlite_path=args.db_path or str(PROJECT_ROOT / "db" / "chat_log_db" / "chats.db"),
mysql_host=args.mysql_host,
mysql_port=args.mysql_port,
mysql_user=args.mysql_user,
mysql_password=args.mysql_password,
mysql_database=args.mysql_database,
)
result = run_cycle(
hours=args.hours,
max_customers=args.max_customers,
max_messages_per_customer=args.max_messages_per_customer,
runtime_hours=args.runtime_hours,
publish=args.publish,
chat_source=chat_source,
policy_path=Path(args.policy_path),
candidate_path=Path(args.candidate_path),
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,45 +0,0 @@
# -*- 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 # 查看当前数据")

View File

@@ -1,175 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把本地 SQLite 聊天记录迁移到 MySQL:
source: db/chat_log_db/chats.db -> table chat_logs
用法示例:
python scripts/migrate_chat_logs_to_mysql.py --host xinhui.cloud --port 3306 \
--user ai_cs_user --password xxx --database ai_cs --batch-size 2000 --truncate-target
"""
from __future__ import annotations
import argparse
import os
import sqlite3
import time
from pathlib import Path
import pymysql
def ensure_mysql_table(conn):
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS chat_logs (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
customer_id VARCHAR(128) NOT NULL,
customer_name VARCHAR(255) DEFAULT '',
acc_id VARCHAR(128) DEFAULT '',
platform VARCHAR(64) DEFAULT '',
direction VARCHAR(8) NOT NULL,
message TEXT NOT NULL,
msg_type INTEGER DEFAULT 0,
timestamp DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
cur.execute("SHOW INDEX FROM chat_logs")
exists = {str(r.get("Key_name", "")) for r in cur.fetchall()}
if "idx_customer" not in exists:
cur.execute("CREATE INDEX idx_customer ON chat_logs(customer_id)")
if "idx_ts" not in exists:
cur.execute("CREATE INDEX idx_ts ON chat_logs(timestamp)")
if "idx_acc" not in exists:
cur.execute("CREATE INDEX idx_acc ON chat_logs(acc_id)")
conn.commit()
def get_sqlite_conn(path: Path):
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
return conn
def get_mysql_conn(host: str, port: int, user: str, password: str, database: str):
return pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
autocommit=False,
cursorclass=pymysql.cursors.DictCursor,
)
def migrate(sqlite_path: Path, host: str, port: int, user: str, password: str, database: str, batch_size: int, truncate_target: bool):
if not sqlite_path.exists():
raise FileNotFoundError(f"SQLite 文件不存在: {sqlite_path}")
s_conn = get_sqlite_conn(sqlite_path)
m_conn = get_mysql_conn(host, port, user, password, database)
try:
ensure_mysql_table(m_conn)
if truncate_target:
with m_conn.cursor() as cur:
cur.execute("TRUNCATE TABLE chat_logs")
m_conn.commit()
total = s_conn.execute("SELECT COUNT(*) AS c FROM chat_logs").fetchone()["c"]
print(f"[MIGRATE] SQLite 源总行数: {total}")
if total == 0:
return 0
migrated = 0
last_id = 0
started = time.time()
insert_sql = (
"INSERT INTO chat_logs "
"(customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)"
)
while True:
rows = s_conn.execute(
"""
SELECT id, customer_id, customer_name, acc_id, platform, direction, message, msg_type, timestamp
FROM chat_logs
WHERE id > ?
ORDER BY id ASC
LIMIT ?
""",
(last_id, batch_size),
).fetchall()
if not rows:
break
vals = []
for r in rows:
vals.append(
(
r["customer_id"] or "",
r["customer_name"] or "",
r["acc_id"] or "",
r["platform"] or "",
r["direction"] or "in",
r["message"] or "",
int(r["msg_type"] or 0),
r["timestamp"],
)
)
last_id = r["id"]
with m_conn.cursor() as cur:
cur.executemany(insert_sql, vals)
m_conn.commit()
migrated += len(vals)
elapsed = time.time() - started
print(f"[MIGRATE] {migrated}/{total} ({(migrated/total)*100:.1f}%) elapsed={elapsed:.1f}s")
return migrated
finally:
try:
s_conn.close()
except Exception:
pass
try:
m_conn.close()
except Exception:
pass
def main():
parser = argparse.ArgumentParser(description="迁移 chat_logs: SQLite -> MySQL")
parser.add_argument("--sqlite-path", default=str(Path("db") / "chat_log_db" / "chats.db"))
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=3306)
parser.add_argument("--user", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--batch-size", type=int, default=2000)
parser.add_argument("--truncate-target", action="store_true")
args = parser.parse_args()
sqlite_path = Path(args.sqlite_path)
migrated = migrate(
sqlite_path=sqlite_path,
host=args.host,
port=args.port,
user=args.user,
password=args.password,
database=args.database,
batch_size=max(100, int(args.batch_size)),
truncate_target=bool(args.truncate_target),
)
print(f"[DONE] 迁移完成,写入 {migrated}")
if __name__ == "__main__":
main()

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
迁移 customer_db/customers.json -> MySQL customer_profiles
"""
from __future__ import annotations
import argparse
import json
from datetime import datetime
from pathlib import Path
import pymysql
def get_conn(host: str, port: int, user: str, password: str, database: str):
return pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
autocommit=False,
cursorclass=pymysql.cursors.DictCursor,
)
def ensure_table(conn):
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS customer_profiles (
customer_id VARCHAR(128) PRIMARY KEY,
profile_json LONGTEXT NOT NULL,
last_update DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""
)
cur.execute("SHOW INDEX FROM customer_profiles")
exists = {str(r.get("Key_name", "")) for r in cur.fetchall()}
if "idx_last_update" not in exists:
cur.execute("CREATE INDEX idx_last_update ON customer_profiles(last_update)")
conn.commit()
def migrate(json_path: Path, host: str, port: int, user: str, password: str, database: str, truncate_target: bool):
if not json_path.exists():
raise FileNotFoundError(f"customers.json 不存在: {json_path}")
customers = json.loads(json_path.read_text(encoding="utf-8") or "{}")
if not isinstance(customers, dict):
raise RuntimeError("customers.json 格式错误,期望对象映射")
conn = get_conn(host, port, user, password, database)
try:
ensure_table(conn)
if truncate_target:
with conn.cursor() as cur:
cur.execute("TRUNCATE TABLE customer_profiles")
conn.commit()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sql = (
"REPLACE INTO customer_profiles (customer_id, profile_json, last_update) "
"VALUES (%s, %s, %s)"
)
total = 0
with conn.cursor() as cur:
for cid, profile in customers.items():
cur.execute(sql, (str(cid), json.dumps(profile, ensure_ascii=False), now))
total += 1
conn.commit()
return total
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="迁移 customers.json 到 MySQL")
parser.add_argument("--json-path", default=str(Path("customer_db") / "customers.json"))
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=3306)
parser.add_argument("--user", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--truncate-target", action="store_true")
args = parser.parse_args()
total = migrate(
json_path=Path(args.json_path),
host=args.host,
port=args.port,
user=args.user,
password=args.password,
database=args.database,
truncate_target=bool(args.truncate_target),
)
print(f"[DONE] customer_profiles 写入 {total}")
if __name__ == "__main__":
main()

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
迁移其余 SQLite 业务库到 MySQL保留主键
- deal_outcome_db/outcomes.db -> deal_outcomes
- designer_roster_db/roster.db -> designers/designer_shops/designer_online/round_robin
- image_tasks.db -> image_tasks/requirement_history
- task_db/tasks.db -> tasks/task_logs
"""
from __future__ import annotations
import argparse
import sqlite3
from pathlib import Path
from typing import List, Dict
import pymysql
MAPPINGS = [
{"sqlite": Path("db/deal_outcome_db/outcomes.db"), "tables": ["deal_outcomes"]},
{"sqlite": Path("db/designer_roster_db/roster.db"), "tables": ["designers", "designer_shops", "designer_online", "round_robin"]},
{"sqlite": Path("db/image_tasks.db"), "tables": ["image_tasks", "task_requirement_changes"]},
{"sqlite": Path("db/task_db/tasks.db"), "tables": ["tasks"]},
]
def mysql_conn(host: str, port: int, user: str, password: str, database: str):
return pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
autocommit=False,
cursorclass=pymysql.cursors.DictCursor,
)
def sqlite_table_exists(conn: sqlite3.Connection, table: str) -> bool:
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table,),
).fetchone()
return row is not None
def sqlite_fetch_all(conn: sqlite3.Connection, table: str) -> List[sqlite3.Row]:
conn.row_factory = sqlite3.Row
return conn.execute(f"SELECT * FROM {table}").fetchall()
def migrate_table(mysql, rows: List[sqlite3.Row], table: str, truncate_target: bool) -> int:
if not rows:
return 0
cols = list(rows[0].keys())
col_sql = ", ".join(cols)
val_sql = ", ".join(["%s"] * len(cols))
sql = f"REPLACE INTO {table} ({col_sql}) VALUES ({val_sql})"
if truncate_target:
with mysql.cursor() as cur:
try:
cur.execute(f"TRUNCATE TABLE {table}")
except Exception:
try:
cur.execute(f"DELETE FROM {table}")
except Exception:
return 0
values = [tuple(r[c] for c in cols) for r in rows]
with mysql.cursor() as cur:
cur.executemany(sql, values)
mysql.commit()
return len(values)
def main():
p = argparse.ArgumentParser(description="迁移剩余 SQLite 业务库到 MySQL")
p.add_argument("--host", required=True)
p.add_argument("--port", type=int, default=3306)
p.add_argument("--user", required=True)
p.add_argument("--password", required=True)
p.add_argument("--database", required=True)
p.add_argument("--truncate-target", action="store_true")
args = p.parse_args()
total = 0
with mysql_conn(args.host, args.port, args.user, args.password, args.database) as mconn:
for item in MAPPINGS:
sp = item["sqlite"]
if not sp.exists():
continue
sconn = sqlite3.connect(str(sp))
try:
for table in item["tables"]:
if not sqlite_table_exists(sconn, table):
continue
rows = sqlite_fetch_all(sconn, table)
n = migrate_table(mconn, rows, table, truncate_target=bool(args.truncate_target))
total += n
print(f"[MIGRATE] {sp}::{table} -> {n}")
finally:
sconn.close()
print(f"[DONE] migrated total rows: {total}")
if __name__ == "__main__":
main()

View File

@@ -1,8 +0,0 @@
$ErrorActionPreference = "Stop"
# Use a writable uv cache path on Windows to avoid permission issues
# with default cache locations in restricted environments.
$env:UV_CACHE_DIR = Join-Path $env:TEMP "uv-cache-tw-runtime"
New-Item -ItemType Directory -Force $env:UV_CACHE_DIR | Out-Null
uv run tests\test_ai_chat.py

View File

@@ -1,53 +0,0 @@
import logging
from utils.observability import build_trace_id
from core.websocket_brain_flow import decide_brain_action, execute_brain_action
logger = logging.getLogger("cs_agent")
async def handle_agent_reply_flow(client, data: dict, *, workflow, shop_type_resolver):
"""处理单条消息:统一走 Brain 决策 + 执行。"""
try:
msg_text = client.to_chinese(data.get("msg", ""))
customer_id = data.get("from_id", "")
trace_id = build_trace_id(data.get("acc_id", ""), customer_id, data.get("msg_id", ""), msg_text[:64])
data["_trace_id"] = trace_id
shop_type = shop_type_resolver(data.get("acc_id", ""), client.to_chinese(data.get("goods_name", "") or ""))
customer_msg = client._build_customer_message(data)
decision = await decide_brain_action(
client,
data,
customer_msg,
trace_id=trace_id,
msg_text=msg_text,
shop_type=shop_type,
)
client._activity_log(
"brain_decision",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
action=decision.action,
source=decision.source,
should_reply=bool(decision.should_reply),
need_transfer=bool(decision.need_transfer),
)
await execute_brain_action(
client,
data,
decision=decision,
trace_id=trace_id,
msg_text=msg_text,
)
except Exception as e:
logger.error("Agent 处理失败: %s", e)
client._activity_log(
"agent_process_error",
trace_id=data.get("_trace_id", ""),
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
error=str(e),
)

View File

@@ -1,100 +0,0 @@
import asyncio
import os
from typing import Any
def cancel_auto_quote_task(client, key: str, reason: str = ""):
task = client._auto_quote_tasks.get(key)
if task and not task.done():
task.cancel()
client._activity_log("auto_quote_cancel", key=key, reason=reason or "unknown")
def build_auto_quote_signature(state: Any) -> str:
"""为待报价内容生成稳定签名,用于避免同一批内容反复自动触发。"""
urls = list(getattr(state, "pending_image_urls", []) or [])
reqs = list(getattr(state, "pending_requirements", []) or [])
req_tail = reqs[-6:] if len(reqs) > 6 else reqs
return "||".join(urls) + "##" + "||".join(req_tail)
async def schedule_auto_quote(client, data: dict, *, shop_type_resolver):
"""
智能兜底:客户发图后若长时间不再补充消息,自动触发一次报价,避免会话卡住。
"""
if not client.enable_agent or not client.agent:
return
try:
shop_type = shop_type_resolver(data.get('acc_id', ''), client.to_chinese(data.get('goods_name', '') or ''))
if shop_type != "find_image":
return
cid = data.get('from_id', '')
key = client._customer_key(data)
state = client.agent._get_conversation_state(cid)
if not state or not getattr(state, "pending_image_urls", None):
cancel_auto_quote_task(client, key, reason="no_pending_images")
client._auto_quote_done_sig.pop(key, None)
return
if state.quote_phase not in {"collecting", "waiting_result"}:
return
current_sig = build_auto_quote_signature(state)
if current_sig and client._auto_quote_done_sig.get(key) == current_sig:
client._activity_log(
"auto_quote_skip_duplicate",
key=key,
pending_count=len(state.pending_image_urls),
)
return
try:
idle_seconds = max(8, int(os.getenv("AUTO_QUOTE_IDLE_SECONDS", "18")))
except Exception:
idle_seconds = 18
cancel_auto_quote_task(client, key, reason="reschedule")
async def _delayed_auto_quote(capture_key: str, capture_data: dict, wait_s: int, capture_sig: str):
await asyncio.sleep(wait_s)
async with client._get_customer_lock(capture_key):
capture_cid = capture_data.get('from_id', '')
st = client.agent._get_conversation_state(capture_cid)
if not st or not st.pending_image_urls:
client._auto_quote_done_sig.pop(capture_key, None)
return
# 内容变化时,放弃旧触发(会在新一轮消息后重新调度)。
if build_auto_quote_signature(st) != capture_sig:
return
# 标记本批次已自动触发,避免同内容循环“马上报价”。
client._auto_quote_done_sig[capture_key] = capture_sig
# 直接置为可报价,走内部自动报价入口(不伪造客户语句)。
client.agent._mark_quote_ready(st)
client.agent._sync_pending_quote_state(capture_cid, st)
client._activity_log(
"auto_quote_trigger",
key=capture_key,
pending_count=len(st.pending_image_urls),
wait_s=wait_s,
)
notify_data = dict(capture_data)
notify_data["msg_id"] = "auto_quote_idle_trigger"
notify_data["msg"] = "__AUTO_QUOTE_INTERNAL_TRIGGER__"
notify_msg = client._build_customer_message(notify_data)
response = await client.agent.build_auto_quote_reply(st, notify_msg)
if response.should_reply and response.reply and not response.need_transfer:
await client.send_reply(capture_data, response.reply)
client._activity_log(
"auto_quote_sent",
key=capture_key,
reply=response.reply,
)
task = asyncio.create_task(_delayed_auto_quote(key, dict(data), idle_seconds, current_sig))
client._auto_quote_tasks[key] = task
client._activity_log(
"auto_quote_scheduled",
key=key,
pending_count=len(state.pending_image_urls),
phase=state.quote_phase,
wait_s=idle_seconds,
)
except Exception as e:
client._activity_log("auto_quote_schedule_error", error=str(e), key=client._customer_key(data))

View File

@@ -1,311 +0,0 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("cs_agent")
@dataclass
class BrainDecision:
action: str # reply | quote | transfer | noop
source: str
reply: str = ""
transfer_msg: str = ""
should_reply: bool = False
need_transfer: bool = False
payload: dict[str, Any] | None = None
def _extract_json_obj(text: str) -> dict[str, Any] | None:
if not text:
return None
m = re.search(r"\{[\s\S]*\}", text)
if not m:
return None
try:
return json.loads(m.group(0))
except Exception:
return None
async def _ai_policy_brain_decide(client, data: dict, *, msg_text: str, shop_type: str) -> BrainDecision | None:
if not client.enable_agent or not client.agent or not client.AgentDeps:
return None
acc_id = str(data.get("acc_id", "") or "")
customer_id = str(data.get("from_id", "") or "")
current_urls = client._extract_image_urls(msg_text)
recent_urls = client._collect_recent_image_urls(customer_id, acc_id, max_count=6)
key = client._customer_key(data)
pending_urls = client._pending_images.get(key) or []
try:
order_status = client._detect_order_status(msg_text)
has_image_url = client._msg_has_image_url(msg_text)
refers_images = client._msg_refers_images(msg_text)
is_price = client._msg_is_price_inquiry(msg_text)
is_req = client._msg_is_requirement(msg_text)
ext_contact = client._msg_requests_external_contact(msg_text)
except Exception:
order_status, has_image_url, refers_images, is_price, is_req, ext_contact = "", False, False, False, False, False
deps = client.AgentDeps(
msg_id=str(data.get("msg_id", "") or "brain_policy"),
acc_id=acc_id,
from_id=customer_id,
platform=str(data.get("acc_type", "") or "AliWorkbench"),
)
prompt = (
"你是淘宝客服系统的主决策Brain只做决策不要解释。\n"
"你必须根据历史规则和当前上下文,输出唯一动作。\n"
"可选动作 action: reply / quote / transfer / noop。\n"
"历史规则(完整继承):\n"
"1) 客户发图/补图:先自然承接,再根据上下文决定继续收集或报价;\n"
"2) 客户询价且有可用图片(当前或最近)时,优先 action=quote\n"
"3) 若有 pending 图片且客户催报价/补充需求,优先 quote_mode=flush_pending\n"
"4) 仅打招呼/短无意义文本:可 action=reply 简短承接,不要机械模板;\n"
"5) 索要外部联系方式(微信/QQ/手机号)时,不外呼,站内引导;\n"
"6) 订单已付款:可回执安排处理;未付款/待付款:提醒完成付款;\n"
"7) 地图/政治/高风险内容:谨慎,必要时 transfer 或拒绝性 reply\n"
"8) 尺寸超限/不可做场景:给明确边界,不要胡乱承诺;\n"
"9) 客户没发图却问价:先承接,再引导发图;\n"
"10) 避免重复外发,避免同一句话反复说。\n"
"\n"
"quote_mode 可选: flush_pending / analyze_current_or_recent / collect_only\n"
"只输出 JSON\n"
'{"action":"reply|quote|transfer|noop","reply":"","transfer_msg":"","quote_mode":"","reason":""}\n\n'
f"店铺类型: {shop_type}\n"
f"legacy_fast_quote_enabled: {str(bool(client._legacy_fast_quote_enabled)).lower()}\n"
f"客户原话: {msg_text}\n"
f"has_image_url: {has_image_url}\n"
f"current_image_urls_count: {len(current_urls)}\n"
f"recent_image_urls_count: {len(recent_urls)}\n"
f"pending_image_urls_count: {len(pending_urls)}\n"
f"refers_images: {refers_images}\n"
f"is_price_inquiry: {is_price}\n"
f"is_requirement: {is_req}\n"
f"requests_external_contact: {ext_contact}\n"
f"order_status: {order_status or 'none'}\n"
)
try:
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
raw = str(getattr(result, "output", "") or "").strip()
obj = _extract_json_obj(raw)
if not obj:
client._activity_log(
"brain_policy_parse_error",
acc_id=acc_id,
customer_id=customer_id,
raw=raw[:300],
)
return None
action = str(obj.get("action", "") or "").strip().lower()
reply = str(obj.get("reply", "") or "").strip()
transfer_msg = str(obj.get("transfer_msg", "") or "").strip()
quote_mode = str(obj.get("quote_mode", "") or "").strip().lower()
reason = str(obj.get("reason", "") or "").strip()
payload: dict[str, Any] | None = None
if action == "quote":
mode = quote_mode or "analyze_current_or_recent"
if mode == "flush_pending":
payload = {"mode": "flush_pending", "key": key, "pre_reply": reply}
elif mode == "collect_only":
payload = {"mode": "collect_only", "pre_reply": reply}
else:
urls = current_urls or recent_urls
payload = {"mode": "analyze_urls", "urls": urls, "pre_reply": reply}
decision = BrainDecision(
action=action if action in {"reply", "quote", "transfer", "noop"} else "noop",
source="brain_ai_policy",
reply=reply,
transfer_msg=transfer_msg,
should_reply=bool(reply),
need_transfer=(action == "transfer"),
payload=payload,
)
client._activity_log(
"brain_policy_raw",
acc_id=acc_id,
customer_id=customer_id,
action=decision.action,
quote_mode=quote_mode,
reason=reason,
)
return decision
except Exception as e:
client._activity_log(
"brain_policy_error",
acc_id=acc_id,
customer_id=customer_id,
error=str(e),
)
return None
async def decide_brain_action(client, data: dict, customer_msg, *, trace_id: str, msg_text: str, shop_type: str) -> BrainDecision:
"""统一主决策层:优先由 Brain AI 决策;失败时回退 Agent 默认决策。"""
ai_decision = await _ai_policy_brain_decide(client, data, msg_text=msg_text, shop_type=shop_type)
if ai_decision is not None:
return ai_decision
# 回退:保持可用性
logger.info("Agent 正在处理消息...")
client._activity_log(
"agent_process_start",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
msg=msg_text,
)
response = await client.agent.process_message(customer_msg)
client._activity_log(
"agent_process_done",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
result="ok",
should_reply=bool(response.should_reply),
need_transfer=bool(response.need_transfer),
)
if response.need_transfer:
return BrainDecision(
action="transfer",
source="fallback_agent",
reply=response.reply or "",
transfer_msg=response.transfer_msg or "",
should_reply=bool(response.should_reply),
need_transfer=True,
)
if response.should_reply and response.reply:
return BrainDecision(
action="reply",
source="fallback_agent",
reply=response.reply,
should_reply=True,
need_transfer=False,
)
return BrainDecision(action="noop", source="fallback_agent", should_reply=False, need_transfer=False)
async def execute_brain_action(client, data: dict, *, decision: BrainDecision, trace_id: str, msg_text: str):
"""统一执行层:只执行标准动作。"""
customer_id = data.get("from_id", "")
if customer_id:
client._touch_customer_last_contact(customer_id)
if decision.action == "transfer":
logger.info("Agent 决定转接人工")
client._activity_log(
"agent_transfer",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
transfer_msg=decision.transfer_msg,
)
client._fire_and_forget(
client._post_tianwang_callback(
"message_processed",
data,
extra={
"should_reply": bool(decision.should_reply),
"need_transfer": True,
"agent_reply": decision.reply or "",
"transfer_msg": decision.transfer_msg or "",
},
)
)
await client.transfer_to_human(data, decision.transfer_msg)
client._push_chat_to_wechat_safe(
data=data,
customer_msg=msg_text,
reply_msg=decision.transfer_msg or "转接",
tag="转人工",
)
return
if decision.action == "reply":
text = (decision.reply or "").strip()
if not text:
return
await asyncio.sleep(0.6)
client._activity_log(
"agent_reply",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
reply=text,
)
await client.send_reply(data, text)
await client._maybe_schedule_auto_quote(data)
client._fire_and_forget(
client._post_tianwang_callback(
"message_processed",
data,
extra={
"should_reply": True,
"need_transfer": False,
"agent_reply": text,
},
)
)
client._push_chat_to_wechat_safe(
data=data,
customer_msg=msg_text,
reply_msg=text,
tag="正常AI回复",
)
return
if decision.action == "quote":
payload = decision.payload or {}
pre_reply = str(payload.get("pre_reply", "") or "").strip()
if pre_reply:
await client.send_reply(data, pre_reply)
mode = str(payload.get("mode", "") or "")
if mode == "flush_pending":
key = str(payload.get("key", "") or "")
if key:
await client._flush_pending_images(key, data)
elif mode == "analyze_urls":
urls = payload.get("urls") or []
if isinstance(urls, list) and urls:
if len(urls) == 1:
asyncio.create_task(client._analyze_single_and_reply(data, urls[0]))
else:
asyncio.create_task(client._analyze_multi_and_reply(data, urls))
else:
await client.send_reply(data, "你把要处理的图再发我一下,我马上给你看。")
else:
if not pre_reply:
await client.send_reply(data, "收到,我先看一下哈,稍等哈。")
return
# noop
client._activity_log(
"agent_no_reply",
trace_id=trace_id,
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
)
client._fire_and_forget(
client._post_tianwang_callback(
"message_processed",
data,
extra={
"should_reply": False,
"need_transfer": False,
"agent_reply": "",
},
)
)

View File

@@ -1,48 +0,0 @@
import os
from datetime import datetime
from typing import Any, Dict, Optional
async def post_tianwang_callback_flow(client, event: str, data: dict, extra: Optional[Dict[str, Any]] = None):
"""将消息处理事件回调给天网。"""
if not client._tianwang_callback_url:
return
try:
import httpx
trust_env = os.getenv("TIANWANG_CALLBACK_TRUST_ENV", "false").lower() in ("1", "true", "yes")
payload = {
"event": event,
"timestamp": datetime.now().isoformat(),
"agent_name": client._tianwang_agent_name,
"acc_id": str(data.get("acc_id", "") or ""),
"customer_id": str(data.get("from_id", "") or ""),
"customer_name": client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")),
"msg_id": str(data.get("msg_id", "") or ""),
"msg_type": int(data.get("msg_type", 0) or 0),
"msg": client.to_chinese(data.get("msg", "") or ""),
"goods_name": client.to_chinese(data.get("goods_name", "") or ""),
"goods_order": client.to_chinese(data.get("goods_order", "") or ""),
}
if extra:
payload.update(extra)
async with httpx.AsyncClient(timeout=6, trust_env=trust_env) as http_client:
resp = await http_client.post(client._tianwang_callback_url, json=payload)
ok = 200 <= resp.status_code < 300
client._activity_log(
"tianwang_callback",
result="ok" if ok else "http_error",
event_name=event,
status_code=resp.status_code,
acc_id=payload["acc_id"],
customer_id=payload["customer_id"],
)
except Exception as e:
client._activity_log(
"tianwang_callback",
result="error",
event_name=event,
acc_id=str(data.get("acc_id", "") or ""),
customer_id=str(data.get("from_id", "") or ""),
error=str(e),
)

View File

@@ -1,556 +0,0 @@
import asyncio
import json
import hashlib
from collections import deque
from datetime import datetime
from typing import Optional, Dict, Any, List
from utils.observability import emit_activity
from core.websocket_agent_reply_flow import handle_agent_reply_flow
from core.websocket_quote_flow import handle_single_image_quote, handle_multi_image_quote
from core.websocket_debounce_flow import (
debounce_agent_reply,
pick_debounce_seconds,
guess_intent_for_debounce,
looks_like_requirement_text,
rand_between,
msg_has_image_url,
msg_refers_images,
extract_image_urls,
collect_recent_image_urls,
)
from core.websocket_auto_quote_flow import (
cancel_auto_quote_task,
build_auto_quote_signature,
schedule_auto_quote,
)
from core.websocket_system_inquiry_flow import (
load_system_inquiry_rules,
normalize_kw_list,
resolve_system_inquiry_policy,
match_system_inquiry,
handle_system_inquiry,
)
from core.websocket_transfer_flow import transfer_to_human_flow
from core.websocket_outbound_arbiter_flow import (
normalize_reply_semantic_key,
classify_outbound_reply,
template_family,
outbound_arbiter,
)
from core.websocket_followup_flow import (
unreplied_followup_loop,
scan_and_send_unreplied_followups,
compose_ai_scene_reply,
)
from core.websocket_outbound_flow import (
send_reply_flow,
ai_generate_outbound_reply,
ai_guard_outbound_reply,
colloquialize_outbound_reply,
)
from core.websocket_runtime_flow import command_handler_flow, run_client_flow
from core.websocket_workflow_flow import workflow_agent_notify_flow, workflow_send_flow
from core.websocket_connection_flow import connect_flow, receive_messages_flow, handle_message_flow
from core.websocket_send_flow import send_text_flow, send_image_flow, send_message_flow
from core.websocket_callback_flow import post_tianwang_callback_flow
from core.websocket_customer_profile_flow import extract_and_save_customer_info_flow
from core.websocket_message_utils_flow import (
is_transfer_msg,
pick_transfer_greeting,
is_shop_card,
extract_customer_text_from_shop_card_msg,
has_chat_history,
should_ignore,
get_msg_type_name,
to_chinese_text,
)
from core.websocket_dispatch_flow import dispatch_assign_once_flow
from core.websocket_image_entry_flow import handle_image_message_flow
from core.websocket_misc_rules_flow import (
msg_is_price_inquiry,
detect_order_status,
msg_requests_external_contact,
extract_size_pairs_m,
oversize_reply_if_needed,
)
from core.websocket_summary_flow import save_conversation_summary_flow
from core.websocket_helpers_flow import (
fire_and_forget,
prune_seen,
log_inbound_once,
log_outbound_once,
build_customer_message,
touch_customer_last_contact,
push_chat_to_wechat_safe,
)
from core.websocket_logger_setup import setup_logger
# ========== 转接分组映射 ==========
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:
logger.debug("读取转接分组配置失败,使用默认分组", exc_info=True)
return default_group
import os
logger = setup_logger()
from db.chat_log_db import log_message as _chat_log
from utils.metrics_tracker import emit as metrics_emit
# 导入 Agent 模块
try:
from core.pydantic_ai_agent import CustomerServiceAgent, CustomerMessage, AgentDeps, _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
AgentDeps = None
_get_shop_type = lambda acc_id, goods_name: "find_image"
import traceback
logger.info(f"警告: Agent 模块导入失败: {e}")
traceback.print_exc()
logger.info("将使用基础回复功能")
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.logger = logger
self.AgentDeps = AgentDeps
self.agent = None
self._replied_msg_ids: deque = deque(maxlen=200) # 已回复消息IDFIFO去重
# 消息防抖:同一客户连续发消息时,等待 N 秒后合并处理
self._DEBOUNCE_SECONDS = MESSAGE_DEBOUNCE_SECONDS if isinstance(MESSAGE_DEBOUNCE_SECONDS, int) else 8
self._adaptive_debounce_enabled = os.getenv("ADAPTIVE_DEBOUNCE_ENABLED", "true").lower() in ("1", "true", "yes")
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 = {}
self._auto_quote_tasks: dict = {} # customer_key -> asyncio.Task
self._auto_quote_done_sig: dict = {} # customer_key -> signature同一批内容仅自动触发一次
# 旧版“看图即报价”快速链路(默认关闭,避免与 Agent 批量收集逻辑并发打架)
self._legacy_fast_quote_enabled = os.getenv("LEGACY_FAST_IMAGE_QUOTE", "false").lower() in ("1", "true", "yes")
self._system_inquiry_rules = self._load_system_inquiry_rules()
self._last_reply_sent_at: dict = {} # customer_key -> monotonic ts
self._outbound_semantic_seen: dict = {} # customer_key -> {semantic_key: ts}
self._outbound_class_seen: dict = {} # customer_key -> {reply_class: ts}
self._outbound_template_seen: dict = {} # customer_key -> {template_family: ts}
self._unreplied_followup_sent: dict = {} # customer_key -> monotonic ts补偿消息节流
self._inbound_log_seen: dict = {} # signature -> monotonic ts防重复写入
self._outbound_log_seen: dict = {} # signature -> monotonic ts防重复写入
self._tianwang_callback_url = (
os.getenv("TIANWANG_CALLBACK_URL", "").strip()
or "http://139.199.3.75:18789/api/callback"
)
self._tianwang_agent_name = os.getenv("TIANWANG_AGENT_NAME", "终结者").strip() or "终结者"
self._reply_guard_enabled = os.getenv("AI_REPLY_GUARD_ENABLED", "true").lower() in ("1", "true", "yes")
self._reply_guard_verbose = os.getenv("AI_REPLY_GUARD_VERBOSE", "false").lower() in ("1", "true", "yes")
self._force_ai_generate_reply = os.getenv("FORCE_AI_GENERATE_ALL_REPLIES", "true").lower() in ("1", "true", "yes")
# 延迟加载任务模块(避免循环导入)
self.task_scheduler = None
self.task_manager = None
self.trigger_engine = None
# 多进程分片支持
self.shard_keys: set = set() # 本进程负责的客户 key 集合
self.worker_id = int(os.getenv('AI_CS_WORKER_ID', '0'))
self.worker_count = max(1, int(os.getenv('AI_CS_WORKER_COUNT', '1')))
# 初始化 Agent
if self.enable_agent:
try:
self.agent = CustomerServiceAgent()
logger.info(f"[{self.get_time()}] Agent 初始化成功")
except Exception as e:
logger.info(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)
def _activity_log(self, event: str, **kwargs):
"""统一活动日志,便于按 event 检索完整链路。"""
emit_activity(
logger,
event=event,
trace_id=str(kwargs.pop("trace_id", "")),
customer_id=str(kwargs.pop("customer_id", "")),
result=str(kwargs.pop("result", "ok")),
**kwargs,
)
async def _post_tianwang_callback(self, event: str, data: dict, extra: Optional[Dict[str, Any]] = None):
await post_tianwang_callback_flow(self, event, data, extra=extra)
async def connect(self):
await connect_flow(self)
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]
def _is_owned_by_this_worker(self, customer_key: str) -> bool:
"""
多进程兜底路由:
- 若显式分片存在,用显式分片;
- 否则按 customer_key 哈希到固定 worker避免多进程重复处理同一消息。
"""
if self.shard_keys:
return customer_key in self.shard_keys
if self.worker_count <= 1:
return True
try:
h = int(hashlib.md5(customer_key.encode("utf-8")).hexdigest()[:8], 16)
return (h % self.worker_count) == self.worker_id
except Exception:
return self.worker_id == 0
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):
fire_and_forget(self, coro)
@staticmethod
def _prune_seen(seen: dict, now_mono: float, ttl_sec: float = 8.0):
prune_seen(seen, now_mono, ttl_sec=ttl_sec)
def _log_inbound_once(self, data: dict):
log_inbound_once(self, data, _chat_log)
def _log_outbound_once(self, original_msg: dict, reply_content: str):
log_outbound_once(self, original_msg, reply_content, _chat_log)
def _build_customer_message(self, data: dict) -> CustomerMessage:
return build_customer_message(self, data, CustomerMessage)
def _touch_customer_last_contact(self, customer_id: str):
touch_customer_last_contact(self, customer_id, db)
def _push_chat_to_wechat_safe(
self,
*,
data: dict,
customer_msg: str,
reply_msg: str,
tag: str,
goods_name: str = "",
) -> None:
push_chat_to_wechat_safe(
self,
data=data,
customer_msg=customer_msg,
reply_msg=reply_msg,
tag=tag,
goods_name=goods_name,
)
@staticmethod
def _normalize_reply_semantic_key(text: str) -> str:
return normalize_reply_semantic_key(text)
@staticmethod
def _classify_outbound_reply(text: str) -> str:
return classify_outbound_reply(text)
@staticmethod
def _template_family(reply: str) -> str:
return template_family(reply)
def _outbound_arbiter(self, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]:
return outbound_arbiter(self, original_msg, reply_content, trace_id)
async def _unreplied_followup_loop(self):
await unreplied_followup_loop(self)
async def _scan_and_send_unreplied_followups(self):
await scan_and_send_unreplied_followups(self)
async def _compose_ai_scene_reply(
self,
*,
original_msg: dict,
scene: str,
intent_hint: str,
fallback: str,
) -> str:
return await compose_ai_scene_reply(
self,
original_msg=original_msg,
scene=scene,
intent_hint=intent_hint,
fallback=fallback,
)
async def receive_messages(self):
await receive_messages_flow(self)
async def handle_message(self, message):
await handle_message_flow(self, message, shop_type_resolver=_get_shop_type)
async def _debounce_agent_reply(self, data: dict):
await debounce_agent_reply(self, data)
@staticmethod
def _rand_between(low: float, high: float) -> float:
return rand_between(low, high)
def _guess_intent_for_debounce(self, msg: str) -> str:
return guess_intent_for_debounce(self, msg)
@staticmethod
def _looks_like_requirement_text(msg: str) -> bool:
return looks_like_requirement_text(msg)
def _pick_debounce_seconds(self, data: dict, msg: str) -> float:
return pick_debounce_seconds(self, data, msg)
def _msg_has_image_url(self, msg: str) -> bool:
return msg_has_image_url(msg)
def _msg_refers_images(self, msg: str) -> bool:
return msg_refers_images(msg)
def _extract_image_urls(self, msg: str) -> list:
return extract_image_urls(msg)
def _collect_recent_image_urls(self, customer_id: str, acc_id: str, max_count: int = 6) -> list:
return collect_recent_image_urls(self, customer_id, acc_id, max_count=max_count)
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:
return msg_is_price_inquiry(msg)
def _detect_order_status(self, msg: str) -> str:
return detect_order_status(msg)
async def _analyze_single_and_reply(self, data: dict, url: str):
await handle_single_image_quote(self, data, url)
async def agent_reply(self, data: dict):
"""使用 Agent 处理消息并回复"""
await handle_agent_reply_flow(
self,
data,
workflow=workflow,
shop_type_resolver=_get_shop_type,
)
def _cancel_auto_quote_task(self, key: str, reason: str = ""):
cancel_auto_quote_task(self, key, reason=reason)
@staticmethod
def _build_auto_quote_signature(state: Any) -> str:
return build_auto_quote_signature(state)
async def _maybe_schedule_auto_quote(self, data: dict):
await schedule_auto_quote(self, data, shop_type_resolver=_get_shop_type)
async def _analyze_multi_and_reply(self, data: dict, urls: list):
await handle_multi_image_quote(self, data, urls)
def _msg_requests_external_contact(self, msg: str) -> bool:
return msg_requests_external_contact(msg)
@staticmethod
def _extract_size_pairs_m(msg: str) -> list[tuple[float, float]]:
return extract_size_pairs_m(msg)
def _oversize_reply_if_needed(self, msg: str) -> str:
return oversize_reply_if_needed(msg)
def _is_transfer_msg(self, data: dict) -> bool:
return is_transfer_msg(self, data)
def _pick_transfer_greeting(self) -> str:
return pick_transfer_greeting()
def _is_shop_card(self, data: dict) -> bool:
return is_shop_card(self, data)
def _extract_customer_text_from_shop_card_msg(self, msg: str) -> str:
return extract_customer_text_from_shop_card_msg(self, msg)
def _has_chat_history(self, customer_id: str, acc_id: str = "") -> bool:
return has_chat_history(customer_id, acc_id=acc_id)
def _load_system_inquiry_rules(self) -> Dict[str, Any]:
return load_system_inquiry_rules()
@staticmethod
def _normalize_kw_list(v: Any) -> List[str]:
return normalize_kw_list(v)
def _resolve_system_inquiry_policy(self, acc_id: str) -> Dict[str, Any]:
return resolve_system_inquiry_policy(self, acc_id)
def _match_system_inquiry(self, data: dict, policy: Dict[str, Any]) -> bool:
return match_system_inquiry(self, data, policy)
async def _handle_system_inquiry(self, data: dict) -> bool:
return await handle_system_inquiry(self, data)
def _should_ignore(self, data: dict) -> bool:
return should_ignore(self, data)
def get_msg_type_name(self, msg_type):
return get_msg_type_name(msg_type)
def _extract_and_save_customer_info(self, message: str, customer_id: str):
extract_and_save_customer_info_flow(self, message, customer_id, db)
def to_chinese(self, text):
return to_chinese_text(text)
async def handle_image_message(self, data: dict):
await handle_image_message_flow(self, data)
async def _dispatch_assign_once(self) -> Dict[str, Any]:
return await dispatch_assign_once_flow(self)
async def transfer_to_human(self, data: dict, transfer_msg: str = ""):
await transfer_to_human_flow(
self,
data,
transfer_msg=transfer_msg,
transfer_group_resolver=_get_transfer_group,
)
async def _save_conversation_summary(self, customer_id: str, buyer_msg: str, agent_reply: str):
await save_conversation_summary_flow(self, customer_id, buyer_msg, agent_reply)
async def _workflow_agent_notify(
self,
customer_id: str,
acc_id: str,
acc_type: str,
system_hint: str,
):
await workflow_agent_notify_flow(self, customer_id, acc_id, acc_type, system_hint)
async def _workflow_send(
self,
customer_id: str,
acc_id: str,
acc_type: str,
content: str,
msg_type: int = 0
):
await workflow_send_flow(self, customer_id, acc_id, acc_type, content, msg_type=msg_type)
async def send_reply(self, original_msg, reply_content):
await send_reply_flow(self, original_msg, reply_content)
async def _ai_generate_outbound_reply(self, original_msg: dict, reply_content: str) -> str:
return await ai_generate_outbound_reply(self, original_msg, reply_content)
def _colloquialize_outbound_reply(self, text: Any) -> Any:
return colloquialize_outbound_reply(text)
async def _ai_guard_outbound_reply(self, original_msg: dict, reply_content: str) -> tuple[bool, str, str]:
return await ai_guard_outbound_reply(self, original_msg, reply_content)
async def send_text(self, cy_id, acc_type, content):
await send_text_flow(self, cy_id, acc_type, content)
async def send_image(self, cy_id, acc_type, image_path):
await send_image_flow(self, cy_id, acc_type, image_path)
async def send_message(self, message):
await send_message_flow(self, message)
async def auto_reply(self, data):
"""自动回复示例(已弃用,使用 agent_reply 替代)"""
pass
async def command_handler(self):
await command_handler_flow(self)
def get_time(self):
"""获取当前时间字符串"""
return datetime.now().strftime("%H:%M:%S")
async def run(self):
await run_client_flow(self)
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:
logger.info("\n已停止")

View File

@@ -1,45 +0,0 @@
import re
from datetime import datetime
def extract_and_save_customer_info_flow(client, message: str, customer_id: str, db):
"""从消息中提取客户信息并保存。"""
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)

View File

@@ -1,265 +0,0 @@
import asyncio
import logging
import re
import secrets
logger = logging.getLogger("cs_agent")
async def debounce_agent_reply(client, data: dict):
"""
消息防抖:同一客户在 _DEBOUNCE_SECONDS 内的连续消息合并后再处理。
订单通知、付款相关消息不走防抖,立即处理。
"""
msg_body = data.get("msg", "")
key = f"{data.get('acc_id','')}:{data.get('from_id','')}"
client._cancel_auto_quote_task(key, reason="new_inbound")
# 以下情况跳过防抖,立即处理(后台执行,不阻塞接收循环)
immediate_keywords = ["买家已付款", "已付款", "[系统订单信息]"]
if any(kw in msg_body for kw in immediate_keywords):
client._activity_log(
"debounce_bypass_immediate",
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
reason="payment_or_order",
msg=msg_body,
)
client._fire_and_forget(client._agent_reply_serialized(data))
return
# 积攒消息
if key not in client._pending_msgs:
client._pending_msgs[key] = []
client._pending_msgs[key].append(msg_body)
client._activity_log(
"debounce_enqueue",
key=key,
queue_size=len(client._pending_msgs[key]),
msg=msg_body,
)
# 取消上一个等待任务(如果有)
old_task = client._debounce_tasks.get(key)
if old_task and not old_task.done():
old_task.cancel()
debounce_seconds = pick_debounce_seconds(client, data, msg_body)
# 创建新的延迟处理任务
async def _delayed(capture_key, capture_data, wait_s: float):
await asyncio.sleep(wait_s)
msgs = client._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())
logger.info(f"[{client.get_time()}] 防抖合并 {len(msgs)} 条消息: {merged_msg[:60]}")
client._activity_log(
"debounce_flush",
key=capture_key,
merged_count=len(msgs),
merged_msg=merged_msg,
)
merged_data = dict(capture_data)
merged_data["msg"] = merged_msg
await client._agent_reply_serialized(merged_data)
task = asyncio.create_task(_delayed(key, data, debounce_seconds))
client._debounce_tasks[key] = task
def rand_between(low: float, high: float) -> float:
if high <= low:
return float(low)
# 使用 secrets 增强随机性,避免固定周期导致机械感
span = high - low
return round(low + span * (secrets.randbelow(1000) / 1000.0), 2)
def guess_intent_for_debounce(client, msg: str) -> str:
text = (msg or "").strip()
if not text:
return "unknown"
if msg_has_image_url(text):
return "image"
try:
from utils.intent_analyzer import detect_intent
decision = detect_intent(text)
intent = decision.intent
if intent:
client._activity_log(
"debounce_intent_detected",
intent=intent,
source=decision.source,
score=round(float(decision.score or 0.0), 4),
msg=text[:120],
)
except Exception:
intent = ""
if intent:
return intent
lower = text.lower()
if any(k in lower for k in ["报价", "多少钱", "价格", "", "优惠", "收费", "怎么收费", "咋收费"]):
return "询价"
if any(k in lower for k in ["做一下", "改一下", "需求", "门头", "上面的字", "处理"]):
return "修改"
if any(k in lower for k in ["在吗", "你好", "有人"]):
return "打招呼"
return "unknown"
def looks_like_requirement_text(msg: str) -> bool:
text = (msg or "").strip().lower()
if not text:
return False
req_kw = (
"做一下",
"改一下",
"处理一下",
"这个字",
"上面的字",
"门头",
"去背景",
"抠图",
"换色",
"调色",
"清晰",
"高清",
"尺寸",
"比例",
"横版",
"竖版",
"排版",
"改字",
"按这个做",
"照这个做",
"就这张",
"看看做",
"弄一下",
)
return any(k in text for k in req_kw)
def pick_debounce_seconds(client, data: dict, msg: str) -> float:
"""意图驱动防抖:不同意图不同等待区间,并引入轻微随机。"""
base = max(1.0, float(client._DEBOUNCE_SECONDS))
if not client._adaptive_debounce_enabled:
return base
intent = guess_intent_for_debounce(client, msg)
is_req = looks_like_requirement_text(msg)
has_img = msg_has_image_url(msg)
# 区间策略:越明确、越短消息,等待越短;需求描述类稍长
if intent == "打招呼":
low, high = 1.0, min(3.0, base)
elif intent in ("询价", "砍价"):
# 询价先略等一会,给客户补发图片/需求的窗口,减少机械两连回
low, high = 4.0, min(7.0, max(base, 7.0))
elif intent == "image":
# 文本里直接贴图链接:短等合并上下文,避免和上一条询价并发
low, high = 2.2, 4.2
elif intent in ("修改", "批量"):
low, high = max(3.0, base * 0.65), min(18.0, base + 2.0)
elif intent == "转接":
low, high = 1.0, 2.5
else:
low, high = max(2.0, base * 0.5), base
# 发图后的需求描述,优先“多等一点”收集完整需求,减少半句回复
# 约束到 12-14s避免等待过长。
if is_req and not has_img:
low = max(low, 12.0)
high = min(14.0, max(high, 12.6))
# 短句更快,长句稍慢,避免把连续半句拆开
text_len = len((msg or "").strip())
if text_len <= 4:
high = min(high, max(low + 0.2, 2.5))
elif text_len >= 18:
low = min(high, low + 0.6)
wait_s = rand_between(low, high)
logger.info(f"防抖等待 {wait_s}s | intent={intent} | len={text_len}")
return wait_s
def msg_has_image_url(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(msg: str) -> bool:
"""判断文本是否指代之前的图片(图一/图二/这张/那张/上面那张等)"""
if not msg:
return False
refs = (
"图一",
"图二",
"第一张",
"第二张",
"这张",
"那张",
"这图",
"那个图",
"这个",
"这个呢",
"上面那张",
"下面那张",
"刚才那张",
"上一张",
"下一张",
)
return any(r in msg for r in refs)
def extract_image_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):
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(client, 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 item in reversed(recent):
if item.get("direction") != "in":
continue
message = item.get("message") or ""
found = extract_image_urls(message)
for u in found:
if u not in seen:
seen.add(u)
urls.append(u)
if len(urls) >= max_count:
return urls
except Exception:
logger.debug("收集近期图片URL失败", exc_info=True)
return urls

View File

@@ -1,36 +0,0 @@
import os
async def dispatch_assign_once_flow(client):
"""
调用新的一键派单接口:
GET {DISPATCH_BASE_URL}/assign
Header: X-API-Key
"""
base_url = os.getenv("DISPATCH_BASE_URL", "http://1.12.50.92:8006").strip().rstrip("/")
api_key = os.getenv("DISPATCH_API_KEY", "tuhui_dispatch_key_2026").strip()
timeout_s = float(os.getenv("DISPATCH_TIMEOUT_SECONDS", "5"))
if not base_url or not api_key:
return {"success": False, "reason": "dispatch config missing"}
try:
import httpx
async with httpx.AsyncClient(timeout=timeout_s) as http_client:
resp = await http_client.get(
f"{base_url}/assign",
headers={"X-API-Key": api_key},
)
if resp.status_code != 200:
return {"success": False, "reason": f"http {resp.status_code}"}
data = resp.json() if resp.content else {}
ok = bool((data or {}).get("success", False))
return {
"success": ok,
"task_id": str((data or {}).get("task_id", "") or ""),
"assigned_to": str((data or {}).get("assigned_to", "") or ""),
"online_count": int((data or {}).get("online_count", 0) or 0),
"notification_sent": bool((data or {}).get("notification_sent", False)),
"raw": data,
}
except Exception as e:
return {"success": False, "reason": str(e)}

View File

@@ -1,181 +0,0 @@
import asyncio
import os
import time
from datetime import datetime, timedelta
import logging
logger = logging.getLogger("cs_agent")
async def unreplied_followup_loop(client):
"""定时补偿:对“最后一条是客户消息且长时间未回复”的会话,补发一次自然跟进。"""
if not client.enable_agent or not client.agent:
return
while client.running:
try:
await asyncio.sleep(max(30, int(os.getenv("UNREPLIED_FOLLOWUP_SCAN_SECONDS", "90"))))
await scan_and_send_unreplied_followups(client)
except asyncio.CancelledError:
break
except Exception as e:
client._activity_log("unreplied_followup_loop_error", error=str(e))
async def scan_and_send_unreplied_followups(client):
from db import chat_log_db as cdb
try:
idle_minutes = max(5, int(os.getenv("UNREPLIED_FOLLOWUP_IDLE_MINUTES", "12")))
max_age_minutes = max(idle_minutes, int(os.getenv("UNREPLIED_FOLLOWUP_MAX_AGE_MINUTES", "180")))
followup_cd = max(300, int(os.getenv("UNREPLIED_FOLLOWUP_COOLDOWN_SECONDS", "3600")))
limit = max(10, int(os.getenv("UNREPLIED_FOLLOWUP_LIMIT", "40")))
except Exception:
idle_minutes, max_age_minutes, followup_cd, limit = 12, 180, 3600, 40
now = datetime.now()
window_start = (now - timedelta(minutes=max_age_minutes)).strftime("%Y-%m-%d %H:%M:%S")
conn = None
try:
conn = cdb._get_conn()
rows = conn.execute(
cdb._sql(
"""
SELECT acc_id, customer_id, MAX(id) AS last_id
FROM chat_logs
WHERE timestamp >= ?
GROUP BY acc_id, customer_id
ORDER BY MAX(id) DESC
LIMIT ?
"""
),
(window_start, limit * 6),
).fetchall()
sessions = [dict(r) for r in rows]
sent = 0
for s in sessions:
if sent >= limit:
break
acc_id = str(s.get("acc_id", "") or "")
cid = str(s.get("customer_id", "") or "")
if not acc_id or not cid:
continue
ckey = f"{acc_id}:{cid}"
if not client._is_owned_by_this_worker(ckey):
continue
last = conn.execute(
cdb._sql(
"""
SELECT id, direction, message, timestamp, customer_name, acc_id, platform
FROM chat_logs
WHERE acc_id = ? AND customer_id = ?
ORDER BY id DESC
LIMIT 1
"""
),
(acc_id, cid),
).fetchone()
if not last:
continue
last = dict(last)
if str(last.get("direction", "")) != "in":
continue
last_ts = last.get("timestamp")
if isinstance(last_ts, datetime):
last_dt = last_ts
else:
last_dt = datetime.strptime(str(last_ts)[:19], "%Y-%m-%d %H:%M:%S")
idle_s = (now - last_dt).total_seconds()
if idle_s < idle_minutes * 60 or idle_s > max_age_minutes * 60:
continue
now_mono = time.monotonic()
if (now_mono - client._unreplied_followup_sent.get(ckey, 0.0)) < followup_cd:
continue
last_msg = str(last.get("message", "") or "").strip().lower()
if last_msg in {"好的", "", "ok", "收到", "", ""}:
continue
followup = await compose_ai_scene_reply(
client,
original_msg={
"acc_id": acc_id,
"from_id": cid,
"from_name": client.to_chinese(last.get("customer_name", "") or cid),
"acc_type": str(last.get("platform", "") or "AliWorkbench"),
"msg": str(last.get("message", "") or ""),
},
scene="unreplied_followup",
intent_hint="客户上一条消息还没接上,先自然承接并请对方补一句当前要处理的图或要求。",
fallback="刚看到你消息了,我在的。你把要处理的图或要求再发我一下,我马上接着看。",
)
fake = {
"acc_id": acc_id,
"from_id": cid,
"from_name": client.to_chinese(last.get("customer_name", "") or cid),
"cy_id": cid,
"cy_name": client.to_chinese(last.get("customer_name", "") or cid),
"acc_type": str(last.get("platform", "") or "AliWorkbench"),
"msg": str(last.get("message", "") or ""),
"msg_type": 0,
}
await client.send_reply(fake, followup)
client._unreplied_followup_sent[ckey] = now_mono
sent += 1
client._activity_log(
"unreplied_followup_sent",
acc_id=acc_id,
customer_id=cid,
idle_seconds=int(idle_s),
last_msg=str(last.get("message", "") or "")[:120],
reply=followup,
)
finally:
try:
if conn:
conn.close()
except Exception:
logger.debug("关闭数据库连接失败", exc_info=True)
async def compose_ai_scene_reply(client, *, original_msg: dict, scene: str, intent_hint: str, fallback: str) -> str:
"""场景化 AI 直接生成回复(不依赖固定模板)。"""
if not client.enable_agent or not client.agent or not client.AgentDeps:
return fallback
try:
deps = client.AgentDeps(
msg_id=str(original_msg.get("msg_id", "") or f"{scene}_gen"),
acc_id=str(original_msg.get("acc_id", "") or ""),
from_id=str(original_msg.get("from_id", "") or ""),
platform=str(original_msg.get("acc_type", "") or ""),
)
customer_msg = client.to_chinese(str(original_msg.get("msg", "") or ""))
prompt = (
"你是淘宝客服,直接生成一条发给客户的话。\n"
f"场景: {scene}\n"
f"意图: {intent_hint}\n"
f"客户原话: {customer_msg}\n"
"要求: 1-2句自然口语不要模板腔不要新增价格/承诺;只输出最终回复。\n"
)
result = await client.agent.agent_natural_reply.run(prompt, deps=deps, message_history=[])
out = str(getattr(result, "output", "") or "").strip()
if not out:
return fallback
if out.startswith("话术|") or "[转移会话]" in out or "TRANSFER_REQUESTED" in out:
return fallback
client._activity_log(
"ai_scene_reply_generated",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
scene=scene,
generated=out[:160],
)
return out
except Exception as e:
client._activity_log(
"ai_scene_reply_error",
acc_id=str(original_msg.get("acc_id", "") or ""),
customer_id=str(original_msg.get("from_id", "") or ""),
scene=scene,
error=str(e),
)
return fallback

View File

@@ -1,128 +0,0 @@
import asyncio
import time
from datetime import datetime
def fire_and_forget(client, coro):
"""后台执行协程,不阻塞接收循环;异常会记录到日志。"""
task = asyncio.create_task(coro)
def _done(t):
if t.cancelled():
return
exc = t.exception()
if exc:
client.logger.exception(f"后台任务异常: {exc}")
task.add_done_callback(_done)
def prune_seen(seen: dict, now_mono: float, ttl_sec: float = 8.0):
if len(seen) <= 2000:
return
stale = [k for k, t in seen.items() if (now_mono - t) > ttl_sec]
for k in stale:
seen.pop(k, None)
def log_inbound_once(client, data: dict, chat_log_fn):
"""统一记录入站消息,短窗口去重,避免多分支重复写库。"""
try:
cid = data.get("from_id", "")
if not cid:
return
msg = client.to_chinese(data.get("msg", "") or "")
acc_id = data.get("acc_id", "")
mtype = int(data.get("msg_type", 0) or 0)
now_mono = time.monotonic()
sig = f"{acc_id}|{cid}|{mtype}|{msg}"
last = client._inbound_log_seen.get(sig, 0.0)
if (now_mono - last) < 2.0:
return
client._inbound_log_seen[sig] = now_mono
prune_seen(client._inbound_log_seen, now_mono, ttl_sec=8.0)
chat_log_fn(
cid,
msg,
"in",
customer_name=client.to_chinese(data.get("from_name", "") or data.get("cy_name", "")),
acc_id=acc_id,
platform=data.get("acc_type", ""),
msg_type=mtype,
)
except Exception:
client.logger.debug("入站消息写库失败", exc_info=True)
def log_outbound_once(client, original_msg: dict, reply_content: str, chat_log_fn):
"""统一记录出站消息,短窗口去重,避免重复写库。"""
try:
cid = original_msg.get("from_id", "")
if not cid:
return
msg = reply_content or ""
acc_id = original_msg.get("acc_id", "")
now_mono = time.monotonic()
sig = f"{acc_id}|{cid}|0|{msg}"
last = client._outbound_log_seen.get(sig, 0.0)
if (now_mono - last) < 2.0:
return
client._outbound_log_seen[sig] = now_mono
prune_seen(client._outbound_log_seen, now_mono, ttl_sec=8.0)
chat_log_fn(
cid,
msg,
"out",
customer_name=client.to_chinese(original_msg.get("from_name", "") or original_msg.get("cy_name", "")),
acc_id=acc_id,
platform=original_msg.get("acc_type", ""),
msg_type=0,
)
except Exception:
client.logger.debug("出站消息写库失败", exc_info=True)
def build_customer_message(client, data: dict, customer_message_cls):
"""把原始消息字典转换为 Agent 输入模型。"""
return customer_message_cls(
msg_id=data.get("msg_id", ""),
acc_id=data.get("acc_id", ""),
msg=client.to_chinese(data.get("msg", "")),
from_id=data.get("from_id", ""),
from_name=client.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=client.to_chinese(data.get("cy_name", "")),
goods_name=client.to_chinese(data.get("goods_name", "")) if data.get("goods_name") else None,
goods_order=client.to_chinese(data.get("goods_order", "")) if data.get("goods_order") else None,
)
def touch_customer_last_contact(client, customer_id: str, db):
"""兜底更新客户最后联系时间。"""
if not customer_id:
return
try:
profile = db.get_customer(customer_id)
profile.last_contact = datetime.now().isoformat()
db.save_customer(profile)
except Exception:
client.logger.debug("更新客户最后联系时间失败: customer_id=%s", customer_id, exc_info=True)
def push_chat_to_wechat_safe(client, *, data: dict, customer_msg: str, reply_msg: str, tag: str, goods_name: str = ""):
"""异步推送企微聊天日志,失败不影响主流程。"""
try:
from utils.wechat_chat_log import push_chat_to_wechat
asyncio.create_task(push_chat_to_wechat(
customer_name=client.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=client.to_chinese(customer_msg or ""),
reply_msg=reply_msg or "",
goods_name=goods_name or client.to_chinese(data.get("goods_name", "") or ""),
))
except Exception:
client.logger.debug("推送企微聊天日志失败(%s)", tag, exc_info=True)

View File

@@ -1,11 +0,0 @@
async def handle_image_message_flow(client, data: dict):
"""
处理图片消息。
先回复"我找找"然后把图片URL作为消息内容交给 Agent后台执行
"""
await client.send_reply(data, "我找找")
image_data = dict(data)
image_data["msg"] = f"[客户发来图片] {data.get('msg', '')}"
image_data["msg_type"] = 0
client._fire_and_forget(client._agent_reply_serialized(image_data))

View File

@@ -1,123 +0,0 @@
import json
import logging
logger = logging.getLogger("cs_agent")
async def handle_incoming_message(client, message: str, *, shop_type_resolver):
"""处理单条入站消息(从 websocket_client.py 拆出)。"""
timestamp = client.get_time()
try:
data = json.loads(message)
# 多进程分片检查:确保同一客户只由一个 worker 处理
customer_key = client._customer_key(data)
if not client._is_owned_by_this_worker(customer_key):
return
timestamp = client.get_time()
# 保存最后一条消息用于回复
client.last_msg = data
# 打印格式化的消息
logger.info(f"\n{'='*50}")
logger.info(f"[{timestamp}] 收到新消息:")
logger.info(f"{'='*50}")
logger.info(f" 消息ID: {data.get('msg_id', 'N/A')}")
logger.info(f" 账号ID: {client.to_chinese(data.get('acc_id', 'N/A'))}")
logger.info(f" 发送者ID: {client.to_chinese(data.get('from_id', 'N/A'))}")
logger.info(f" 发送者名称: {client.to_chinese(data.get('from_name', 'N/A'))}")
logger.info(f" 会话ID: {client.to_chinese(data.get('cy_id', 'N/A'))}")
logger.info(f" 平台类型: {data.get('acc_type', 'N/A')}")
logger.info(f" 消息类型: {client.get_msg_type_name(data.get('msg_type', 0))}")
logger.info(f" 消息内容: {client.to_chinese(data.get('msg', 'N/A'))}")
# 显示商品信息(如果有)
if data.get('goods_name'):
logger.info(f" 商品名称: {client.to_chinese(data.get('goods_name', ''))}")
if data.get('goods_order'):
logger.info(f" 订单信息: {client.to_chinese(data.get('goods_order', ''))}")
logger.info(f"{'='*50}\n")
# 消息去重:同一条消息不重复处理
msg_id = data.get('msg_id', '')
if msg_id and msg_id in client._replied_msg_ids:
logger.info(f"重复消息,跳过: {msg_id}")
return
if msg_id:
client._replied_msg_ids.append(msg_id) # deque 自动淘汰最旧的
# 空消息/无效消息过滤N/A 或关键字段全为空)
from_id = data.get('from_id', '')
acc_id = data.get('acc_id', '')
if not from_id or from_id == 'N/A' or not acc_id or acc_id == 'N/A':
logger.info(f"[{client.get_time()}] 空消息跳过from_id={from_id!r} acc_id={acc_id!r}")
return
client._log_inbound_once(data)
client._fire_and_forget(client._post_tianwang_callback("message_received", data))
# Gemini 店铺:不回复,直接跳过
goods_name = client.to_chinese(data.get('goods_name', '') or '')
if shop_type_resolver(acc_id, goods_name) == "gemini_api":
logger.info(f"[{client.get_time()}] Gemini 店铺消息,跳过")
client._push_chat_to_wechat_safe(
data=data,
customer_msg=data.get('msg', ''),
reply_msg="",
goods_name=goods_name,
tag="gemini店铺跳过",
)
return
# 使用 Agent 自动回复(仅处理文本消息)
if client.enable_agent:
msg_type = data.get('msg_type', 0)
if msg_type == 0:
if client._is_transfer_msg(data):
# 会话转交 → 主动打招呼
logger.info(f"[{client.get_time()}] 收到转交消息,发送问候")
greeting = client._pick_transfer_greeting()
await client.send_reply(data, greeting)
client._push_chat_to_wechat_safe(
data=data,
customer_msg=data.get('msg', ''),
reply_msg=greeting,
tag="转交问候",
)
elif client._is_shop_card(data):
# 进店卡片有历史对话就不回复没有才打招呼Gemini 已在上面统一跳过)
cid = data.get('from_id', '')
acc_id = data.get('acc_id', '')
residual_text = client._extract_customer_text_from_shop_card_msg(data.get('msg', ''))
if residual_text:
logger.info(f"[{client.get_time()}] 进店卡片携带客户文本,转普通消息处理: {residual_text}")
patched = dict(data)
patched['msg'] = residual_text
await client._debounce_agent_reply(patched)
elif client._has_chat_history(cid, acc_id=acc_id):
logger.info(f"[{client.get_time()}] 进店卡片(已有记录),跳过")
else:
logger.info(f"[{client.get_time()}] 进店卡片(新客户),发送问候")
greeting = "在呢,发图来我看看"
await client.send_reply(data, greeting)
client._push_chat_to_wechat_safe(
data=data,
customer_msg=data.get('msg', ''),
reply_msg=greeting,
goods_name=goods_name,
tag="进店卡片问候",
)
elif await client._handle_system_inquiry(data):
logger.info(f"[{client.get_time()}] 系统客服询单消息,已按规则处理")
elif client._should_ignore(data):
logger.info(f"[{client.get_time()}] 系统通知,跳过回复")
else:
await client._debounce_agent_reply(data)
elif msg_type == 1:
# 图片消息直接处理,不走防抖(图片不会连续多发)
await client.handle_image_message(data)
except json.JSONDecodeError:
logger.info(f"[{timestamp}] 收到非JSON消息: {message}")

View File

@@ -1,98 +0,0 @@
import json
import random
import re
def to_chinese_text(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
def is_transfer_msg(client, data: dict) -> bool:
msg = to_chinese_text(data.get("msg", ""))
return "转交给" in msg or "转接给" in msg
def pick_transfer_greeting() -> str:
choices = [
"在的亲,发图我看下",
"在呢亲,有需求直接说",
"我在的,您把要求发我",
"在的哈,你说我这边看着处理",
"在呢,图和需求发来我看看",
]
return random.choice(choices)
def is_shop_card(client, data: dict) -> bool:
msg = to_chinese_text(data.get("msg", ""))
return msg.startswith("[进店卡片]") or "我想咨询你们店的这个商品" in msg
def extract_customer_text_from_shop_card_msg(client, msg: str) -> str:
text = to_chinese_text(msg or "").strip()
if not text:
return ""
parts = [p.strip() for p in text.split("#*#") if p and p.strip()]
kept = []
for part in parts:
if part.startswith("[进店卡片]") or "我想咨询你们店的这个商品" in part:
continue
kept.append(part)
if kept:
return " ".join(kept).strip()
stripped = re.sub(r"\[进店卡片\][^\n\r]*", "", text).strip()
stripped = stripped.replace("我想咨询你们店的这个商品", "").strip(",。,#* ")
return stripped
def has_chat_history(customer_id: str, acc_id: str = "") -> bool:
if not customer_id:
return False
try:
from db.chat_log_db import get_recent_conversation
msgs = get_recent_conversation(customer_id, acc_id=acc_id, limit=1)
return len(msgs) > 0
except Exception:
return False
def should_ignore(client, data: dict) -> bool:
msg = to_chinese_text(data.get("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(msg_type):
types = {
0: "文本",
1: "图片",
2: "视频",
3: "文件",
}
return types.get(msg_type, f"未知({msg_type})")

View File

@@ -1,84 +0,0 @@
import re
from typing import Any
def msg_is_price_inquiry(msg: str) -> bool:
if not msg:
return False
patterns = ("多少钱", "多少一张", "一张多少钱", "画图多少", "报价", "给个价", "几块", "多少钱")
return any(p in msg for p in patterns)
def detect_order_status(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 ""
def msg_requests_external_contact(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 extract_size_pairs_m(msg: str) -> list[tuple[float, float]]:
"""提取消息中的米制尺寸对,如 15*6.4米 / 15米*6.4 / 15x6.4m。"""
if not msg:
return []
s = (msg or "").lower().replace("×", "*").replace("x", "*")
pairs = []
patterns = [
r"(\d+(?:\.\d+)?)\s*\*\s*(\d+(?:\.\d+)?)\s*(?:米|m)\b",
r"(\d+(?:\.\d+)?)\s*(?:米|m)\s*\*\s*(\d+(?:\.\d+)?)\b",
]
for p in patterns:
for m in re.findall(p, s):
try:
a = float(m[0])
b = float(m[1])
if a > 0 and b > 0:
pairs.append((a, b))
except Exception:
continue
return pairs
def oversize_reply_if_needed(msg: str) -> str:
"""
检测超大尺寸需求并返回拒绝话术;未命中返回空字符串。
规则:最长边 > 阈值 或 面积 > 阈值。
"""
try:
from config.config import MAX_SERVICE_SIZE_LONGEST_METERS, MAX_SERVICE_SIZE_AREA_SQM
longest_limit = float(MAX_SERVICE_SIZE_LONGEST_METERS)
area_limit = float(MAX_SERVICE_SIZE_AREA_SQM)
except Exception:
longest_limit = 10.0
area_limit = 20.0
pairs = extract_size_pairs_m(msg)
for w, h in pairs:
longest = max(w, h)
area = w * h
if longest > longest_limit or area > area_limit:
return (
f"{w:g}米*{h:g}米这个尺寸太大了,我们这边做不了。"
"如果要做可以拆成几段小尺寸,我再给你按段评估。"
)
return ""
def build_auto_quote_signature(state: Any) -> str:
from core.websocket_auto_quote_flow import build_auto_quote_signature as _build
return _build(state)

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