refactor: migrate workflow to v2 core and archive legacy modules

This commit is contained in:
2026-03-04 21:52:24 +08:00
parent e1ce17f2aa
commit fa61b11b02
156 changed files with 1781 additions and 2066 deletions

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
初始化设计师派单数据SQLite
同一设计师在不同店铺对应不同 group_id。
用法:
python scripts/init_designer_roster.py
# 按提示添加设计师和店铺分组,或直接修改下方示例后运行
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from db.designer_roster_db import add_designer, set_designer_shop, list_designers, update_online
def init_example():
"""示例:添加设计师,同一人在不同店铺不同分组"""
# 设计师A在 小威哥1216 用分组 20252916034在 另一店铺 用 12345678
aid = add_designer("设计师A", "user_a")
set_designer_shop(aid, "小威哥1216", "20252916034")
set_designer_shop(aid, "另一店铺", "12345678")
# 设计师B只在 小威哥1216
bid = add_designer("设计师B", "user_b")
set_designer_shop(bid, "小威哥1216", "99998888")
# 可选:手动标记上线(否则等企微群解析)
update_online("user_a", True)
update_online("user_b", True)
print("示例数据已写入")
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "example":
init_example()
elif len(sys.argv) > 1 and sys.argv[1] == "list":
for d in list_designers():
print(f"{d['name']} ({d['wechat_user_id']}) 在线={d['is_online']}")
for shop, gid in d["shops"].items():
print(f" - {shop} -> {gid}")
else:
print("用法: python scripts/init_designer_roster.py example # 写入示例")
print(" python scripts/init_designer_roster.py list # 查看当前数据")

View File

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

View File

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

View File

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

View File

@@ -1,209 +0,0 @@
# -*- coding: utf-8 -*-
"""
多进程异步并行启动器
按客户 ID hash 分配到不同进程,实现真正的并行处理
"""
import os
import sys
import signal
import logging
from multiprocessing import Process, cpu_count
from typing import List, Dict
import hashlib
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] %(levelname)s: %(message)s'
)
logger = logging.getLogger(__name__)
class WorkerProcess:
"""工作进程"""
def __init__(self, worker_id: int, shard_keys: List[str], num_workers: int, enable_agent: bool = True):
self.worker_id = worker_id
self.shard_keys = shard_keys
self.num_workers = max(1, int(num_workers))
self.enable_agent = enable_agent
self.process = None
def start(self):
"""启动工作进程"""
self.process = Process(
target=self._run,
args=(self.worker_id, self.shard_keys, self.num_workers, self.enable_agent),
name=f"ai-cs-worker-{self.worker_id}"
)
self.process.start()
logger.info(f"Worker {self.worker_id} 启动 (PID: {self.process.pid})")
def _run(self, worker_id: int, shard_keys: List[str], num_workers: int, enable_agent: bool):
"""工作进程入口"""
try:
# 设置进程环境变量
os.environ['AI_CS_WORKER_ID'] = str(worker_id)
os.environ['AI_CS_WORKER_COUNT'] = str(max(1, int(num_workers)))
os.environ['AI_CS_SHARD_KEYS'] = ','.join(shard_keys)
# 导入并启动 WebSocket 客户端
from core.websocket_client import QingjianAPIClient
logger.info(f"Worker {worker_id} 初始化 Agent...")
client = QingjianAPIClient(enable_agent=enable_agent)
# 只处理分配给这个 worker 的客户
client.shard_keys = set(shard_keys)
logger.info(f"Worker {worker_id} 开始处理消息...")
import asyncio
asyncio.run(client.connect())
except KeyboardInterrupt:
logger.info(f"Worker {worker_id} 收到退出信号")
except Exception as e:
logger.error(f"Worker {worker_id} 异常:{e}")
import traceback
traceback.print_exc()
def stop(self):
"""停止工作进程"""
if self.process and self.process.is_alive():
self.process.terminate()
self.process.join(timeout=5)
logger.info(f"Worker {self.worker_id} 已停止")
class Coordinator:
"""协调器 - 管理多个工作进程"""
def __init__(self, num_workers: int = None, enable_agent: bool = True):
self.num_workers = num_workers or max(2, cpu_count())
self.workers: List[WorkerProcess] = []
self.running = False
self._stopping = False
self.enable_agent = enable_agent
def _get_shard_key(self, acc_id: str, from_id: str) -> int:
"""根据店铺 ID + 客户 ID 计算分片 key"""
key = f"{acc_id}:{from_id}"
hash_value = int(hashlib.md5(key.encode()).hexdigest(), 16)
return hash_value % self.num_workers
def _load_customer_shards(self) -> Dict[int, List[str]]:
"""加载客户分片信息
Returns:
{shard_id: [customer_key1, customer_key2, ...]}
"""
# 从数据库或配置文件加载客户列表
# 这里简化处理,实际应该从数据库加载活跃客户
shards = {i: [] for i in range(self.num_workers)}
# TODO: 从数据库加载活跃客户列表
# customers = db.query(...).all()
# for customer in customers:
# shard_id = self._get_shard_key(customer.acc_id, customer.from_id)
# shards[shard_id].append(f"{customer.acc_id}:{customer.from_id}")
logger.info(f"已加载 {sum(len(v) for v in shards.values())} 个客户分片")
return shards
def start(self):
"""启动所有工作进程"""
logger.info(f"启动协调器,工作进程数:{self.num_workers}")
shards = self._load_customer_shards()
# 启动工作进程
for worker_id in range(self.num_workers):
worker = WorkerProcess(
worker_id=worker_id,
shard_keys=shards.get(worker_id, []),
num_workers=self.num_workers,
enable_agent=self.enable_agent
)
worker.start()
self.workers.append(worker)
self.running = True
# 注册信号处理
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
# 监控工作进程
self._monitor_workers()
def _monitor_workers(self):
"""监控工作进程健康状态"""
import time
while self.running:
# 检查工作进程是否存活
for worker in self.workers:
if worker.process and not worker.process.is_alive():
logger.warning(f"Worker {worker.worker_id} 已退出,尝试重启...")
# 重启工作进程
worker.start()
time.sleep(10) # 每 10 秒检查一次
def _signal_handler(self, signum, frame):
"""信号处理"""
if self._stopping:
return
self._stopping = True
logger.info(f"收到信号 {signum},正在停止所有工作进程...")
self.stop()
def stop(self):
"""停止所有工作进程"""
if self._stopping and not self.running and not any(w.process and w.process.is_alive() for w in self.workers):
return
self._stopping = True
self.running = False
for worker in self.workers:
worker.stop()
logger.info("所有工作进程已停止")
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(description='AI 客服多进程启动器')
parser.add_argument(
'--workers',
type=int,
default=None,
help='工作进程数默认CPU 核心数)'
)
args = parser.parse_args()
logger.info("=" * 60)
logger.info("AI 客服系统 - 多进程异步并行模式")
logger.info("=" * 60)
coordinator = Coordinator(num_workers=args.workers)
try:
coordinator.start()
except KeyboardInterrupt:
logger.info("收到退出信号")
coordinator.stop()
except Exception as e:
logger.error(f"启动失败:{e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

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