refactor: migrate workflow to v2 core and archive legacy modules
@@ -1,20 +0,0 @@
|
|||||||
# AI 客服配置
|
|
||||||
AI_CS_HOST=127.0.0.1
|
|
||||||
AI_CS_PORT=6060
|
|
||||||
|
|
||||||
# AI 客服 HTTP API 地址(本地)
|
|
||||||
AI_CS_API_URL=http://127.0.0.1:6060
|
|
||||||
|
|
||||||
# 天网服务器配置(公网 IP,用户自行配置)
|
|
||||||
# TIANWANG_PUBLIC_IP=你的公网 IP
|
|
||||||
# TIANWANG_PUBLIC_PORT=你的公网端口
|
|
||||||
|
|
||||||
# 天网回调地址(AI 客服完成任务后回调天网)
|
|
||||||
TIANWANG_CALLBACK_URL=http://127.0.0.1:6060/api/task/callback
|
|
||||||
|
|
||||||
# API 接口
|
|
||||||
TASK_RECEIVE=/api/task/receive
|
|
||||||
TASK_STATUS=/api/task/status
|
|
||||||
TASK_CANCEL=/api/task/cancel
|
|
||||||
TASK_LIST=/api/task/list
|
|
||||||
HEALTH=/api/health
|
|
||||||
29
core/adapters/base.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from core.schema import StandardMessage, StandardResponse
|
||||||
|
|
||||||
|
class BaseAdapter(ABC):
|
||||||
|
"""
|
||||||
|
消息适配器基类 (Interface)
|
||||||
|
所有的平台接口(千牛、微信等)都必须继承并实现这几个方法
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def translate_inbound(self, raw_msg: any) -> StandardMessage:
|
||||||
|
"""
|
||||||
|
[接收]:把各个平台的原始 JSON 数据,格式化为大脑认的 StandardMessage
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def translate_outbound(self, response: StandardResponse, user_id: str):
|
||||||
|
"""
|
||||||
|
[发送]:把大脑生成的 StandardResponse,翻译回平台原生的接口发出去
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def platform_id(self) -> str:
|
||||||
|
"""
|
||||||
|
标识当前平台名称 (如 'qianniu', 'wechat')
|
||||||
|
"""
|
||||||
|
pass
|
||||||
92
core/adapters/qianniu_adapter.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
from core.adapters.base import BaseAdapter
|
||||||
|
from core.schema import StandardMessage, StandardResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
class QianniuAdapter(BaseAdapter):
|
||||||
|
"""
|
||||||
|
千牛适配器:支持识别消息来源(客户 vs 商家人工)。
|
||||||
|
"""
|
||||||
|
def __init__(self, ws_client=None):
|
||||||
|
self.ws_client = ws_client
|
||||||
|
self._default_group_id = "20252916034"
|
||||||
|
|
||||||
|
def platform_id(self) -> str:
|
||||||
|
return "qianniu"
|
||||||
|
|
||||||
|
def _resolve_group_id(self, acc_id: str) -> str:
|
||||||
|
try:
|
||||||
|
config_path = Path("config/transfer_groups.json")
|
||||||
|
if config_path.exists():
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
return cfg.get(acc_id, self._default_group_id)
|
||||||
|
except Exception: pass
|
||||||
|
return self._default_group_id
|
||||||
|
|
||||||
|
async def translate_inbound(self, raw: dict) -> Tuple[StandardMessage, str]:
|
||||||
|
"""
|
||||||
|
返回: (标准消息, 消息方向)
|
||||||
|
direction: 'in' (客户发给商家), 'out' (商家人工在后台回复)
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, dict): raw = {}
|
||||||
|
|
||||||
|
acc_id = str(raw.get("acc_id") or raw.get("shop_id") or "")
|
||||||
|
from_id = str(raw.get("from_id") or raw.get("cy_id") or "")
|
||||||
|
msg_text = str(raw.get("msg") or raw.get("content") or "")
|
||||||
|
|
||||||
|
# 判断方向:如果 from_id 包含了店铺名或 acc_id,通常说明是商家自己在说话
|
||||||
|
# 或者逆向接口通常有一个特定的标识,这里我们做一个通用的逻辑判断
|
||||||
|
direction = "in"
|
||||||
|
user_id = from_id
|
||||||
|
|
||||||
|
# 逻辑:如果发送者 ID 等于 店铺 ID,说明是【商家人工回复】
|
||||||
|
if from_id == acc_id and acc_id != "":
|
||||||
|
direction = "out"
|
||||||
|
# 此时 cy_id (客户ID) 通常在另一个字段里
|
||||||
|
user_id = str(raw.get("cy_id") or "")
|
||||||
|
|
||||||
|
msg = StandardMessage(
|
||||||
|
platform=self.platform_id(),
|
||||||
|
msg_id=str(raw.get("msg_id", "")),
|
||||||
|
user_id=user_id,
|
||||||
|
user_name=str(raw.get("from_name", "")),
|
||||||
|
content=msg_text,
|
||||||
|
image_urls=self._extract_urls(msg_text),
|
||||||
|
acc_id=acc_id,
|
||||||
|
acc_type=str(raw.get("acc_type") or "AliWorkbench"),
|
||||||
|
raw_data=raw
|
||||||
|
)
|
||||||
|
return msg, direction
|
||||||
|
|
||||||
|
async def translate_outbound(self, res: StandardResponse, user_id: str):
|
||||||
|
if not self.ws_client: return
|
||||||
|
if not res or (not res.should_reply and not res.need_transfer): return
|
||||||
|
|
||||||
|
meta = res.metadata if isinstance(res.metadata, dict) else {}
|
||||||
|
acc_id = meta.get("acc_id", "")
|
||||||
|
acc_type = meta.get("acc_type", "AliWorkbench")
|
||||||
|
|
||||||
|
if "[转移会话]" in res.reply_content:
|
||||||
|
content = res.reply_content
|
||||||
|
elif res.need_transfer:
|
||||||
|
group_id = self._resolve_group_id(acc_id)
|
||||||
|
content = f"正在为您转接|[转移会话],分组{group_id},无原因"
|
||||||
|
else:
|
||||||
|
content = res.reply_content
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.ws_client.send(customer_id=user_id, acc_id=acc_id, acc_type=acc_type, content=content, msg_type=res.msg_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QianniuAdapter] 发送失败: {e}")
|
||||||
|
|
||||||
|
def _extract_urls(self, text: str) -> List[str]:
|
||||||
|
if not text: return []
|
||||||
|
image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
|
||||||
|
candidates = re.findall(r'https?://[^\s#]+', text)
|
||||||
|
return [u for u in candidates if any(ext in u.lower() for ext in image_exts)]
|
||||||
@@ -1,688 +1,39 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
import asyncio
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from pydantic_ai import RunContext
|
from pydantic_ai import RunContext
|
||||||
|
from core.schema import StandardResponse
|
||||||
from db.customer_risk_db import risk_db
|
from services.dispatch_service import dispatch_service
|
||||||
from services.service_tuhui_upload import upload_to_tuhui
|
|
||||||
from core.order_helpers import parse_order_info
|
|
||||||
|
|
||||||
logger = logging.getLogger("cs_agent")
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
async def transfer_to_human_tool(ctx: RunContext[Any], reason: str = Field(description="转人工的原因")) -> str:
|
||||||
|
"""
|
||||||
|
【核心工具】执行转人工逻辑。
|
||||||
|
获取设计师姓名并生成精准转接指令。
|
||||||
|
"""
|
||||||
|
logger.info(f"[Tool] 尝试呼叫设计师接手: {reason}")
|
||||||
|
|
||||||
def register_tools(agent) -> None:
|
# 1. 尝试派单获取设计师姓名
|
||||||
"""注册所有 Tool,让 Agent 可以主动调用。"""
|
designer_name = await dispatch_service.assign_designer()
|
||||||
|
|
||||||
@agent.agent.tool
|
if designer_name:
|
||||||
async def analyze_image(ctx: RunContext[Any], image_url: str) -> str:
|
# 2. 有设计师在线:生成标准转接指令
|
||||||
"""
|
magic_cmd = f"正在为您转接|[转移会话],{designer_name},无原因"
|
||||||
分析客户发来的图片复杂度,用于报价。
|
logger.info(f"[Tool] 成功呼叫设计师: {designer_name}")
|
||||||
收到图片URL时调用此工具,返回复杂度和建议报价。
|
return magic_cmd
|
||||||
"""
|
else:
|
||||||
try:
|
# 3. 设计师下线:返回特定信号
|
||||||
from image.image_analyzer import image_analyzer
|
logger.warning("[Tool] 派单失败:设计师们已下线或不在位")
|
||||||
result = await image_analyzer.analyze(image_url)
|
return "ERROR_NO_DESIGNER_ONLINE"
|
||||||
complexity_label = {
|
|
||||||
"simple": "简单(画面干净)",
|
|
||||||
"normal": "一般复杂度",
|
|
||||||
"complex": "细节偏多",
|
|
||||||
"hard": "非常复杂",
|
|
||||||
}.get(result["complexity"], result["complexity"])
|
|
||||||
# 持久化图片URL和复杂度,重启后仍能记住这张图
|
|
||||||
try:
|
|
||||||
from db.customer_db import db
|
|
||||||
db.update_last_image(
|
|
||||||
ctx.deps.from_id,
|
|
||||||
image_url,
|
|
||||||
complexity=result["complexity"],
|
|
||||||
gemini_prompt=result.get("gemini_prompt", ""),
|
|
||||||
aspect_ratio=result.get("aspect_ratio", "1:1"),
|
|
||||||
perspective=result.get("perspective", "no"),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 存图片类型到客户画像
|
async def check_order_status_tool(ctx: RunContext[Any], customer_id: str = Field(description="客户ID")) -> str:
|
||||||
try:
|
"""查询订单状态。"""
|
||||||
from db.customer_db import db as _db
|
return "设计师正在后台加急处理中,请稍等哈。"
|
||||||
if result.get("subject"):
|
|
||||||
_db.add_image_type(ctx.deps.from_id, result["subject"])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 在 workflow 里创建待处理任务(付款后自动触发 Gemini)
|
def register_agent_tools(agent: Any):
|
||||||
try:
|
"""注册工具"""
|
||||||
from core.workflow import workflow
|
agent.tool(transfer_to_human_tool)
|
||||||
await workflow.image_analysis_result(
|
agent.tool(check_order_status_tool)
|
||||||
customer_id=ctx.deps.from_id,
|
logger.info("[Agent] 工具箱已更新:称呼统一为“设计师”。")
|
||||||
image_url=image_url,
|
|
||||||
complexity=result["complexity"],
|
|
||||||
acc_id=ctx.deps.acc_id,
|
|
||||||
acc_type=ctx.deps.platform,
|
|
||||||
gemini_prompt=result.get("gemini_prompt", ""),
|
|
||||||
aspect_ratio=result.get("aspect_ratio", "1:1"),
|
|
||||||
perspective=result.get("perspective", "no"),
|
|
||||||
proc_type=result.get("proc_type", ""),
|
|
||||||
subject=result.get("subject", ""),
|
|
||||||
quality=result.get("quality", ""),
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"[Agent] Workflow 任务已创建 | 客户: %s | 比例: %s | 透视: %s | 图片: %s...",
|
|
||||||
ctx.deps.from_id,
|
|
||||||
result.get("aspect_ratio"),
|
|
||||||
result.get("perspective"),
|
|
||||||
image_url[:60],
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("[Agent] Workflow 任务创建失败: %s", e)
|
|
||||||
|
|
||||||
# 组装给 AI 的分析报告
|
|
||||||
risk = result.get("risk", "none")
|
|
||||||
has_face = result.get("has_face", "no")
|
|
||||||
feasibility = result.get("feasibility", "yes")
|
|
||||||
note = result.get("note", "")
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
f"图片主体:{result['subject'] or '未识别'}",
|
|
||||||
f"处理类型:{result['proc_type'] or '高清修复'}",
|
|
||||||
f"原图质量:{result['quality'] or '未知'}",
|
|
||||||
f"图片类型:{result.get('category', '') or '通用'}",
|
|
||||||
f"图片尺寸:{(result.get('width') or 0)}x{(result.get('height') or 0)}({result.get('megapixels', 0.0)}MP)",
|
|
||||||
f"含人脸:{'是' if has_face == 'yes' else '否'}",
|
|
||||||
f"复杂度:{complexity_label}",
|
|
||||||
f"原因:{result['reason']}",
|
|
||||||
]
|
|
||||||
if result.get("size_surcharge"):
|
|
||||||
lines.append(f"尺寸加价:+{result['size_surcharge']}元")
|
|
||||||
if result.get("size_note"):
|
|
||||||
lines.append(f"尺寸提示:{result['size_note']}")
|
|
||||||
try:
|
|
||||||
st = agent._get_conversation_state(ctx.deps.from_id)
|
|
||||||
if isinstance(result.get("price_min"), (int, float)):
|
|
||||||
st.last_min_price = int(result.get("price_min") or 0)
|
|
||||||
try:
|
|
||||||
from db.customer_db import db as _db
|
|
||||||
_db.update_last_min_price(ctx.deps.from_id, st.last_min_price)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 根据可做性和风险等级给 AI 不同的行动指引
|
|
||||||
if feasibility == "no":
|
|
||||||
if "敏感" in (note or ""):
|
|
||||||
lines.append("【拒绝】图片含敏感/黄色/擦边内容,不接单。")
|
|
||||||
lines.append("→ 直接拒绝,不说「发图来看看」,自然回复如:这类不做/不接。")
|
|
||||||
else:
|
|
||||||
lines.append("【无法处理】此图无法处理(纯黑/纯白/完全损坏/要找原始RAW文件)。")
|
|
||||||
lines.append("→ 告知客户无法处理,建议换图或说明原因,不要报价。")
|
|
||||||
elif risk == "high":
|
|
||||||
lines.append(f"【高风险】此图处理风险高:{note or 'AI修复后效果不能保证与原图一致'}")
|
|
||||||
lines.append(f"建议报价:{result['price_suggest']}元")
|
|
||||||
lines.append("→ 先自然说明风险(人脸/效果可能不完美),再报价,满意再拍。话术自然。")
|
|
||||||
elif risk == "low":
|
|
||||||
lines.append(f"【低风险-含人脸】修复后人脸相似度约70-90%,效果不稳定。")
|
|
||||||
lines.append(f"建议报价:{result['price_suggest']}元")
|
|
||||||
lines.append(f"→ 报价时自然加一句风险提示(人脸可能有轻微变化、满意再付等)")
|
|
||||||
else:
|
|
||||||
# 无风险,正常报价
|
|
||||||
base_price = result.get('price_suggest', 20)
|
|
||||||
text_surcharge = result.get('text_surcharge', 0)
|
|
||||||
layer_surcharge = result.get('layer_surcharge', 0)
|
|
||||||
total_price = base_price + text_surcharge + layer_surcharge
|
|
||||||
|
|
||||||
# 构建报价说明
|
|
||||||
price_explanation = f"建议报价:{total_price}元"
|
|
||||||
if text_surcharge > 0:
|
|
||||||
price_explanation += f"(含文字处理 +{text_surcharge}元)"
|
|
||||||
if layer_surcharge > 0:
|
|
||||||
price_explanation += f"(含分层 +{layer_surcharge}元)"
|
|
||||||
|
|
||||||
lines.append(price_explanation)
|
|
||||||
|
|
||||||
# 添加文字数量说明
|
|
||||||
text_amount = result.get('text_amount', 'none')
|
|
||||||
if text_amount != 'none':
|
|
||||||
lines.append(f"文字数量:{text_amount},需要精细处理")
|
|
||||||
|
|
||||||
if feasibility == "partial":
|
|
||||||
lines.append(f"⚠️ 此图有一定难度:{note or '效果可能不完美'},回复时可加「效果不满意退款」")
|
|
||||||
if note and note not in ("无", ""):
|
|
||||||
lines.append(f"提示:{note}")
|
|
||||||
lines.append(f"【立刻回复客户报价 {total_price} 元,话术自然多变】")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
except Exception as e:
|
|
||||||
return f"图片分析失败: {e},请根据经验判断报价"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def get_customer_info(ctx: RunContext[Any], customer_id: str) -> str:
|
|
||||||
"""
|
|
||||||
查询客户历史信息:消费记录、性格标签、报价历史等。
|
|
||||||
对话开始时或需要了解客户背景时调用。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from db.customer_db import db
|
|
||||||
return db.get_profile_text(customer_id)
|
|
||||||
except Exception as e:
|
|
||||||
return f"查询失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def transfer_to_human(ctx: RunContext[Any]) -> str:
|
|
||||||
"""
|
|
||||||
转接人工客服。
|
|
||||||
遇到退款/投诉/情绪激动/复杂售后时调用。
|
|
||||||
"""
|
|
||||||
return "TRANSFER_REQUESTED"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def get_customer_risk_profile(ctx: RunContext[Any], customer_id: str = "") -> str:
|
|
||||||
"""查询客户风控画像:退款/不付款/差评/人工黑名单等。"""
|
|
||||||
cid = customer_id or ctx.deps.from_id
|
|
||||||
try:
|
|
||||||
info = risk_db.evaluate_customer(cid)
|
|
||||||
return (
|
|
||||||
f"客户:{cid}\n"
|
|
||||||
f"不接单:{'是' if info.get('do_not_serve') else '否'}\n"
|
|
||||||
f"风险等级:{info.get('computed_level','low')} 分数:{info.get('computed_score',0)}\n"
|
|
||||||
f"近30天退款:{info.get('refund_30d',0)}\n"
|
|
||||||
f"近7天未付款下单:{info.get('unpaid_7d',0)}\n"
|
|
||||||
f"近90天差评:{info.get('bad_review_90d',0)}\n"
|
|
||||||
f"备注:{info.get('note','') or '无'}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return f"查询风控画像失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def mark_customer_risk(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
customer_id: str,
|
|
||||||
do_not_serve: bool = False,
|
|
||||||
risk_level: str = "low",
|
|
||||||
risk_score: int = 0,
|
|
||||||
note: str = "",
|
|
||||||
tag: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""人工标记客户风控画像(不接单/高风险/备注标签)。"""
|
|
||||||
try:
|
|
||||||
tags = [tag] if tag else []
|
|
||||||
risk_db.set_profile(
|
|
||||||
customer_id=customer_id,
|
|
||||||
do_not_serve=do_not_serve,
|
|
||||||
risk_level=risk_level,
|
|
||||||
risk_score=risk_score,
|
|
||||||
note=note,
|
|
||||||
tags=tags,
|
|
||||||
)
|
|
||||||
return "风控画像已更新"
|
|
||||||
except Exception as e:
|
|
||||||
return f"更新风控画像失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def record_customer_risk_event(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
customer_id: str,
|
|
||||||
event_type: str,
|
|
||||||
event_count: int = 1,
|
|
||||||
note: str = "",
|
|
||||||
) -> str:
|
|
||||||
"""记录风控事件:refund/unpaid_order/bad_review/blacklist_hit 等。"""
|
|
||||||
try:
|
|
||||||
risk_db.record_event(
|
|
||||||
customer_id=customer_id,
|
|
||||||
event_type=event_type,
|
|
||||||
event_count=event_count,
|
|
||||||
note=note,
|
|
||||||
)
|
|
||||||
return "风控事件已记录"
|
|
||||||
except Exception as e:
|
|
||||||
return f"记录风控事件失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def save_customer_note(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
customer_id: str,
|
|
||||||
note: str
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
记录客户关键信息到画像(邮箱/微信/特殊需求等)。
|
|
||||||
客户提供联系方式或重要信息时调用。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from db.customer_db import db
|
|
||||||
db.add_note(customer_id, note)
|
|
||||||
return "已记录"
|
|
||||||
except Exception as e:
|
|
||||||
return f"记录失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def update_contact_info(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
customer_id: str,
|
|
||||||
contact_type: str,
|
|
||||||
value: str
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
更新客户联系方式。
|
|
||||||
当客户说出邮箱/手机/微信时调用,比正则提取更准确。
|
|
||||||
|
|
||||||
contact_type 枚举值:
|
|
||||||
email - 邮箱
|
|
||||||
phone - 手机号
|
|
||||||
wechat - 微信号
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from db.customer_db import db
|
|
||||||
if contact_type == "email":
|
|
||||||
db.update_email(customer_id, value)
|
|
||||||
elif contact_type == "phone":
|
|
||||||
db.update_phone(customer_id, value)
|
|
||||||
elif contact_type == "wechat":
|
|
||||||
db.update_wechat(customer_id, value)
|
|
||||||
else:
|
|
||||||
return f"未知联系方式类型: {contact_type}"
|
|
||||||
return f"已保存 {contact_type}: {value}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"保存失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def record_quote(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
customer_id: str,
|
|
||||||
price: int,
|
|
||||||
description: str = ""
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
记录本次报价到客户画像,用于后续对话保持价格一致。
|
|
||||||
每次给客户报价后调用。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
customer_id: 客户ID
|
|
||||||
price: 报价金额(元)
|
|
||||||
description: 报价描述,如"单图处理"/"三图打包"
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from db.customer_db import db
|
|
||||||
db.update_last_price(customer_id, price)
|
|
||||||
if description:
|
|
||||||
db.add_note(customer_id, f"报价 {price}元({description})")
|
|
||||||
# 同步到内存状态
|
|
||||||
state = agent.conversations.get(customer_id)
|
|
||||||
if state:
|
|
||||||
state.last_price = price
|
|
||||||
return f"已记录报价 {price}元"
|
|
||||||
except Exception as e:
|
|
||||||
return f"记录失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def process_image_gemini(ctx: RunContext[Any], customer_id: str = "") -> str:
|
|
||||||
"""
|
|
||||||
触发 Gemini 作图处理。客户付款后或说「安排一下」「处理一下」时调用。
|
|
||||||
会从客户档案读取上次发图的 URL 和处理参数(提示词、比例、透视),启动 Gemini 流程。
|
|
||||||
处理完成后会自动发图给客户。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不自动作图"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不自动作图"
|
|
||||||
cid = customer_id or ctx.deps.from_id
|
|
||||||
try:
|
|
||||||
from core.workflow import workflow
|
|
||||||
ok = await workflow.trigger_processing_on_payment(
|
|
||||||
customer_id=cid,
|
|
||||||
acc_id=ctx.deps.acc_id,
|
|
||||||
acc_type=ctx.deps.platform,
|
|
||||||
)
|
|
||||||
if ok:
|
|
||||||
return "已安排,稍后发你"
|
|
||||||
return "该客户暂无待处理图片,请先发图"
|
|
||||||
except Exception as e:
|
|
||||||
return f"触发作图失败: {e},请稍后重试或转人工"
|
|
||||||
|
|
||||||
@agent.agent_pricing.tool
|
|
||||||
async def analyze_image_pricing(ctx: RunContext[Any], image_url: str) -> str:
|
|
||||||
try:
|
|
||||||
from image.image_analyzer import image_analyzer
|
|
||||||
result = await image_analyzer.analyze(image_url)
|
|
||||||
if result.get("feasibility") == "no" or result.get("risk") == "high":
|
|
||||||
return "该图风险高或不可做:不报价,建议换图或转人工评估。"
|
|
||||||
if not result.get("success", False):
|
|
||||||
return "图片识别异常:先不报价,建议客户重发更清晰图片。"
|
|
||||||
p = result.get("price_suggest", 20)
|
|
||||||
try:
|
|
||||||
st = agent._get_conversation_state(ctx.deps.from_id)
|
|
||||||
if isinstance(result.get("price_min"), (int, float)):
|
|
||||||
st.last_min_price = int(result.get("price_min") or 0)
|
|
||||||
try:
|
|
||||||
from db.customer_db import db as _db
|
|
||||||
_db.update_last_min_price(ctx.deps.from_id, st.last_min_price)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return f"建议报价:{p}元"
|
|
||||||
except Exception as e:
|
|
||||||
return f"图片分析失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent_pricing.tool
|
|
||||||
async def record_quote_pricing(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
customer_id: str,
|
|
||||||
price: int,
|
|
||||||
description: str = ""
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
from db.customer_db import db
|
|
||||||
db.update_last_price(customer_id, price)
|
|
||||||
return "ok"
|
|
||||||
except Exception as e:
|
|
||||||
return f"记录失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent_processing.tool
|
|
||||||
async def process_image_gemini_run(ctx: RunContext[Any], customer_id: str = "") -> str:
|
|
||||||
"""触发 Gemini 作图处理(processing agent 专用入口)。"""
|
|
||||||
return await process_image_gemini(ctx, customer_id)
|
|
||||||
|
|
||||||
@agent.agent_similar.tool
|
|
||||||
async def recommend_similar(ctx: RunContext[Any], hint: str = "") -> str:
|
|
||||||
try:
|
|
||||||
return "有类似款,拍下我发你参考图。"
|
|
||||||
except Exception as e:
|
|
||||||
return f"推荐失败: {e}"
|
|
||||||
|
|
||||||
@agent.agent_order.tool
|
|
||||||
async def handle_order(ctx: RunContext[Any], raw_msg: str = "") -> str:
|
|
||||||
try:
|
|
||||||
info = parse_order_info(raw_msg or "")
|
|
||||||
paid_kw = ["等待发货", "已付款", "付款成功", "买家已付款"]
|
|
||||||
if any(k in (info.get("pay_status", "") or "") for k in paid_kw) or any(k in (info.get("order_status", "") or "") for k in paid_kw):
|
|
||||||
return "已安排,稍后发你"
|
|
||||||
return ""
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@agent.agent_risk.tool
|
|
||||||
async def risk_filter(ctx: RunContext[Any], text: str = "") -> str:
|
|
||||||
return "这类不做哈,政治/敏感内容都不接。"
|
|
||||||
|
|
||||||
@agent.agent_risk.tool
|
|
||||||
async def get_customer_risk_profile_risk(ctx: RunContext[Any], customer_id: str = "") -> str:
|
|
||||||
return await get_customer_risk_profile(ctx, customer_id)
|
|
||||||
|
|
||||||
@agent.agent_risk.tool
|
|
||||||
async def mark_customer_risk_risk(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
customer_id: str,
|
|
||||||
do_not_serve: bool = False,
|
|
||||||
risk_level: str = "low",
|
|
||||||
risk_score: int = 0,
|
|
||||||
note: str = "",
|
|
||||||
tag: str = "",
|
|
||||||
) -> str:
|
|
||||||
return await mark_customer_risk(
|
|
||||||
ctx=ctx,
|
|
||||||
customer_id=customer_id,
|
|
||||||
do_not_serve=do_not_serve,
|
|
||||||
risk_level=risk_level,
|
|
||||||
risk_score=risk_score,
|
|
||||||
note=note,
|
|
||||||
tag=tag,
|
|
||||||
)
|
|
||||||
|
|
||||||
@agent.agent_risk.tool
|
|
||||||
async def record_customer_risk_event_risk(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
customer_id: str,
|
|
||||||
event_type: str,
|
|
||||||
event_count: int = 1,
|
|
||||||
note: str = "",
|
|
||||||
) -> str:
|
|
||||||
return await record_customer_risk_event(
|
|
||||||
ctx=ctx,
|
|
||||||
customer_id=customer_id,
|
|
||||||
event_type=event_type,
|
|
||||||
event_count=event_count,
|
|
||||||
note=note,
|
|
||||||
)
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def remove_background(ctx: RunContext[Any], image_url: str) -> str:
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
"""【独立工具】去背景,输出白底图。客户只要去背景时调用。"""
|
|
||||||
try:
|
|
||||||
from image.image_tools import remove_background as _rb
|
|
||||||
r = await _rb(image_url)
|
|
||||||
if r["success"]:
|
|
||||||
return f"去背景完成,已保存。自然回复客户好了发你"
|
|
||||||
return f"去背景失败:{r['message']}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"去背景失败:{e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def perspective_correct(ctx: RunContext[Any], image_url: str) -> str:
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
"""【独立工具】透视矫正。输入需白底图,输出展平图。"""
|
|
||||||
try:
|
|
||||||
from image.image_tools import perspective_correct as _pc
|
|
||||||
r = await _pc(image_url)
|
|
||||||
if r["success"]:
|
|
||||||
return f"透视矫正完成。自然回复客户好了"
|
|
||||||
return f"透视矫正失败:{r['message']}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"透视矫正失败:{e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def extract_pattern_tool(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
image_url: str,
|
|
||||||
prompt: str = "",
|
|
||||||
aspect_ratio: str = "1:1"
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
"""【独立工具】印花提取/主处理。按提示词和比例处理。"""
|
|
||||||
try:
|
|
||||||
from image.image_tools import extract_pattern
|
|
||||||
r = await extract_pattern(image_url, prompt=prompt, aspect_ratio=aspect_ratio)
|
|
||||||
if r["success"]:
|
|
||||||
return f"提取完成。自然回复客户好了发你"
|
|
||||||
return f"提取失败:{r['message']}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"提取失败:{e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def enhance_image_tool(ctx: RunContext[Any], image_url: str) -> str:
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
"""【独立工具】高清增强。客户只要清晰化时调用。"""
|
|
||||||
try:
|
|
||||||
from image.image_tools import enhance_image
|
|
||||||
r = await enhance_image(image_url)
|
|
||||||
if r["success"]:
|
|
||||||
return f"高清增强完成。自然回复客户好了"
|
|
||||||
return f"增强失败:{r['message']}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"增强失败:{e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def color_match_tool(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
orig_url: str,
|
|
||||||
result_url: str,
|
|
||||||
strength: float = 0.75
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
"""【独立工具】颜色匹配。将 result 色调匹配到 orig。"""
|
|
||||||
try:
|
|
||||||
from image.image_tools import color_match_images
|
|
||||||
r = await color_match_images(orig_url, result_url, strength=strength)
|
|
||||||
if r["success"]:
|
|
||||||
return f"颜色匹配完成"
|
|
||||||
return f"颜色匹配失败:{r['message']}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"颜色匹配失败:{e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def trim_border_tool(ctx: RunContext[Any], image_url: str) -> str:
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
"""【独立工具】裁切四周背景边(白/黄/米等)。"""
|
|
||||||
try:
|
|
||||||
from image.image_tools import trim_border
|
|
||||||
r = await trim_border(image_url)
|
|
||||||
if r["success"]:
|
|
||||||
return f"裁边完成"
|
|
||||||
return f"裁边失败:{r['message']}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"裁边失败:{e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def vectorize_to_eps_tool(ctx: RunContext[Any], image_url: str) -> str:
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
"""【独立工具】矢量化 - 将图片转为 EPS 矢量文件。客户要做矢量图、转 EPS、转 AI 格式时调用。"""
|
|
||||||
try:
|
|
||||||
from image.image_tools import vectorize_to_eps
|
|
||||||
r = await vectorize_to_eps(image_url)
|
|
||||||
if r["success"]:
|
|
||||||
return f"矢量化完成,已生成 EPS 文件。自然回复客户好了发你"
|
|
||||||
return f"矢量化失败:{r['message']}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"矢量化失败:{e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def meitu_enhance_tool(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
image_url: str,
|
|
||||||
mode: str = "standard"
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
"""
|
|
||||||
【独立工具】美图画质增强。客户要画质增强、清晰化、美图处理时调用。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_url: 图片 URL 或本地路径
|
|
||||||
mode: 处理模式。crystal(极速重绘) standard(标准) enhance(增强) hdr(HDR) portrait(人像优化)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from image.image_tools import meitu_enhance
|
|
||||||
r = await meitu_enhance(image_url, mode=mode)
|
|
||||||
if r["success"]:
|
|
||||||
return f"画质增强完成。自然回复客户好了发你"
|
|
||||||
return f"画质增强失败:{r['message']}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"画质增强失败:{e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def resize_image(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
image_url: str,
|
|
||||||
width: int,
|
|
||||||
height: int = 0
|
|
||||||
) -> str:
|
|
||||||
try:
|
|
||||||
from config.config import IMAGE_MODULE_ENABLED
|
|
||||||
if not IMAGE_MODULE_ENABLED:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
except Exception:
|
|
||||||
return "现在处理模块暂时暂停,先不处理图片"
|
|
||||||
"""
|
|
||||||
改图片尺寸。客户说「改成1920x1080」「弄成横图」「改下尺寸」时调用。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_url: 图片URL(客户刚发的图,或从对话中获取)
|
|
||||||
width: 目标宽度(像素),如 1920
|
|
||||||
height: 目标高度(0=按宽度等比缩放),如 1080
|
|
||||||
|
|
||||||
常用尺寸:1920x1080(横屏) 1080x1920(竖屏) 2000x2000(方图)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from image.image_processor import image_processor
|
|
||||||
result = await image_processor.resize(image_url, width, height)
|
|
||||||
if result["success"]:
|
|
||||||
return f"改尺寸完成:{width}x{height},已保存。自然回复客户改好了"
|
|
||||||
else:
|
|
||||||
return f"改尺寸失败:{result['message']},告知客户稍后重试"
|
|
||||||
except Exception as e:
|
|
||||||
return f"改尺寸失败:{e}"
|
|
||||||
|
|
||||||
@agent.agent.tool
|
|
||||||
async def calculate_bulk_price(
|
|
||||||
ctx: RunContext[Any],
|
|
||||||
image_count: int,
|
|
||||||
complexities: str = ""
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
计算多图打包价格。
|
|
||||||
客户要做多张图时调用,返回建议总价。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_count: 图片数量
|
|
||||||
complexities: 各图复杂度,逗号分隔,如 "normal,complex,simple"
|
|
||||||
没有识别结果时留空,按平均价格估算
|
|
||||||
"""
|
|
||||||
if image_count <= 0:
|
|
||||||
return "图片数量无效"
|
|
||||||
|
|
||||||
# 各复杂度单价(必须为5的整数倍)
|
|
||||||
unit_price = {"simple": 15, "normal": 20, "complex": 25, "hard": 30}
|
|
||||||
default_unit = 20 # 没有识别结果时的默认单价
|
|
||||||
|
|
||||||
if complexities:
|
|
||||||
levels = [c.strip() for c in complexities.split(",")]
|
|
||||||
total = sum(unit_price.get(lv, default_unit) for lv in levels)
|
|
||||||
else:
|
|
||||||
total = image_count * default_unit
|
|
||||||
|
|
||||||
# 打包优惠:3张以上9折,5张以上8折,价格必须为5的整数倍
|
|
||||||
if image_count >= 5:
|
|
||||||
discounted = round(total * 0.8 / 5) * 5
|
|
||||||
tip = f"({image_count}张8折优惠)"
|
|
||||||
elif image_count >= 3:
|
|
||||||
discounted = round(total * 0.9 / 5) * 5
|
|
||||||
tip = f"({image_count}张9折优惠)"
|
|
||||||
else:
|
|
||||||
discounted = round(total / 5) * 5
|
|
||||||
tip = ""
|
|
||||||
|
|
||||||
return f"建议打包报价:{discounted}元{tip}(原价{total}元)"
|
|
||||||
|
|||||||
63
core/engine.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Optional, Any
|
||||||
|
from core.schema import StandardMessage, StandardResponse
|
||||||
|
from core.events.event_bus import bus
|
||||||
|
|
||||||
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
class BusinessEngine:
|
||||||
|
"""
|
||||||
|
业务逻辑中枢:
|
||||||
|
1. 接收 StandardMessage。
|
||||||
|
2. 决定由哪个 AI 工具或流程处理。
|
||||||
|
3. 返回 StandardResponse。
|
||||||
|
4. 对外广播异步事件。
|
||||||
|
"""
|
||||||
|
def __init__(self, agent_instance: Any = None):
|
||||||
|
"""
|
||||||
|
:param agent_instance: 核心 AI Agent 的实例(比如重构后的 CustomerServiceAgent)
|
||||||
|
"""
|
||||||
|
self.agent = agent_instance
|
||||||
|
|
||||||
|
async def handle_message(self, msg: StandardMessage) -> StandardResponse:
|
||||||
|
"""
|
||||||
|
大脑的思考主入口
|
||||||
|
"""
|
||||||
|
logger.info(f"[Engine] 收到来自 {msg.platform} 的消息: {msg.user_id} -> {msg.content[:50]}")
|
||||||
|
|
||||||
|
# TODO: 这里将接入重构后的 Single Agent + Tool Calling
|
||||||
|
# 目前模拟一个简单的规则响应,展示 StandardResponse 的用法
|
||||||
|
|
||||||
|
if "报价" in msg.content or msg.image_urls:
|
||||||
|
return StandardResponse(
|
||||||
|
reply_content="正在为你查看图片,请稍等...",
|
||||||
|
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
if "转人工" in msg.content:
|
||||||
|
return StandardResponse(
|
||||||
|
reply_content="正在为你转接设计师...",
|
||||||
|
need_transfer=True,
|
||||||
|
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 兜底回复
|
||||||
|
return StandardResponse(
|
||||||
|
reply_content="你好,我是AI助手,有什么可以帮你的?",
|
||||||
|
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def emit_image_result(self, user_id: str, platform: str, url: str, acc_id: str):
|
||||||
|
"""
|
||||||
|
这是一个业务触发器示例:当图片处理完成时,由 Engine 主动发广播。
|
||||||
|
"""
|
||||||
|
await bus.emit(
|
||||||
|
"MESSAGE_OUTBOUND",
|
||||||
|
user_id=user_id,
|
||||||
|
platform=platform,
|
||||||
|
response=StandardResponse(
|
||||||
|
reply_content=url,
|
||||||
|
msg_type=1, # 图片
|
||||||
|
metadata={"acc_id": acc_id}
|
||||||
|
)
|
||||||
|
)
|
||||||
36
core/events/event_bus.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Dict, List, Any, Awaitable
|
||||||
|
|
||||||
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
class AsyncEventBus:
|
||||||
|
"""
|
||||||
|
异步事件总线:解耦业务触发与平台发送。
|
||||||
|
支持一个事件被多个订阅者监听。
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self._listeners: Dict[str, List[Callable[..., Awaitable[None]]]] = {}
|
||||||
|
|
||||||
|
def subscribe(self, event_type: str, callback: Callable[..., Awaitable[None]]):
|
||||||
|
"""订阅事件"""
|
||||||
|
if event_type not in self._listeners:
|
||||||
|
self._listeners[event_type] = []
|
||||||
|
self._listeners[event_type].append(callback)
|
||||||
|
logger.info(f"[EventBus] 新订阅者已注册到事件: {event_type}")
|
||||||
|
|
||||||
|
async def emit(self, event_type: str, **kwargs):
|
||||||
|
"""发布事件:异步广播给所有订阅者"""
|
||||||
|
if event_type not in self._listeners:
|
||||||
|
return
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for callback in self._listeners[event_type]:
|
||||||
|
tasks.append(asyncio.create_task(callback(**kwargs)))
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
logger.info(f"[EventBus] 事件 {event_type} 已成功广播给 {len(tasks)} 个订阅者")
|
||||||
|
|
||||||
|
# 全局单例,所有模块共用这一个广播台
|
||||||
|
bus = AsyncEventBus()
|
||||||
156
core/orchestrator.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Optional, List, Any, Dict
|
||||||
|
from collections import deque
|
||||||
|
from core.schema import StandardMessage, StandardResponse
|
||||||
|
from core.adapters.qianniu_adapter import QianniuAdapter
|
||||||
|
from core.pydantic_ai_agent_v2 import CustomerServiceBrain
|
||||||
|
from core.events.event_bus import bus
|
||||||
|
from core.repository import repo
|
||||||
|
|
||||||
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
class SystemOrchestrator:
|
||||||
|
"""
|
||||||
|
全系统总编排:具备转接冷却、防抖合并、多消息去重、以及精准日志。
|
||||||
|
"""
|
||||||
|
def __init__(self, ws_client=None):
|
||||||
|
self.ws_client = ws_client
|
||||||
|
self.qianniu_adapter = QianniuAdapter(ws_client)
|
||||||
|
self.brain = CustomerServiceBrain()
|
||||||
|
|
||||||
|
# 1. 消息 ID 去重
|
||||||
|
self._processed_msg_ids = deque(maxlen=200)
|
||||||
|
|
||||||
|
# 2. 转接冷却存储 (customer_id -> last_transfer_time)
|
||||||
|
self._last_transfer_time: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# 3. 防抖配置
|
||||||
|
self._debounce_seconds = 5.0
|
||||||
|
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)
|
||||||
|
|
||||||
|
def _get_user_lock(self, user_id: str) -> asyncio.Lock:
|
||||||
|
if user_id not in self._user_locks:
|
||||||
|
self._user_locks[user_id] = asyncio.Lock()
|
||||||
|
return self._user_locks[user_id]
|
||||||
|
|
||||||
|
async def on_raw_message_received(self, platform: str, raw_data: dict):
|
||||||
|
"""链路入口"""
|
||||||
|
try:
|
||||||
|
if platform != "qianniu": return
|
||||||
|
|
||||||
|
std_msg, direction = await self.qianniu_adapter.translate_inbound(raw_data)
|
||||||
|
|
||||||
|
# 过滤心跳
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ID 去重
|
||||||
|
if std_msg.msg_id:
|
||||||
|
if std_msg.msg_id in self._processed_msg_ids: return
|
||||||
|
self._processed_msg_ids.append(std_msg.msg_id)
|
||||||
|
|
||||||
|
# 进入防抖
|
||||||
|
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)
|
||||||
|
|
||||||
|
self._debounce_tasks[user_id] = asyncio.create_task(self._debounced_process(user_id, platform))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Orchestrator] 处理失败: {e}")
|
||||||
|
|
||||||
|
async def _handle_order_packet(self, platform: str, msg: StandardMessage):
|
||||||
|
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
|
||||||
|
|
||||||
|
async def _debounced_process(self, 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, [])
|
||||||
|
if not messages: return
|
||||||
|
|
||||||
|
# A. 合并与元数据修复
|
||||||
|
combined_content = "\n".join([m.content for m in messages if m.content.strip()])
|
||||||
|
all_image_urls = []
|
||||||
|
acc_id = messages[-1].acc_id
|
||||||
|
acc_type = messages[-1].acc_type
|
||||||
|
for m in messages:
|
||||||
|
for url in m.image_urls:
|
||||||
|
if url not in all_image_urls: all_image_urls.append(url)
|
||||||
|
|
||||||
|
# 防抖合并后的消息仍需有 msg_id,避免触发 StandardMessage 校验失败
|
||||||
|
merged_msg_id = messages[-1].msg_id if messages[-1].msg_id else f"merged-{user_id}-{int(time.time() * 1000)}"
|
||||||
|
final_msg = StandardMessage(
|
||||||
|
platform=platform,
|
||||||
|
msg_id=merged_msg_id,
|
||||||
|
user_id=user_id,
|
||||||
|
content=combined_content,
|
||||||
|
image_urls=all_image_urls,
|
||||||
|
acc_id=acc_id,
|
||||||
|
acc_type=acc_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# C. 冷却检查:如果 60秒内发过转接,告诉大脑“已处于转接中”
|
||||||
|
is_in_cooldown = (time.time() - self._last_transfer_time.get(user_id, 0)) < 60
|
||||||
|
|
||||||
|
# D. 思考
|
||||||
|
history = await repo.get_chat_history(user_id, limit=10)
|
||||||
|
if history and history[-1]['content'] == db_content: history = history[:-1]
|
||||||
|
|
||||||
|
# 如果在冷却中,在当前消息里注入“当前已在转接中”的信息
|
||||||
|
if is_in_cooldown:
|
||||||
|
final_msg.content = f"【系统:当前已向设计师发出转接请求,请勿再次调用转接工具】\n{final_msg.content}"
|
||||||
|
|
||||||
|
std_res = await self.brain.think_and_reply(final_msg, history=history)
|
||||||
|
|
||||||
|
# E. 发送并记录时间
|
||||||
|
if std_res.should_reply:
|
||||||
|
# 关键修复:补全发送时的元数据,解决日志 customer_id 为空的问题
|
||||||
|
std_res.metadata = {"acc_id": acc_id, "acc_type": acc_type}
|
||||||
|
await self.qianniu_adapter.translate_outbound(std_res, user_id)
|
||||||
|
await repo.save_chat(platform, user_id, std_res.reply_content, "out", acc_id=acc_id)
|
||||||
|
|
||||||
|
if "[转移会话]" in std_res.reply_content:
|
||||||
|
self._last_transfer_time[user_id] = time.time()
|
||||||
|
|
||||||
|
except asyncio.CancelledError: pass
|
||||||
|
except Exception as e: logger.exception(f"[Orchestrator] 处理失败: {e}")
|
||||||
|
|
||||||
|
async def handle_outbound_event(self, user_id: str, platform: str, response: StandardResponse):
|
||||||
|
if platform == "qianniu":
|
||||||
|
await self.qianniu_adapter.translate_outbound(response, user_id)
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
orchestrator: Optional[SystemOrchestrator] = None
|
||||||
|
def init_orchestrator(ws_client):
|
||||||
|
global orchestrator
|
||||||
|
orchestrator = SystemOrchestrator(ws_client)
|
||||||
|
return orchestrator
|
||||||
91
core/pydantic_ai_agent_v2.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional, Any, Dict
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
from pydantic_ai.providers.openai import OpenAIProvider
|
||||||
|
from core.schema import StandardMessage, StandardResponse
|
||||||
|
from core.agent_tools import register_agent_tools
|
||||||
|
|
||||||
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
from core.skill_manager import skill_manager
|
||||||
|
|
||||||
|
class CustomerServiceBrain:
|
||||||
|
"""
|
||||||
|
重构后的单一 Agent 大脑:
|
||||||
|
【全能终极版】统一称呼为“设计师”,支持下线安抚。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model_name: str = None):
|
||||||
|
self.api_key = os.getenv("OPENAI_API_KEY")
|
||||||
|
self.base_url = os.getenv("OPENAI_BASE_URL")
|
||||||
|
self.model_name = model_name or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
||||||
|
|
||||||
|
model = OpenAIChatModel(
|
||||||
|
model_name=self.model_name,
|
||||||
|
provider=OpenAIProvider(api_key=self.api_key, base_url=self.base_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
all_skills = skill_manager.get_all_skills_text()
|
||||||
|
|
||||||
|
# --- 统一口径后的 System Prompt ---
|
||||||
|
system_prompt = (
|
||||||
|
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话高端、专业。\n\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"
|
||||||
|
|
||||||
|
"【必杀令】\n"
|
||||||
|
"1. 每句回复严禁超过15个字!\n"
|
||||||
|
"2. 严禁报价,严禁复读图片已收到的情况。\n"
|
||||||
|
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n\n"
|
||||||
|
|
||||||
|
f"业务参考:\n{all_skills}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.agent = Agent(model=model, system_prompt=system_prompt)
|
||||||
|
register_agent_tools(self.agent)
|
||||||
|
|
||||||
|
async def think_and_reply(self, msg: StandardMessage, history: 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:]]
|
||||||
|
recent_context = "【近期对话回顾】\n" + "\n".join(lines) + "\n----------------\n"
|
||||||
|
|
||||||
|
full_input = f"{recent_context}现在的对话:{user_content}"
|
||||||
|
|
||||||
|
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 "在呢铁子。"
|
||||||
|
|
||||||
|
need_transfer = "[转移会话]" in reply_text
|
||||||
|
|
||||||
|
return StandardResponse(
|
||||||
|
reply_content=reply_text,
|
||||||
|
need_transfer=need_transfer,
|
||||||
|
metadata={"acc_id": msg.acc_id, "acc_type": msg.acc_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Brain Error]: {e}")
|
||||||
|
return StandardResponse(reply_content="好哒,设计师正在看图,稍等回你。", metadata={"acc_id": msg.acc_id})
|
||||||
69
core/repository.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from db.customer_db import db as customer_db
|
||||||
|
from db.image_tasks_db import db as task_db
|
||||||
|
from db.chat_log_db import log_message, get_conversation
|
||||||
|
|
||||||
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
class DataRepository:
|
||||||
|
"""
|
||||||
|
异步数据仓库:使用 asyncio.to_thread 屏蔽底层同步 IO 阻塞。
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.customer_db = customer_db
|
||||||
|
self.task_db = task_db
|
||||||
|
|
||||||
|
# --- 聊天记录 (异步化) ---
|
||||||
|
|
||||||
|
async def save_chat(self, platform: str, user_id: str, content: str, direction: str, acc_id: str = ""):
|
||||||
|
"""异步持久化存储聊天记录"""
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
log_message,
|
||||||
|
customer_id=user_id,
|
||||||
|
message=content,
|
||||||
|
direction=direction,
|
||||||
|
platform=platform,
|
||||||
|
acc_id=acc_id
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_chat_history(self, user_id: str, limit: int = 10) -> List[dict]:
|
||||||
|
"""异步获取历史记录"""
|
||||||
|
rows = await asyncio.to_thread(get_conversation, user_id, limit=limit)
|
||||||
|
history = []
|
||||||
|
for r in rows:
|
||||||
|
role = "user" if r["direction"] == "in" else "assistant"
|
||||||
|
history.append({"role": role, "content": r["message"]})
|
||||||
|
return history
|
||||||
|
|
||||||
|
# --- 客户相关 (异步化) ---
|
||||||
|
|
||||||
|
async def get_customer(self, platform: str, user_id: str):
|
||||||
|
customer_key = f"{platform}:{user_id}"
|
||||||
|
return await asyncio.to_thread(self.customer_db.get_customer, customer_key)
|
||||||
|
|
||||||
|
# --- 任务相关 (异步化) ---
|
||||||
|
|
||||||
|
async def create_task(self, platform: str, user_id: str, image_url: str, operation: str, requirements: str = ""):
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self.task_db.add_task,
|
||||||
|
customer_id=user_id,
|
||||||
|
platform=platform,
|
||||||
|
original_image=image_url,
|
||||||
|
operation=operation,
|
||||||
|
requirements=requirements,
|
||||||
|
status="pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_task_price(self, platform: str, user_id: str, price: float):
|
||||||
|
"""异步记录成交价"""
|
||||||
|
return await asyncio.to_thread(self.task_db.update_price, user_id, platform, price)
|
||||||
|
|
||||||
|
async def update_task_outcome(self, platform: str, user_id: str, outcome: str):
|
||||||
|
"""异步记录最终结局"""
|
||||||
|
return await asyncio.to_thread(self.task_db.update_outcome, user_id, platform, outcome)
|
||||||
|
|
||||||
|
# 全局异步仓库单例
|
||||||
|
repo = DataRepository()
|
||||||
29
core/schema.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class StandardMessage(BaseModel):
|
||||||
|
"""全平台通用的输入消息协议"""
|
||||||
|
platform: str = "qianniu" # 来源平台:qianniu, wechat, feishu, console
|
||||||
|
msg_id: str # 消息唯一ID
|
||||||
|
user_id: str # 发送者唯一ID
|
||||||
|
user_name: str = "" # 发送者昵称
|
||||||
|
content: str # 消息文本内容
|
||||||
|
image_urls: List[str] = [] # 提取出来的图片链接
|
||||||
|
acc_id: str = "" # 商家/店铺账号ID
|
||||||
|
acc_type: str = "" # 平台类型标识
|
||||||
|
timestamp: datetime = Field(default_factory=datetime.now)
|
||||||
|
raw_data: Any = None # 原始消息体(仅供调试或特殊逻辑备查)
|
||||||
|
|
||||||
|
# 扩展字段:针对电商场景
|
||||||
|
goods_name: Optional[str] = None
|
||||||
|
goods_order: Optional[str] = None
|
||||||
|
|
||||||
|
class StandardResponse(BaseModel):
|
||||||
|
"""大脑给出的通用回复协议"""
|
||||||
|
reply_content: str # 回复文本或图片URL
|
||||||
|
msg_type: int = 0 # 0: 文本, 1: 图片, 2: 撤回, 9: 转人工
|
||||||
|
should_reply: bool = True # 是否需要发送
|
||||||
|
need_transfer: bool = False # 是否触发转人工
|
||||||
|
transfer_group: str = "" # 转人工的分组ID
|
||||||
|
metadata: dict = {} # 额外元数据(如埋点、调试信息)
|
||||||
56
core/skill_manager.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
class SkillManager:
|
||||||
|
"""
|
||||||
|
技能包管理器:
|
||||||
|
1. 自动扫描 skills/ 目录下的 SKILL.md 文件。
|
||||||
|
2. 提供按需加载和组合技能的能力。
|
||||||
|
3. 支持热加载(无需重启即可更新 AI 知识)。
|
||||||
|
"""
|
||||||
|
def __init__(self, skills_dir: str = "skills"):
|
||||||
|
self.skills_dir = Path(skills_dir)
|
||||||
|
self._skill_cache: Dict[str, str] = {}
|
||||||
|
self.reload_skills()
|
||||||
|
|
||||||
|
def reload_skills(self):
|
||||||
|
"""扫描并加载所有技能文件"""
|
||||||
|
new_cache = {}
|
||||||
|
skill_files = glob.glob(str(self.skills_dir / "**/SKILL.md"), recursive=True)
|
||||||
|
|
||||||
|
for file_path in skill_files:
|
||||||
|
try:
|
||||||
|
path = Path(file_path)
|
||||||
|
skill_name = path.parent.name.lower()
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
new_cache[skill_name] = content
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[SkillManager] 加载技能失败 {file_path}: {e}")
|
||||||
|
|
||||||
|
self._skill_cache = new_cache
|
||||||
|
logger.info(f"[SkillManager] 成功加载 {len(self._skill_cache)} 个技能包: {list(self._skill_cache.keys())}")
|
||||||
|
|
||||||
|
def get_skill(self, name: str) -> str:
|
||||||
|
"""获取单个技能内容"""
|
||||||
|
return self._skill_cache.get(name.lower(), "")
|
||||||
|
|
||||||
|
def compose_skills(self, names: List[str]) -> str:
|
||||||
|
"""组合多个技能内容,用于注入 System Prompt"""
|
||||||
|
parts = []
|
||||||
|
for name in names:
|
||||||
|
content = self.get_skill(name)
|
||||||
|
if content:
|
||||||
|
parts.append(f"### 技能:{name}\n{content}")
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
def get_all_skills_text(self) -> str:
|
||||||
|
"""获取所有技能的合集(用于全能大脑模式)"""
|
||||||
|
return self.compose_skills(list(self._skill_cache.keys()))
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
skill_manager = SkillManager()
|
||||||
81
core/websocket_client_v2.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from core.orchestrator import init_orchestrator
|
||||||
|
from core.websocket_connection_flow import connect_flow, receive_messages_flow
|
||||||
|
from core.websocket_send_flow import send_message_flow
|
||||||
|
from utils.observability import emit_activity
|
||||||
|
|
||||||
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
|
class QingjianAPIClient:
|
||||||
|
"""
|
||||||
|
重构后的轻简API客户端 (协议全复刻版)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, uri=None, enable_agent: bool = True):
|
||||||
|
from config.config import QINGJIAN_WS_URI
|
||||||
|
self.uri = uri or QINGJIAN_WS_URI
|
||||||
|
self.websocket = None
|
||||||
|
self.running = True
|
||||||
|
self.logger = logger
|
||||||
|
self.enable_agent = enable_agent
|
||||||
|
|
||||||
|
# 初始化新架构总指挥部
|
||||||
|
self.orchestrator = init_orchestrator(ws_client=self)
|
||||||
|
logger.info("[WebSocket] 新架构 Orchestrator 已就绪。")
|
||||||
|
|
||||||
|
def _activity_log(self, event: str, **kwargs):
|
||||||
|
emit_activity(logger, event=event, **kwargs)
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
await connect_flow(self)
|
||||||
|
|
||||||
|
async def receive_messages(self):
|
||||||
|
await receive_messages_flow(self)
|
||||||
|
|
||||||
|
async def handle_message(self, message):
|
||||||
|
"""收到消息处理"""
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
await self.orchestrator.on_raw_message_received(platform="qianniu", raw_data=data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebSocket] 处理消息异常: {e}")
|
||||||
|
|
||||||
|
async def send(self, customer_id: str, acc_id: str, acc_type: str, content: str, msg_type: int = 0):
|
||||||
|
"""
|
||||||
|
【协议全复刻】严格按照 legacy/websocket_outbound_flow.py 的结构
|
||||||
|
"""
|
||||||
|
# 注意:在这里 from_id 竟然填的是 customer_id,这是逆向接口的特殊要求
|
||||||
|
msg_payload = {
|
||||||
|
"msg_id": "",
|
||||||
|
"acc_id": acc_id,
|
||||||
|
"msg": content,
|
||||||
|
"from_id": customer_id,
|
||||||
|
"from_name": "",
|
||||||
|
"cy_id": customer_id,
|
||||||
|
"acc_type": acc_type,
|
||||||
|
"msg_type": msg_type,
|
||||||
|
"cy_name": "",
|
||||||
|
}
|
||||||
|
await self.send_message(msg_payload)
|
||||||
|
|
||||||
|
async def send_message(self, message_dict: dict):
|
||||||
|
"""底层的 WebSocket 发送"""
|
||||||
|
await send_message_flow(self, message_dict)
|
||||||
|
|
||||||
|
def get_time(self):
|
||||||
|
return datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
await self.connect()
|
||||||
|
await self.receive_messages()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
client = QingjianAPIClient()
|
||||||
|
try:
|
||||||
|
asyncio.run(client.run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("已停止")
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import websockets
|
import websockets
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("cs_agent")
|
||||||
|
|
||||||
async def connect_flow(client):
|
async def connect_flow(client):
|
||||||
"""连接 WebSocket 服务器并自动重连。"""
|
"""连接 WebSocket 服务器并自动重连。"""
|
||||||
@@ -9,49 +11,22 @@ async def connect_flow(client):
|
|||||||
client.logger.info(f"[{client.get_time()}] 正在连接轻简API {client.uri}...")
|
client.logger.info(f"[{client.get_time()}] 正在连接轻简API {client.uri}...")
|
||||||
async with websockets.connect(client.uri) as websocket:
|
async with websockets.connect(client.uri) as websocket:
|
||||||
client.websocket = websocket
|
client.websocket = websocket
|
||||||
from utils.health_check import set_qingjian_connected
|
|
||||||
set_qingjian_connected(True)
|
|
||||||
client.logger.info(f"[{client.get_time()}] 连接成功!")
|
client.logger.info(f"[{client.get_time()}] 连接成功!")
|
||||||
if client.enable_agent:
|
|
||||||
client.logger.info(f"[{client.get_time()}] AI Agent 已启用,将自动处理消息")
|
|
||||||
client.logger.info(f"[{client.get_time()}] 等待接收消息...")
|
client.logger.info(f"[{client.get_time()}] 等待接收消息...")
|
||||||
|
|
||||||
await client.receive_messages()
|
await client.receive_messages()
|
||||||
|
|
||||||
except ConnectionRefusedError:
|
|
||||||
from utils.health_check import set_qingjian_connected
|
|
||||||
set_qingjian_connected(False)
|
|
||||||
client.logger.info(f"[{client.get_time()}] 连接被拒绝,请检查轻简软件是否已启动")
|
|
||||||
except websockets.exceptions.InvalidURI:
|
|
||||||
from utils.health_check import set_qingjian_connected
|
|
||||||
set_qingjian_connected(False)
|
|
||||||
client.logger.info(f"[{client.get_time()}] URI格式错误")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
from utils.health_check import set_qingjian_connected
|
# 统一捕获异常,避免因为不同版本的 websockets 导致属性错误
|
||||||
set_qingjian_connected(False)
|
client.logger.info(f"[{client.get_time()}] 连接或运行错误: {e}")
|
||||||
client.logger.info(f"[{client.get_time()}] 连接错误: {e}")
|
|
||||||
|
|
||||||
if client.running:
|
if client.running:
|
||||||
client.logger.info(f"[{client.get_time()}] 5秒后尝试重连...")
|
client.logger.info(f"[{client.get_time()}] 5秒后尝试重连...")
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
async def receive_messages_flow(client):
|
async def receive_messages_flow(client):
|
||||||
"""持续接收消息。"""
|
"""持续接收消息。"""
|
||||||
try:
|
try:
|
||||||
async for message in client.websocket:
|
async for message in client.websocket:
|
||||||
await client.handle_message(message)
|
await client.handle_message(message)
|
||||||
except websockets.exceptions.ConnectionClosed:
|
|
||||||
from utils.health_check import set_qingjian_connected
|
|
||||||
set_qingjian_connected(False)
|
|
||||||
client.logger.info(f"[{client.get_time()}] 连接已关闭")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
from utils.health_check import set_qingjian_connected
|
client.logger.info(f"[{client.get_time()}] 接收消息中断: {e}")
|
||||||
set_qingjian_connected(False)
|
|
||||||
client.logger.info(f"[{client.get_time()}] 接收消息错误: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_message_flow(client, message, *, shop_type_resolver):
|
|
||||||
from core.websocket_inbound_flow import handle_incoming_message
|
|
||||||
|
|
||||||
await handle_incoming_message(client, message, shop_type_resolver=shop_type_resolver)
|
|
||||||
|
|||||||
@@ -42,13 +42,14 @@ async def send_message_flow(client, message):
|
|||||||
await client.websocket.send(msg_json)
|
await client.websocket.send(msg_json)
|
||||||
pretty = json.dumps(message, ensure_ascii=False, indent=2)
|
pretty = json.dumps(message, ensure_ascii=False, indent=2)
|
||||||
client.logger.info(f"[{client.get_time()}] 发送成功:\n{pretty}")
|
client.logger.info(f"[{client.get_time()}] 发送成功:\n{pretty}")
|
||||||
|
data = message.get("data", {}) if isinstance(message, dict) else {}
|
||||||
client._activity_log(
|
client._activity_log(
|
||||||
"send_message_success",
|
"send_message_success",
|
||||||
trace_id=message.get("_trace_id", ""),
|
trace_id=message.get("_trace_id", "") if isinstance(message, dict) else "",
|
||||||
acc_id=message.get("acc_id", ""),
|
acc_id=data.get("acc_id", ""),
|
||||||
customer_id=message.get("from_id", ""),
|
customer_id=data.get("cy_id", ""),
|
||||||
msg_type=message.get("msg_type", 0),
|
msg_type=data.get("msg_type", 0),
|
||||||
msg=message.get("msg", ""),
|
msg=data.get("msg", ""),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
client.logger.info(f"[{client.get_time()}] 发送失败: {e}")
|
client.logger.info(f"[{client.get_time()}] 发送失败: {e}")
|
||||||
|
|||||||
19
core/提示词.MD
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"你是一位专注【高清修复】和【找原图】的专业店主。性格干脆,说话高端、专业。\n\n"
|
||||||
|
|
||||||
|
"【统一称呼规范】\n"
|
||||||
|
"1. 严禁使用'师傅'、'客服'、'专员'等词汇!\n"
|
||||||
|
"2. 必须统一称呼为【设计师】。比如:'找设计师看下'、'设计师马上来'、'等设计师核价'。\n\n"
|
||||||
|
|
||||||
|
"【核心逻辑】\n"
|
||||||
|
"1. 业务:只聊高清修复和找原图。引导发图 -> 问需求 -> 找设计师。\n"
|
||||||
|
"2. 下线安抚:如果工具返回 'ERROR_NO_DESIGNER_ONLINE',说明设计师们【下班/下线】了。回:'亲亲,设计师现在下班啦,需求我先记下,明天第一时间回您哈!'。\n"
|
||||||
|
"3. 正在转接中:如果看到系统提示已在转接,回:'设计师正在赶来,我再帮你催下哈!'。\n\n"
|
||||||
|
"4. 没转接时:引导发图 -> 问需求 -> 调工具转人工。\n\n
|
||||||
|
"5. 语气:淘宝亲切风,多用'亲亲'、'铁子'。每句回复【严禁超过15字】!\n\n"
|
||||||
|
|
||||||
|
"【必杀令】\n"
|
||||||
|
"1. 每句回复严禁超过15个字!\n"
|
||||||
|
"2. 严禁报价,严禁复读图片已收到的情况。\n"
|
||||||
|
"3. 必须原样输出工具返回的'正在为您转接|'指令。\n\n"
|
||||||
|
|
||||||
|
f"业务参考:\n{all_skills}"
|
||||||
@@ -1,889 +1 @@
|
|||||||
{
|
{}
|
||||||
"new_customer_001": {
|
|
||||||
"customer_id": "new_customer_001",
|
|
||||||
"name": "新客户小王",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 0,
|
|
||||||
"total_spent": 0,
|
|
||||||
"avg_order_value": 0.0,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "C",
|
|
||||||
"vip": false,
|
|
||||||
"vip_level": 0,
|
|
||||||
"last_price": 20,
|
|
||||||
"last_price_time": "2026-02-28T15:04:15.181813",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 0,
|
|
||||||
"lowest_price_accepted": 0,
|
|
||||||
"preferred_format": "jpg",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "",
|
|
||||||
"decision_speed": "",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 0,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "低",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 0,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:03:57.129715",
|
|
||||||
"last_update": "2026-02-28T15:04:15.184378"
|
|
||||||
},
|
|
||||||
"fast_customer_002": {
|
|
||||||
"customer_id": "fast_customer_002",
|
|
||||||
"name": "爽快老客老李",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 8,
|
|
||||||
"total_spent": 280,
|
|
||||||
"avg_order_value": 0.0,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [
|
|
||||||
"爽快"
|
|
||||||
],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "C",
|
|
||||||
"vip": false,
|
|
||||||
"vip_level": 0,
|
|
||||||
"last_price": 10,
|
|
||||||
"last_price_time": "2026-02-28T15:06:10.872962",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 2,
|
|
||||||
"lowest_price_accepted": 10,
|
|
||||||
"preferred_format": "jpg",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "中",
|
|
||||||
"decision_speed": "快",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 8,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "低",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 0,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:03:57.131384",
|
|
||||||
"last_update": "2026-02-28T15:06:10.875534"
|
|
||||||
},
|
|
||||||
"bargainer_003": {
|
|
||||||
"customer_id": "bargainer_003",
|
|
||||||
"name": "砍价王小张",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 3,
|
|
||||||
"total_spent": 45,
|
|
||||||
"avg_order_value": 0.0,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [
|
|
||||||
"砍价狂",
|
|
||||||
"纠结"
|
|
||||||
],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "C",
|
|
||||||
"vip": false,
|
|
||||||
"vip_level": 0,
|
|
||||||
"last_price": 10,
|
|
||||||
"last_price_time": "2026-02-28T15:05:45.067204",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 6,
|
|
||||||
"lowest_price_accepted": 10,
|
|
||||||
"preferred_format": "jpg",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "高",
|
|
||||||
"decision_speed": "慢",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 0,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "低",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 0,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:03:57.132648",
|
|
||||||
"last_update": "2026-02-28T15:05:45.071818"
|
|
||||||
},
|
|
||||||
"vip_customer_004": {
|
|
||||||
"customer_id": "vip_customer_004",
|
|
||||||
"name": "VIP客户陈总",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 15,
|
|
||||||
"total_spent": 680,
|
|
||||||
"avg_order_value": 0.0,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [
|
|
||||||
"爽快"
|
|
||||||
],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "A",
|
|
||||||
"vip": true,
|
|
||||||
"vip_level": 2,
|
|
||||||
"last_price": 20,
|
|
||||||
"last_price_time": "2026-02-28T15:04:56.155844",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 0,
|
|
||||||
"lowest_price_accepted": 0,
|
|
||||||
"preferred_format": "jpg",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "低",
|
|
||||||
"decision_speed": "快",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 0,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "低",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 18,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:03:57.134104",
|
|
||||||
"last_update": "2026-02-28T15:04:56.158233"
|
|
||||||
},
|
|
||||||
"high_value_005": {
|
|
||||||
"customer_id": "high_value_005",
|
|
||||||
"name": "高价值客户刘老板",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 20,
|
|
||||||
"total_spent": 1200,
|
|
||||||
"avg_order_value": 60,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [
|
|
||||||
"爽快"
|
|
||||||
],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "A",
|
|
||||||
"vip": false,
|
|
||||||
"vip_level": 0,
|
|
||||||
"last_price": 20,
|
|
||||||
"last_price_time": "2026-02-28T15:05:11.156030",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 0,
|
|
||||||
"lowest_price_accepted": 0,
|
|
||||||
"preferred_format": "jpg",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "低",
|
|
||||||
"decision_speed": "快",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 0,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "低",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 0,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:03:57.135396",
|
|
||||||
"last_update": "2026-02-28T15:05:11.160004"
|
|
||||||
},
|
|
||||||
"blacklist_006": {
|
|
||||||
"customer_id": "blacklist_006",
|
|
||||||
"name": "黑名单客户",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 0,
|
|
||||||
"total_spent": 0.0,
|
|
||||||
"avg_order_value": 0.0,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "C",
|
|
||||||
"vip": false,
|
|
||||||
"vip_level": 0,
|
|
||||||
"last_price": 0,
|
|
||||||
"last_price_time": "",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 0,
|
|
||||||
"lowest_price_accepted": 0,
|
|
||||||
"preferred_format": "jpg",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "",
|
|
||||||
"decision_speed": "",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 0,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "低",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": true,
|
|
||||||
"blacklist_reason": "恶意投诉多次",
|
|
||||||
"vip_custom_price": 0,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:03:57.136490",
|
|
||||||
"last_update": "2026-02-28T15:05:27.155220"
|
|
||||||
},
|
|
||||||
"test_new_001": {
|
|
||||||
"customer_id": "test_new_001",
|
|
||||||
"name": "新客户小王",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 0,
|
|
||||||
"total_spent": 0,
|
|
||||||
"avg_order_value": 0.0,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "C",
|
|
||||||
"vip": false,
|
|
||||||
"vip_level": 0,
|
|
||||||
"last_price": 0,
|
|
||||||
"last_price_time": "2026-02-28T15:27:40.801329",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 0,
|
|
||||||
"lowest_price_accepted": 0,
|
|
||||||
"preferred_format": "jpg",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "",
|
|
||||||
"decision_speed": "",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 0,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "低",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 0,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:29:05.719291",
|
|
||||||
"last_update": "2026-02-28T15:29:05.719308"
|
|
||||||
},
|
|
||||||
"test_fast_002": {
|
|
||||||
"customer_id": "test_fast_002",
|
|
||||||
"name": "爽快老客老李",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 8,
|
|
||||||
"total_spent": 280,
|
|
||||||
"avg_order_value": 0.0,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [
|
|
||||||
"爽快"
|
|
||||||
],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "C",
|
|
||||||
"vip": false,
|
|
||||||
"vip_level": 0,
|
|
||||||
"last_price": 25,
|
|
||||||
"last_price_time": "",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 0,
|
|
||||||
"lowest_price_accepted": 0,
|
|
||||||
"preferred_format": "",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "低",
|
|
||||||
"decision_speed": "快",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 8,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 0,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:29:05.720944",
|
|
||||||
"last_update": "2026-02-28T15:29:05.720948"
|
|
||||||
},
|
|
||||||
"test_bargain_003": {
|
|
||||||
"customer_id": "test_bargain_003",
|
|
||||||
"name": "砍价王小张",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 3,
|
|
||||||
"total_spent": 45,
|
|
||||||
"avg_order_value": 0.0,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [
|
|
||||||
"砍价狂",
|
|
||||||
"纠结"
|
|
||||||
],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "C",
|
|
||||||
"vip": false,
|
|
||||||
"vip_level": 0,
|
|
||||||
"last_price": 15,
|
|
||||||
"last_price_time": "",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 4,
|
|
||||||
"lowest_price_accepted": 15,
|
|
||||||
"preferred_format": "",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "高",
|
|
||||||
"decision_speed": "慢",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 0,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 0,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:29:05.722448",
|
|
||||||
"last_update": "2026-02-28T15:29:05.722454"
|
|
||||||
},
|
|
||||||
"test_vip_004": {
|
|
||||||
"customer_id": "test_vip_004",
|
|
||||||
"name": "VIP 客户陈总",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 15,
|
|
||||||
"total_spent": 680,
|
|
||||||
"avg_order_value": 0.0,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [
|
|
||||||
"爽快"
|
|
||||||
],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "A",
|
|
||||||
"vip": true,
|
|
||||||
"vip_level": 2,
|
|
||||||
"last_price": 0,
|
|
||||||
"last_price_time": "",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 0,
|
|
||||||
"lowest_price_accepted": 0,
|
|
||||||
"preferred_format": "",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "低",
|
|
||||||
"decision_speed": "快",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 0,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 18,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:29:05.723887",
|
|
||||||
"last_update": "2026-02-28T15:29:05.723890"
|
|
||||||
},
|
|
||||||
"test_highvalue_005": {
|
|
||||||
"customer_id": "test_highvalue_005",
|
|
||||||
"name": "高价值客户刘老板",
|
|
||||||
"nickname": "",
|
|
||||||
"email": "",
|
|
||||||
"phone": "",
|
|
||||||
"wechat": "",
|
|
||||||
"address": "",
|
|
||||||
"platform": "",
|
|
||||||
"platform_id": "",
|
|
||||||
"budget": "",
|
|
||||||
"budget_range_min": 0,
|
|
||||||
"budget_range_max": 0,
|
|
||||||
"requirements": [],
|
|
||||||
"preference_services": [],
|
|
||||||
"total_orders": 20,
|
|
||||||
"total_spent": 1200,
|
|
||||||
"avg_order_value": 60,
|
|
||||||
"purchase_frequency": "",
|
|
||||||
"last_order_date": "",
|
|
||||||
"first_order_date": "",
|
|
||||||
"order_ids": [],
|
|
||||||
"pending_orders": 0,
|
|
||||||
"completed_orders": 0,
|
|
||||||
"refund_count": 0,
|
|
||||||
"personality": [
|
|
||||||
"爽快"
|
|
||||||
],
|
|
||||||
"communication_prefer": "",
|
|
||||||
"response_speed": "",
|
|
||||||
"patience_level": "",
|
|
||||||
"customer_level": "A",
|
|
||||||
"vip": false,
|
|
||||||
"vip_level": 0,
|
|
||||||
"last_price": 0,
|
|
||||||
"last_price_time": "",
|
|
||||||
"last_quote_no_convert": false,
|
|
||||||
"last_min_price": 0,
|
|
||||||
"last_image_url": "",
|
|
||||||
"last_image_time": "",
|
|
||||||
"last_gemini_prompt": "",
|
|
||||||
"last_aspect_ratio": "1:1",
|
|
||||||
"last_perspective": "no",
|
|
||||||
"processing_status": "",
|
|
||||||
"processing_image_url": "",
|
|
||||||
"expected_done_at": "",
|
|
||||||
"discount_given_count": 0,
|
|
||||||
"lowest_price_accepted": 0,
|
|
||||||
"preferred_format": "",
|
|
||||||
"preferred_size": "",
|
|
||||||
"last_conversation_summary": "",
|
|
||||||
"last_conversation_time": "",
|
|
||||||
"total_images_sent": 0,
|
|
||||||
"complexity_history": [],
|
|
||||||
"image_type_history": [],
|
|
||||||
"price_sensitivity": "低",
|
|
||||||
"decision_speed": "快",
|
|
||||||
"revision_count": 0,
|
|
||||||
"revision_orders": 0,
|
|
||||||
"total_completed_orders": 0,
|
|
||||||
"bulk_potential": "",
|
|
||||||
"churn_risk": "",
|
|
||||||
"upsell_opportunity": [],
|
|
||||||
"blacklist": false,
|
|
||||||
"blacklist_reason": "",
|
|
||||||
"vip_custom_price": 0,
|
|
||||||
"last_email_status": "",
|
|
||||||
"good_reviews": 0,
|
|
||||||
"bad_reviews": 0,
|
|
||||||
"dispute_count": 0,
|
|
||||||
"follow_up_by": "",
|
|
||||||
"follow_up_date": "",
|
|
||||||
"next_follow_date": "",
|
|
||||||
"source": "",
|
|
||||||
"coupon_used": "",
|
|
||||||
"notes": [],
|
|
||||||
"tags": [],
|
|
||||||
"created_at": "",
|
|
||||||
"last_contact": "2026-02-28T15:29:05.725313",
|
|
||||||
"last_update": "2026-02-28T15:29:05.725316"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -199,14 +199,18 @@ def get_customers(limit: int = 100) -> List[Dict]:
|
|||||||
|
|
||||||
|
|
||||||
def get_conversation(customer_id: str, limit: int = 200) -> List[Dict]:
|
def get_conversation(customer_id: str, limit: int = 200) -> List[Dict]:
|
||||||
"""返回某客户的全部对话记录(按时间升序)"""
|
"""返回某客户的最近对话记录(按时间升序)"""
|
||||||
with _get_conn() as conn:
|
with _get_conn() as conn:
|
||||||
|
# 核心修复:先取最新的 limit 条,再按时间正序排列
|
||||||
rows = conn.execute(_sql("""
|
rows = conn.execute(_sql("""
|
||||||
SELECT id, direction, message, msg_type, timestamp, acc_id
|
SELECT * FROM (
|
||||||
FROM chat_logs
|
SELECT id, direction, message, msg_type, timestamp, acc_id
|
||||||
WHERE customer_id = ?
|
FROM chat_logs
|
||||||
|
WHERE customer_id = ?
|
||||||
|
ORDER BY timestamp DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
) AS recent
|
||||||
ORDER BY timestamp ASC, id ASC
|
ORDER BY timestamp ASC, id ASC
|
||||||
LIMIT ?
|
|
||||||
"""), (customer_id, limit)).fetchall()
|
"""), (customer_id, limit)).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|||||||
BIN
db/image_tasks.db
Normal file
@@ -1,480 +1,113 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
|
||||||
图片任务数据库管理
|
|
||||||
支持客户后续增加需求细节
|
|
||||||
"""
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import os
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_DB_TYPE = os.getenv("DB_TYPE", "sqlite").lower()
|
|
||||||
_MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
|
|
||||||
_MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
|
|
||||||
_MYSQL_USER = os.getenv("MYSQL_USER", "root")
|
|
||||||
_MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
|
|
||||||
_MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ai_cs")
|
|
||||||
|
|
||||||
def _is_mysql() -> bool:
|
|
||||||
return _DB_TYPE in ("mysql", "mariadb")
|
|
||||||
|
|
||||||
def _sql(query: str) -> str:
|
|
||||||
return query.replace("?", "%s") if _is_mysql() else query
|
|
||||||
|
|
||||||
def _now_str() -> str:
|
|
||||||
if _is_mysql():
|
|
||||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
return datetime.now().isoformat()
|
|
||||||
|
|
||||||
class TaskStatus(Enum):
|
class TaskStatus(Enum):
|
||||||
"""任务状态"""
|
PENDING = "pending"
|
||||||
PENDING = "pending" # 待付款
|
PROCESSING = "processing"
|
||||||
PAID = "paid" # 已付款,待处理
|
COMPLETED = "completed"
|
||||||
PROCESSING = "processing" # 处理中
|
FAILED = "failed"
|
||||||
AWAITING_CONFIRM = "awaiting_confirm" # 已完成,待客户确认
|
|
||||||
COMPLETED = "completed" # 已完成
|
|
||||||
FAILED = "failed" # 失败
|
|
||||||
CANCELLED = "cancelled" # 已取消
|
|
||||||
|
|
||||||
class ImageTaskManager:
|
class ImageTaskManager:
|
||||||
"""图片任务管理器"""
|
|
||||||
|
|
||||||
def __init__(self, db_path: str = None):
|
def __init__(self, db_path: str = None):
|
||||||
if db_path is None:
|
if db_path is None:
|
||||||
db_path = Path(__file__).parent / "image_tasks.db"
|
db_path = Path(__file__).parent / "image_tasks.db"
|
||||||
|
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self._init_db()
|
self._init_db()
|
||||||
logger.info(f"图片任务管理器初始化完成,数据库:{self.db_path}")
|
|
||||||
|
|
||||||
def _init_db(self):
|
def _init_db(self):
|
||||||
"""初始化数据库"""
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if _is_mysql():
|
conn = sqlite3.connect(self.db_path)
|
||||||
conn = self._get_conn()
|
cursor = conn.cursor()
|
||||||
cursor = conn.cursor()
|
# 核心任务表
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS image_tasks (
|
CREATE TABLE IF NOT EXISTS image_tasks (
|
||||||
task_id VARCHAR(128) PRIMARY KEY,
|
task_id TEXT PRIMARY KEY,
|
||||||
customer_id VARCHAR(128) NOT NULL,
|
customer_id TEXT NOT NULL,
|
||||||
customer_name VARCHAR(255),
|
platform TEXT DEFAULT 'qianniu',
|
||||||
original_image TEXT NOT NULL,
|
original_image TEXT NOT NULL,
|
||||||
operation VARCHAR(64) DEFAULT 'enhance',
|
operation TEXT DEFAULT 'enhance',
|
||||||
requirements TEXT,
|
requirements TEXT,
|
||||||
customer_notes TEXT,
|
status TEXT DEFAULT 'pending',
|
||||||
status VARCHAR(32) DEFAULT 'pending',
|
result_image TEXT,
|
||||||
created_at DATETIME,
|
price REAL DEFAULT 0.0,
|
||||||
paid_at DATETIME,
|
outcome TEXT DEFAULT 'pending',
|
||||||
started_at DATETIME,
|
created_at TEXT,
|
||||||
completed_at DATETIME,
|
updated_at TEXT
|
||||||
result_image TEXT,
|
)
|
||||||
error_message TEXT,
|
''')
|
||||||
retry_count INT DEFAULT 0,
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON image_tasks(status)')
|
||||||
acc_id VARCHAR(128),
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_cust_plat ON image_tasks(customer_id, platform)')
|
||||||
acc_type VARCHAR(64) DEFAULT 'AliWorkbench'
|
conn.commit()
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
conn.close()
|
||||||
''')
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS task_requirement_changes (
|
|
||||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
|
||||||
task_id VARCHAR(128) NOT NULL,
|
|
||||||
change_type VARCHAR(64),
|
|
||||||
old_value TEXT,
|
|
||||||
new_value TEXT,
|
|
||||||
changed_at DATETIME,
|
|
||||||
changed_by VARCHAR(32),
|
|
||||||
FOREIGN KEY (task_id) REFERENCES image_tasks(task_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
''')
|
|
||||||
cursor.execute("SHOW INDEX FROM image_tasks")
|
|
||||||
exists = {str(r.get("Key_name", "")) for r in cursor.fetchall()}
|
|
||||||
if "idx_customer" not in exists:
|
|
||||||
cursor.execute('CREATE INDEX idx_customer ON image_tasks(customer_id)')
|
|
||||||
if "idx_status" not in exists:
|
|
||||||
cursor.execute('CREATE INDEX idx_status ON image_tasks(status)')
|
|
||||||
if "idx_created" not in exists:
|
|
||||||
cursor.execute('CREATE INDEX idx_created ON image_tasks(created_at)')
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
else:
|
|
||||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS image_tasks (
|
|
||||||
task_id TEXT PRIMARY KEY,
|
|
||||||
customer_id TEXT NOT NULL,
|
|
||||||
customer_name TEXT,
|
|
||||||
original_image TEXT NOT NULL,
|
|
||||||
operation TEXT DEFAULT 'enhance',
|
|
||||||
requirements TEXT,
|
|
||||||
customer_notes TEXT,
|
|
||||||
status TEXT DEFAULT 'pending',
|
|
||||||
created_at TEXT,
|
|
||||||
paid_at TEXT,
|
|
||||||
started_at TEXT,
|
|
||||||
completed_at TEXT,
|
|
||||||
result_image TEXT,
|
|
||||||
error_message TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
acc_id TEXT,
|
|
||||||
acc_type TEXT DEFAULT 'AliWorkbench'
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS task_requirement_changes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
change_type TEXT,
|
|
||||||
old_value TEXT,
|
|
||||||
new_value TEXT,
|
|
||||||
changed_at TEXT,
|
|
||||||
changed_by TEXT,
|
|
||||||
FOREIGN KEY (task_id) REFERENCES image_tasks(task_id)
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_customer ON image_tasks(customer_id)')
|
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_status ON image_tasks(status)')
|
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_created ON image_tasks(created_at)')
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
logger.info("数据库表初始化完成")
|
|
||||||
|
|
||||||
def _get_conn(self):
|
def _get_conn(self):
|
||||||
"""获取数据库连接"""
|
|
||||||
if _is_mysql():
|
|
||||||
import pymysql
|
|
||||||
return pymysql.connect(
|
|
||||||
host=_MYSQL_HOST,
|
|
||||||
port=_MYSQL_PORT,
|
|
||||||
user=_MYSQL_USER,
|
|
||||||
password=_MYSQL_PASSWORD,
|
|
||||||
database=_MYSQL_DATABASE,
|
|
||||||
charset="utf8mb4",
|
|
||||||
cursorclass=pymysql.cursors.DictCursor,
|
|
||||||
autocommit=False,
|
|
||||||
)
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
conn = sqlite3.connect(self.db_path)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def create_task(self, task_id: str, customer_id: str, customer_name: str,
|
def add_task(self, customer_id: str, platform: str, original_image: str, operation: str, requirements: str = "", status: str = "pending") -> str:
|
||||||
original_image: str, operation: str = 'enhance',
|
task_id = str(uuid.uuid4())
|
||||||
requirements: dict = None, acc_id: str = '', acc_type: str = 'AliWorkbench') -> bool:
|
now = datetime.now().isoformat()
|
||||||
"""创建图片任务"""
|
|
||||||
try:
|
try:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
requirements_json = json.dumps(requirements) if requirements else None
|
INSERT INTO image_tasks (task_id, customer_id, platform, original_image, operation, requirements, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
cursor.execute(_sql('''
|
''', (task_id, customer_id, platform, original_image, operation, requirements, status, now, now))
|
||||||
INSERT INTO image_tasks (
|
|
||||||
task_id, customer_id, customer_name, original_image,
|
|
||||||
operation, requirements, customer_notes, status,
|
|
||||||
created_at, acc_id, acc_type
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
'''), (
|
|
||||||
task_id,
|
|
||||||
customer_id,
|
|
||||||
customer_name,
|
|
||||||
original_image,
|
|
||||||
operation,
|
|
||||||
requirements_json,
|
|
||||||
'', # 初始备注为空
|
|
||||||
TaskStatus.PENDING.value,
|
|
||||||
_now_str(),
|
|
||||||
acc_id,
|
|
||||||
acc_type
|
|
||||||
))
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
return task_id
|
||||||
logger.info(f"图片任务创建成功:{task_id}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"创建图片任务失败:{e}")
|
logger.error(f"Failed to add task: {e}")
|
||||||
return False
|
return ""
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> Optional[dict]:
|
def update_status(self, task_id: str, status: str, result_image: str = ""):
|
||||||
"""查询任务"""
|
now = datetime.now().isoformat()
|
||||||
try:
|
try:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
if result_image:
|
||||||
cursor.execute(_sql('SELECT * FROM image_tasks WHERE task_id = ?'), (task_id,))
|
cursor.execute('UPDATE image_tasks SET status = ?, result_image = ?, updated_at = ? WHERE task_id = ?',
|
||||||
row = cursor.fetchone()
|
(status, result_image, now, task_id))
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
task = dict(row)
|
|
||||||
# 解析 JSON 字段
|
|
||||||
if task.get('requirements'):
|
|
||||||
task['requirements'] = json.loads(task['requirements'])
|
|
||||||
return task
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"查询任务失败:{e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_customer_tasks(self, customer_id: str, status: str = None) -> List[dict]:
|
|
||||||
"""查询客户的任务列表"""
|
|
||||||
try:
|
|
||||||
conn = self._get_conn()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
if status:
|
|
||||||
cursor.execute(_sql('''
|
|
||||||
SELECT * FROM image_tasks
|
|
||||||
WHERE customer_id = ? AND status = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
'''), (customer_id, status))
|
|
||||||
else:
|
else:
|
||||||
cursor.execute(_sql('''
|
cursor.execute('UPDATE image_tasks SET status = ?, updated_at = ? WHERE task_id = ?',
|
||||||
SELECT * FROM image_tasks
|
(status, now, task_id))
|
||||||
WHERE customer_id = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
'''), (customer_id,))
|
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
tasks = []
|
|
||||||
for row in rows:
|
|
||||||
task = dict(row)
|
|
||||||
if task.get('requirements'):
|
|
||||||
task['requirements'] = json.loads(task['requirements'])
|
|
||||||
tasks.append(task)
|
|
||||||
|
|
||||||
return tasks
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"查询客户任务失败:{e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def update_status(self, task_id: str, status: TaskStatus):
|
|
||||||
"""更新任务状态"""
|
|
||||||
try:
|
|
||||||
conn = self._get_conn()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
placeholder = "%s" if _is_mysql() else "?"
|
|
||||||
updates = [f'status = {placeholder}']
|
|
||||||
params = [status.value]
|
|
||||||
|
|
||||||
# 根据状态设置时间
|
|
||||||
if status == TaskStatus.PAID:
|
|
||||||
updates.append(f'paid_at = {placeholder}')
|
|
||||||
params.append(_now_str())
|
|
||||||
elif status == TaskStatus.PROCESSING:
|
|
||||||
updates.append(f'started_at = {placeholder}')
|
|
||||||
params.append(_now_str())
|
|
||||||
elif status in [TaskStatus.COMPLETED, TaskStatus.FAILED]:
|
|
||||||
updates.append(f'completed_at = {placeholder}')
|
|
||||||
params.append(_now_str())
|
|
||||||
|
|
||||||
params.append(task_id)
|
|
||||||
|
|
||||||
cursor.execute(_sql(f'''
|
|
||||||
UPDATE image_tasks
|
|
||||||
SET {', '.join(updates)}
|
|
||||||
WHERE task_id = ?
|
|
||||||
'''), params)
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
logger.info(f"任务状态更新:{task_id} -> {status.value}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"更新任务状态失败:{e}")
|
logger.error(f"Failed to update task status: {e}")
|
||||||
|
|
||||||
def update_result(self, task_id: str, result_image: str, error_message: str = None):
|
def update_outcome(self, customer_id: str, platform: str, outcome: str):
|
||||||
"""更新处理结果"""
|
"""记录任务的最终结局(用于训练样本分类)"""
|
||||||
|
now = datetime.now().isoformat()
|
||||||
try:
|
try:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
cursor.execute(_sql('''
|
|
||||||
UPDATE image_tasks
|
UPDATE image_tasks
|
||||||
SET result_image = ?, error_message = ?
|
SET outcome = ?, updated_at = ?
|
||||||
WHERE task_id = ?
|
WHERE task_id = (
|
||||||
'''), (result_image, error_message, task_id))
|
SELECT task_id FROM image_tasks
|
||||||
|
WHERE customer_id = ? AND platform = ?
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
)
|
||||||
|
''', (outcome, now, customer_id, platform))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
logger.info(f"[DB] 客户 {customer_id} 任务结局更新为: {outcome}")
|
||||||
logger.info(f"任务结果更新:{task_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"更新任务结果失败:{e}")
|
logger.error(f"Failed to update outcome: {e}")
|
||||||
|
|
||||||
def add_customer_note(self, task_id: str, note: str, changed_by: str = 'customer') -> bool:
|
|
||||||
"""
|
|
||||||
客户添加需求备注(支持后续增加细节)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 任务 ID
|
|
||||||
note: 备注内容
|
|
||||||
changed_by: 修改者(customer/staff)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
conn = self._get_conn()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# 获取旧备注
|
|
||||||
cursor.execute(_sql('SELECT customer_notes FROM image_tasks WHERE task_id = ?'), (task_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
old_note = row['customer_notes'] if row else ''
|
|
||||||
|
|
||||||
# 更新备注
|
|
||||||
new_note = f"{old_note}\n[{datetime.now().strftime('%m-%d %H:%M')}] {note}" if old_note else f"[{datetime.now().strftime('%m-%d %H:%M')}] {note}"
|
|
||||||
|
|
||||||
cursor.execute(_sql('''
|
|
||||||
UPDATE image_tasks
|
|
||||||
SET customer_notes = ?
|
|
||||||
WHERE task_id = ?
|
|
||||||
'''), (new_note, task_id))
|
|
||||||
|
|
||||||
# 记录变更历史
|
|
||||||
cursor.execute(_sql('''
|
|
||||||
INSERT INTO task_requirement_changes (
|
|
||||||
task_id, change_type, old_value, new_value, changed_at, changed_by
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
'''), (
|
|
||||||
task_id,
|
|
||||||
'add_note',
|
|
||||||
old_note or '无',
|
|
||||||
note,
|
|
||||||
_now_str(),
|
|
||||||
changed_by
|
|
||||||
))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
logger.info(f"客户添加备注成功:{task_id}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"添加客户备注失败:{e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def modify_operation(self, task_id: str, new_operation: str, changed_by: str = 'customer') -> bool:
|
|
||||||
"""
|
|
||||||
修改操作类型(客户后续修改需求)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 任务 ID
|
|
||||||
new_operation: 新操作类型
|
|
||||||
changed_by: 修改者
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 是否成功
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
conn = self._get_conn()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# 获取旧操作
|
|
||||||
cursor.execute(_sql('SELECT operation FROM image_tasks WHERE task_id = ?'), (task_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
old_operation = row['operation'] if row else ''
|
|
||||||
|
|
||||||
# 更新操作
|
|
||||||
cursor.execute(_sql('''
|
|
||||||
UPDATE image_tasks
|
|
||||||
SET operation = ?
|
|
||||||
WHERE task_id = ?
|
|
||||||
'''), (new_operation, task_id))
|
|
||||||
|
|
||||||
# 记录变更历史
|
|
||||||
cursor.execute(_sql('''
|
|
||||||
INSERT INTO task_requirement_changes (
|
|
||||||
task_id, change_type, old_value, new_value, changed_at, changed_by
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
'''), (
|
|
||||||
task_id,
|
|
||||||
'modify_operation',
|
|
||||||
old_operation,
|
|
||||||
new_operation,
|
|
||||||
_now_str(),
|
|
||||||
changed_by
|
|
||||||
))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
logger.info(f"修改操作类型成功:{task_id} -> {new_operation}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"修改操作类型失败:{e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_requirement_history(self, task_id: str) -> List[dict]:
|
|
||||||
"""获取需求变更历史"""
|
|
||||||
try:
|
|
||||||
conn = self._get_conn()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute(_sql('''
|
|
||||||
SELECT * FROM task_requirement_changes
|
|
||||||
WHERE task_id = ?
|
|
||||||
ORDER BY changed_at DESC
|
|
||||||
'''), (task_id,))
|
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"查询需求历史失败:{e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_pending_tasks(self) -> List[dict]:
|
|
||||||
"""获取所有待处理任务"""
|
|
||||||
return self.get_customer_tasks('', 'pending')
|
|
||||||
|
|
||||||
def increment_retry(self, task_id: str) -> int:
|
|
||||||
"""增加重试次数"""
|
|
||||||
try:
|
|
||||||
conn = self._get_conn()
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute(_sql('''
|
|
||||||
UPDATE image_tasks
|
|
||||||
SET retry_count = retry_count + 1
|
|
||||||
WHERE task_id = ?
|
|
||||||
'''), (task_id,))
|
|
||||||
|
|
||||||
cursor.execute(_sql('SELECT retry_count FROM image_tasks WHERE task_id = ?'), (task_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return row['retry_count'] if row else 0
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"增加重试次数失败:{e}")
|
|
||||||
return 999
|
|
||||||
|
|
||||||
# 单例
|
# 单例
|
||||||
_task_manager: Optional[ImageTaskManager] = None
|
db = ImageTaskManager()
|
||||||
|
|
||||||
def get_image_task_manager() -> ImageTaskManager:
|
|
||||||
"""获取图片任务管理器单例"""
|
|
||||||
global _task_manager
|
|
||||||
if _task_manager is None:
|
|
||||||
_task_manager = ImageTaskManager()
|
|
||||||
return _task_manager
|
|
||||||
|
|||||||
0
chat_log_db/chats.db → legacy/chat_log_db/chats.db
Executable file → Normal file
889
legacy/customer_db/customers.json
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
utils/daily_summary.py → legacy/daily_summary.py
Executable file → Normal file
0
db/deal_outcome_db.py → legacy/deal_outcome_db.py
Executable file → Normal file
0
db/designer_roster_db.py → legacy/designer_roster_db.py
Executable file → Normal file
0
utils/intent_analyzer.py → legacy/intent_analyzer.py
Executable file → Normal file
0
mail/__init__.py → legacy/mail/__init__.py
Executable file → Normal file
0
mail/email_receiver.py → legacy/mail/email_receiver.py
Executable file → Normal file
0
mail/email_sender.py → legacy/mail/email_sender.py
Executable file → Normal file
0
core/pydantic_ai_agent.py → legacy/pydantic_ai_agent.py
Executable file → Normal file
0
results/20260225211854.jpg → legacy/results/20260225211854.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 212 KiB |
0
results/debug_7debc0124b0441da9945feaeceef93b1.jpg → legacy/results/debug_7debc0124b0441da9945feaeceef93b1.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 231 KiB |
0
results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg → legacy/results/pfix_final_73bc9c0c4bed4be198b200158be6f813.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
0
results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg → legacy/results/pfix_final_7debc0124b0441da9945feaeceef93b1.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.5 MiB |
0
results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg → legacy/results/pfix_final_b3dd76cbc37e403ca9425ece8ba2ebcd.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 855 KiB After Width: | Height: | Size: 855 KiB |
0
results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg → legacy/results/pfix_final_bc3c45fd447749f38f62dbb87a942aba.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 810 KiB After Width: | Height: | Size: 810 KiB |
0
results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg → legacy/results/pfix_final_d9679c27640b43c18b9f590047e6c2dd.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 883 KiB After Width: | Height: | Size: 883 KiB |
0
results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg → legacy/results/resize_95152a96618146738c3e6a12a6a6d9d8.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
0
results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg → legacy/results/resize_d9ef87fa8de14b0b8d030067d0de163e.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
0
results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg → legacy/results/result_2d5b47961e7b42eabe2fd7beb8c9be1f.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
0
results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg → legacy/results/result_3e60b204f3a748eabb41a05cc28e1a11.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
0
results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg → legacy/results/result_4cd07206b2d24c21a81c3d45a3c4e16f.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
0
results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg → legacy/results/result_5c19d435fc8e4b2caa03c589f53d61ac.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
0
results/result_90eaf777934445af81abbd60fe4778c5.jpg → legacy/results/result_90eaf777934445af81abbd60fe4778c5.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |