301 lines
12 KiB
Python
301 lines
12 KiB
Python
# -*- 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)
|