refactor: migrate workflow to v2 core and archive legacy modules
This commit is contained in:
300
legacy/daily_summary.py
Normal file
300
legacy/daily_summary.py
Normal file
@@ -0,0 +1,300 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user