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,300 +0,0 @@
# -*- coding: utf-8 -*-
"""
每日聊天汇总定时任务
- 每天 23:50 自动统计当日各店铺数据
- 用 AI 生成自然语言摘要
- 发送到企业微信 Webhook + QQ 邮件
"""
import asyncio
import os
import json
import logging
from datetime import datetime, date, timedelta
from typing import Optional
import httpx
from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger("cs_agent")
WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "")
SUMMARY_EMAIL = os.getenv("SUMMARY_EMAIL", "") # 收摘要的邮箱
SEND_HOUR = int(os.getenv("SUMMARY_HOUR", "23"))
SEND_MINUTE = int(os.getenv("SUMMARY_MINUTE", "50"))
# ──────────────────────────────────────────
# 统计数据整理
# ──────────────────────────────────────────
def _build_stats_text(target_date: str = "") -> str:
"""整理今日数据,返回给 AI 的原始统计文本"""
from db import chat_log_db as db
from db.deal_outcome_db import get_daily_summary
if not target_date:
target_date = datetime.now().strftime("%Y-%m-%d")
stats = db.get_daily_stats(target_date)
convs = db.get_daily_conversations(target_date)
deal_sum = get_daily_summary(target_date)
if not stats:
return f"{target_date} 当日无任何聊天记录。"
# 按 acc_id 分组对话片段
conv_map: dict[str, list] = {}
for c in convs:
aid = c.get("acc_id") or "未知店铺"
conv_map.setdefault(aid, []).append(c)
lines = [f"{target_date} 各店铺数据】\n"]
# 成交/未成交汇总(供 AI 摘要与数据分析)
lines.append("【成交与未成交】")
lines.append(f" 成交:{deal_sum['成交数']} 笔,金额 {deal_sum['成交金额']:.0f}")
lines.append(f" 未成交:{deal_sum['未成交数']}")
if deal_sum["未成交原因分布"]:
for reason, cnt in deal_sum["未成交原因分布"].items():
lines.append(f" - {reason}{cnt}")
if deal_sum["成交明细"]:
for o in deal_sum["成交明细"][:5]:
r = "让价后" if o.get("discount_given") else "直接"
lines.append(f"{o.get('customer_name', '')[:6]} {r}成交 {o.get('amount', 0):.0f}")
if deal_sum["未成交明细"]:
for o in deal_sum["未成交明细"][:5]:
lines.append(f"{o.get('customer_name', '')[:6]} {o.get('reason', '')}")
lines.append("")
for s in stats:
acc = s.get("acc_id") or "未知店铺"
plat = s.get("platform") or ""
label = f"{acc}{plat}" if plat else acc
lines.append(f"▶ 店铺:{label}")
lines.append(f" 接待客户:{s['unique_customers']} 人,共 {s['total_msgs']} 条消息(收 {s['recv']}{s['sent']}")
lines.append(f" 首条:{(s.get('first_msg') or '')[-8:-3]} 末条:{(s.get('last_msg') or '')[-8:-3]}")
shop_convs = conv_map.get(acc, [])
for c in shop_convs[:6]: # 最多展示6个客户片段
name = c.get("customer_name") or c.get("customer_id", "")[:8]
snippet = (c.get("snippet") or "")[:120]
lines.append(f" · {name}{c['msg_count']}条){snippet}")
if len(shop_convs) > 6:
lines.append(f" ... 还有 {len(shop_convs)-6} 位客户")
lines.append("")
return "\n".join(lines)
# ──────────────────────────────────────────
# AI 生成摘要
# ──────────────────────────────────────────
async def _ai_summary(raw_text: str) -> str:
"""调用 AI 把统计文本转成自然语言日报"""
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
)
model = os.getenv("OPENAI_MODEL", "doubao-seed-2-0-lite-260215")
resp = await client.chat.completions.create(
model=model,
messages=[
{
"role": "system",
"content": (
"你是一名电商运营助理。根据下面的客服聊天数据,"
"为老板写一份简洁的当日运营日报200字以内"
"要包含:接待总人数、各店铺情况、有无成交或异常情况。"
"语气轻松,像发给老板的微信消息,不需要标题。"
),
},
{"role": "user", "content": raw_text},
],
max_tokens=300,
temperature=0.5,
)
return resp.choices[0].message.content.strip()
except Exception as e:
# AI 失败就直接返回原始统计
return raw_text
# ──────────────────────────────────────────
# 推送:企业微信
# ──────────────────────────────────────────
async def _send_wechat(content: str):
"""推送到企业微信群机器人markdown 格式,单条 ≤4096 字节自动分段)"""
if not WECHAT_WEBHOOK:
logger.info("[DailySummary] 未配置 WECHAT_WEBHOOK跳过推送")
return
# 企业微信单条 markdown 限 4096 字节,超长自动分段
encoded = content.encode("utf-8")
chunks = []
while encoded:
chunk = encoded[:3800].decode("utf-8", errors="ignore")
chunks.append(chunk)
encoded = encoded[len(chunk.encode("utf-8")):]
async with httpx.AsyncClient(timeout=10) as client:
for i, chunk in enumerate(chunks):
payload = {"msgtype": "markdown", "markdown": {"content": chunk}}
try:
resp = await client.post(WECHAT_WEBHOOK, json=payload)
data = resp.json()
if data.get("errcode") == 0:
logger.info("[DailySummary] 企业微信推送成功(第%s段)", i + 1)
else:
logger.warning("[DailySummary] 企业微信推送失败: %s", data)
except Exception as e:
logger.exception("[DailySummary] 企业微信推送异常: %s", e)
# ──────────────────────────────────────────
# 推送:邮件
# ──────────────────────────────────────────
def _send_email(subject: str, body: str):
"""发送日报邮件"""
if not SUMMARY_EMAIL:
return
try:
from mail.email_sender import email_sender
import smtplib
from email.mime.text import MIMEText
from email.header import Header
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = Header(subject, "utf-8").encode()
msg["From"] = f"{Header(email_sender.sender_name, 'utf-8').encode()} <{email_sender.smtp_user}>"
msg["To"] = SUMMARY_EMAIL
with smtplib.SMTP(email_sender.smtp_host, email_sender.smtp_port) as s:
s.starttls()
s.login(email_sender.smtp_user, email_sender.smtp_password)
s.sendmail(email_sender.smtp_user, [SUMMARY_EMAIL], msg.as_string())
logger.info("[DailySummary] 日报邮件已发送至 %s", SUMMARY_EMAIL)
except Exception as e:
logger.exception("[DailySummary] 日报邮件发送失败: %s", e)
# ──────────────────────────────────────────
# 企业微信 Markdown 排版
# ──────────────────────────────────────────
def _build_wechat_markdown(title: str, ai_text: str, raw_text: str, target_date: str = "") -> str:
"""
构建符合企业微信规范的 markdown 内容。
支持:**bold**、<font color="...">、> 引用、``` 代码块、- 列表
不支持:<details>、<summary>、HTML 标签(除 font/br
"""
from db import chat_log_db as db
from db.deal_outcome_db import get_daily_summary
date = target_date or datetime.now().strftime("%Y-%m-%d")
stats = db.get_daily_stats(date)
deal_sum = get_daily_summary(date)
lines = [f"## {title}\n"]
# AI 摘要部分
lines.append("> " + ai_text.replace("\n", "\n> "))
lines.append("")
# 成交/未成交
lines.append("**📈 成交与未成交**")
lines.append(f"- 成交 **{deal_sum['成交数']}** 笔 · 金额 **{deal_sum['成交金额']:.0f}** 元")
lines.append(f"- 未成交 **{deal_sum['未成交数']}** 笔")
if deal_sum["未成交原因分布"]:
for reason, cnt in deal_sum["未成交原因分布"].items():
lines.append(f" - {reason}{cnt}")
lines.append("")
# 各店铺数据表格(企业微信不支持 | 表格,用列表代替)
if stats:
lines.append("**📋 各店铺明细**")
for s in stats:
acc = s.get("acc_id") or "未知店铺"
plat = s.get("platform") or ""
label = f"{acc}{plat}" if plat else acc
first = (s.get("first_msg") or "")[-8:-3]
last = (s.get("last_msg") or "")[-8:-3]
lines.append(
f"- <font color=\"info\">{label}</font> "
f"接待 **{s['unique_customers']}** 人 · "
f"消息 {s['total_msgs']} 条(收{s['recv']}/发{s['sent']}"
f" {first}~{last}"
)
lines.append("")
lines.append(f"<font color=\"comment\">发送时间:{datetime.now().strftime('%H:%M:%S')}</font>")
return "\n".join(lines)
# ──────────────────────────────────────────
# 主入口:生成并推送日报
# ──────────────────────────────────────────
async def send_daily_summary(target_date: str = ""):
"""生成并推送当日汇总"""
if not target_date:
target_date = datetime.now().strftime("%Y-%m-%d")
logger.info("[DailySummary] 开始生成 %s 日报...", target_date)
raw_text = _build_stats_text(target_date)
ai_text = await _ai_summary(raw_text)
title = f"📊 {target_date} 客服日报"
# ── 企业微信 markdown不支持 <details>,用标准语法)──
wechat_md = _build_wechat_markdown(title, ai_text, raw_text, target_date)
await _send_wechat(wechat_md)
# ── 邮件:纯文本 ──
email_body = f"{ai_text}\n\n{'='*40}\n\n{raw_text}"
_send_email(title, email_body)
logger.info("[DailySummary] 日报推送完成")
return ai_text
# ──────────────────────────────────────────
# 定时调度(由 websocket_client 启动)
# ──────────────────────────────────────────
async def scheduler():
"""每天 SEND_HOUR:SEND_MINUTE 触发日报"""
logger.info("[DailySummary] 定时日报已启动,发送时间 %02d:%02d", SEND_HOUR, SEND_MINUTE)
sent_today: Optional[str] = None # 记录已发日期,防重复
while True:
now = datetime.now()
today = now.strftime("%Y-%m-%d")
if now.hour == SEND_HOUR and now.minute == SEND_MINUTE and sent_today != today:
sent_today = today
try:
await send_daily_summary(today)
except Exception as e:
logger.exception("[DailySummary] 日报生成出错: %s", e)
# 每 30 秒检查一次
await asyncio.sleep(30)
# ──────────────────────────────────────────
# 命令行手动触发
# ──────────────────────────────────────────
if __name__ == "__main__":
import sys
target = sys.argv[1] if len(sys.argv) > 1 else ""
result = asyncio.run(send_daily_summary(target))
logger.info("\n=== AI 摘要 ===")
logger.info(result)

View File

@@ -1,159 +0,0 @@
# -*- coding: utf-8 -*-
"""
语义匹配 - 用 embedding 做意图/情绪识别
配置 EMBEDDING_MODEL 后启用,否则回退到关键词
"""
import os
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
# 意图模板(用于 embedding 相似度匹配)
INTENT_TEMPLATES = {
"询价": "我想问一下价格多少钱",
"发图": "我发图给你看看",
"砍价": "能不能便宜点太贵了",
"批量": "我要做很多张图批量",
"加急": "能不能快点很急",
"售后": "已经付款了什么时候好",
"修改": "不满意要改一下",
"转接": "我要退款投诉",
"打招呼": "你好在吗有人吗",
}
EMOTION_TEMPLATES = {
"平静": "好的谢谢",
"着急": "快点啊很急",
"不满": "怎么这么慢不满意",
"砍价": "太贵了便宜点",
}
_template_embeddings: dict = {}
@dataclass
class IntentDecision:
intent: str = ""
source: str = "none" # embedding / keyword / none
score: float = 0.0
def _get_embedding(text: str, cache_key: str = None) -> Optional[list]:
"""调用 embedding API失败返回 None。cache_key 用于缓存模板向量"""
model = os.getenv("EMBEDDING_MODEL", "")
if not model:
return None
if cache_key and cache_key in _template_embeddings:
return _template_embeddings[cache_key]
try:
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
)
resp = client.embeddings.create(model=model, input=text[:2000])
emb = resp.data[0].embedding
if cache_key:
_template_embeddings[cache_key] = emb
return emb
except Exception as e:
logger.debug(f"embedding 失败: {e}")
return None
def _cosine_sim(a: list, b: list) -> float:
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(y * y for y in b) ** 0.5
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
def detect_intent_embedding(msg: str) -> Optional[str]:
"""用 embedding 检测意图,未配置或失败返回 None。"""
decision = detect_intent_embedding_decision(msg)
return decision.intent or None
def detect_intent_embedding_decision(msg: str) -> IntentDecision:
"""返回 embedding 意图决策(含分值)。"""
msg_emb = _get_embedding(msg)
if not msg_emb:
return IntentDecision()
best_intent, best_score = "", 0.0
for intent, template in INTENT_TEMPLATES.items():
tpl_emb = _get_embedding(template, cache_key=f"intent_{intent}")
if not tpl_emb:
continue
sim = _cosine_sim(msg_emb, tpl_emb)
if sim > best_score:
best_score = sim
best_intent = intent
if best_score > 0.6:
return IntentDecision(intent=best_intent, source="embedding", score=float(best_score))
return IntentDecision()
def detect_emotion_embedding(msg: str) -> Optional[str]:
"""用 embedding 检测情绪"""
msg_emb = _get_embedding(msg)
if not msg_emb:
return None
best_emotion, best_score = "", 0.0
for emotion, template in EMOTION_TEMPLATES.items():
tpl_emb = _get_embedding(template, cache_key=f"emotion_{emotion}")
if not tpl_emb:
continue
sim = _cosine_sim(msg_emb, tpl_emb)
if sim > best_score:
best_score = sim
best_emotion = emotion
return best_emotion if best_score > 0.55 else None
def detect_intent_keywords(msg: str) -> str:
"""关键词回退:无 embedding 时使用"""
m = (msg or "").strip().lower()
if any(k in m for k in ["退款", "退货", "投诉"]):
return "转接"
if any(k in m for k in ["多张", "批量", "很多", "几十张"]):
return "批量"
if any(k in m for k in ["快点", "加急", "很急", "着急"]):
return "加急"
if any(k in m for k in ["便宜", "", "少点", "打折"]):
return "砍价"
if any(k in m for k in ["", "修改", "不满意"]):
return "修改"
if any(k in m for k in ["多少钱", "价格", "报价", "多钱", "收费", "怎么收费", "咋收费"]):
return "询价"
if any(k in m for k in ["在吗", "你好", "有人"]):
return "打招呼"
return ""
def detect_intent(msg: str) -> IntentDecision:
"""
AI 意图判定 + 规则兜底:
1) 有 embedding 配置时先走 embedding。
2) 失败/低置信时回退关键词规则。
"""
text = (msg or "").strip()
if not text:
return IntentDecision()
try:
emb_decision = detect_intent_embedding_decision(text)
except Exception:
emb_decision = IntentDecision()
if emb_decision.intent:
return emb_decision
kw_intent = detect_intent_keywords(text)
if kw_intent:
return IntentDecision(intent=kw_intent, source="keyword", score=0.0)
return IntentDecision()

