Files
tw/utils/daily_summary.py

301 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)