View File

@@ -1,150 +0,0 @@
# -*- coding: utf-8 -*-
"""
客服对话推送到企业微信群 - 客户消息与AI回复成对发送保持上下文
"""
import asyncio
import os
import logging
from datetime import datetime
import httpx
from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger("cs_agent")
_last_push: dict[tuple[str, str], tuple[str, str, float]] = {}
def _get_webhook() -> str:
"""优先从 config 读取,与健康检查/日报保持一致"""
try:
from config.config import WECHAT_WEBHOOK
return WECHAT_WEBHOOK or os.getenv("WECHAT_WEBHOOK", "")
except Exception:
return os.getenv("WECHAT_WEBHOOK", "")
def _truncate(text: str, max_len: int = 200) -> str:
"""截断过长内容"""
if not text:
return ""
text = str(text).strip()
if len(text) > max_len:
return text[:max_len] + "..."
return text
def _get_recent_conversation(customer_id: str, acc_id: str, last_n: int = 8) -> list:
"""获取近期对话(同店铺),保持连贯上下文"""
try:
from db.chat_log_db import get_recent_conversation
return get_recent_conversation(customer_id, acc_id, limit=last_n)
except Exception:
logger.debug("[WechatChatLog] 获取近期对话失败,返回空列表", exc_info=True)
return []
async def push_chat_to_wechat(
customer_name: str,
customer_id: str,
acc_id: str,
customer_msg: str,
reply_msg: str,
goods_name: str = "",
):
"""
将客户消息与AI回复推送到企业微信群附带近期对话保持连贯。
"""
webhook = _get_webhook()
if not webhook:
return
# 去重:同一客户+店铺,若客户消息与回复完全相同且在窗口期内,则跳过
try:
import time
key = (customer_id or "", acc_id or "")
now = time.time()
last = _last_push.get(key)
if last:
last_customer_msg, last_reply_msg, last_ts = last
if (last_customer_msg or "") == (customer_msg or "") and (last_reply_msg or "") == (reply_msg or ""):
if now - last_ts < 30:
return
_last_push[key] = ((customer_msg or ""), (reply_msg or ""), now)
except Exception:
logger.debug("[WechatChatLog] 去重检查异常,忽略本次去重", exc_info=True)
reply_msg = _truncate(reply_msg, 300)
ts = datetime.now().strftime("%H:%M")
shop = acc_id or "未知店铺"
name = (customer_name or customer_id or "客户")[:12]
lines = [f"**📩 {ts} | {shop}**"]
if goods_name:
lines.append(f"**商品** {_truncate(goods_name, 80)}")
if customer_id:
lines.append(f"**客户ID** {customer_id}")
lines.append("")
# 附带近期对话,保持连贯
recent = _get_recent_conversation(customer_id, acc_id, last_n=8)
last_line = None
for m in recent:
role = customer_id if m.get("direction") == "in" else "客服"
msg = _truncate((m.get("message") or "").strip(), 120)
if msg:
line = f"{role}{msg}"
# 防止日志中的重复记录在企微里连续刷屏
if line == last_line:
continue
lines.append(line)
last_line = line
# 当前回复(可能已在 recent 中有客户消息,客服回复是新的)
lines.append(f"客服:{reply_msg or '(无回复)'}")
content = "\n".join(lines)
enc = content.encode("utf-8")
if len(enc) > 3800:
content = enc[:3750].decode("utf-8", errors="ignore") + "\n...(略)"
try:
async with httpx.AsyncClient(timeout=8) as client:
resp = await client.post(
webhook,
json={"msgtype": "markdown", "markdown": {"content": content}},
)
data = resp.json()
if data.get("errcode") == 0:
return
else:
logger.warning("[WechatChatLog] 推送失败: %s", data)
except Exception as e:
logger.exception("[WechatChatLog] 推送异常: %s", e)
async def send_morning_startup():
"""每天早上8点发送客服启动消息到企微群"""
webhook = _get_webhook()
if not webhook:
return
ts = datetime.now().strftime("%Y-%m-%d %H:%M")
content = f"**☀️ 客服已启动**\n{ts}"
try:
async with httpx.AsyncClient(timeout=8) as client:
await client.post(
webhook,
json={"msgtype": "markdown", "markdown": {"content": content}},
)
logger.info("[WechatChatLog] 早8点启动消息已发送")
except Exception as e:
logger.exception("[WechatChatLog] 启动消息发送失败: %s", e)
async def morning_startup_scheduler():
"""每天 8:00 发送启动消息"""
logger.info("[WechatChatLog] 早8点启动消息定时任务已启动")
sent_today = None
while True:
now = datetime.now()
today = now.strftime("%Y-%m-%d")
if now.hour == 8 and now.minute == 0 and sent_today != today:
sent_today = today
await send_morning_startup()
await asyncio.sleep(30)