init
This commit is contained in:
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
BIN
utils/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
utils/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/api_cost_tracker.cpython-310.pyc
Normal file
BIN
utils/__pycache__/api_cost_tracker.cpython-310.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/content_filter.cpython-310.pyc
Normal file
BIN
utils/__pycache__/content_filter.cpython-310.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/daily_summary.cpython-310.pyc
Normal file
BIN
utils/__pycache__/daily_summary.cpython-310.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/designer_roster.cpython-310.pyc
Normal file
BIN
utils/__pycache__/designer_roster.cpython-310.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/health_check.cpython-310.pyc
Normal file
BIN
utils/__pycache__/health_check.cpython-310.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/image_queue.cpython-310.pyc
Normal file
BIN
utils/__pycache__/image_queue.cpython-310.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/intent_analyzer.cpython-310.pyc
Normal file
BIN
utils/__pycache__/intent_analyzer.cpython-310.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/service_base.cpython-310.pyc
Normal file
BIN
utils/__pycache__/service_base.cpython-310.pyc
Normal file
Binary file not shown.
BIN
utils/__pycache__/wechat_chat_log.cpython-310.pyc
Normal file
BIN
utils/__pycache__/wechat_chat_log.cpython-310.pyc
Normal file
Binary file not shown.
95
utils/api_cost_tracker.py
Normal file
95
utils/api_cost_tracker.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
API 成本统计 - 记录调用成本,超预算告警
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
COST_FILE = ROOT / "config" / ".api_cost.json"
|
||||
BUDGET_DAILY = float(os.getenv("API_COST_BUDGET_DAILY", "0")) # 0=不限制
|
||||
BUDGET_MONTHLY = float(os.getenv("API_COST_BUDGET_MONTHLY", "0"))
|
||||
|
||||
# 单次调用预估成本(元,可按实际调整)
|
||||
COST_PER_CALL = {
|
||||
"gemini_extract": 0.02,
|
||||
"gemini_qa": 0.01,
|
||||
"gemini_vision": 0.015,
|
||||
"openai_chat": 0.02,
|
||||
"openai_embedding": 0.001,
|
||||
"qwen_enhance": 0.05,
|
||||
}
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if not COST_FILE.exists():
|
||||
return {"daily": {}, "monthly": {}}
|
||||
try:
|
||||
with open(COST_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {"daily": {}, "monthly": {}}
|
||||
|
||||
|
||||
def _save(data: dict):
|
||||
COST_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(COST_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def record(service: str, count: int = 1, cost: Optional[float] = None):
|
||||
"""记录一次 API 调用"""
|
||||
c = cost if cost is not None else COST_PER_CALL.get(service, 0.01) * count
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
month = datetime.now().strftime("%Y-%m")
|
||||
data = _load()
|
||||
data["daily"][today] = data["daily"].get(today, 0) + c
|
||||
data["monthly"][month] = data["monthly"].get(month, 0) + c
|
||||
_save(data)
|
||||
return c
|
||||
|
||||
|
||||
def get_today_cost() -> float:
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
data = _load()
|
||||
return data["daily"].get(today, 0)
|
||||
|
||||
|
||||
def get_month_cost() -> float:
|
||||
month = datetime.now().strftime("%Y-%m")
|
||||
data = _load()
|
||||
return data["monthly"].get(month, 0)
|
||||
|
||||
|
||||
async def check_budget_alert():
|
||||
"""检查是否超预算,超则企微告警"""
|
||||
if BUDGET_DAILY <= 0 and BUDGET_MONTHLY <= 0:
|
||||
return
|
||||
try:
|
||||
from config.config import WECHAT_WEBHOOK
|
||||
if not WECHAT_WEBHOOK:
|
||||
return
|
||||
import httpx
|
||||
today = get_today_cost()
|
||||
month = get_month_cost()
|
||||
msg_parts = []
|
||||
if BUDGET_DAILY > 0 and today >= BUDGET_DAILY:
|
||||
msg_parts.append(f"⚠️ 今日 API 成本 {today:.2f} 元 ≥ 预算 {BUDGET_DAILY} 元")
|
||||
if BUDGET_MONTHLY > 0 and month >= BUDGET_MONTHLY:
|
||||
msg_parts.append(f"⚠️ 本月 API 成本 {month:.2f} 元 ≥ 预算 {BUDGET_MONTHLY} 元")
|
||||
if not msg_parts:
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
await client.post(WECHAT_WEBHOOK, json={
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": "\n".join(msg_parts)}
|
||||
})
|
||||
logger.info(f"[成本] 告警已发送: {msg_parts}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[成本] 告警失败: {e}")
|
||||
135
utils/content_filter.py
Normal file
135
utils/content_filter.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
敏感词过滤 - 党政/暴力/血腥/黄色
|
||||
敏感图片检测 - 暴力/血腥/色情/政治敏感
|
||||
合规、风险可控
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import base64
|
||||
from typing import Tuple
|
||||
|
||||
# 敏感词库(按类别,可扩展)
|
||||
_SENSITIVE_PATTERNS = {
|
||||
"党政": [
|
||||
r"习近平", r"共产党", r"党中央", r"政治局", r"六四", r"天安门",
|
||||
r"法轮功", r"台独", r"藏独", r"疆独", r"邪教",
|
||||
],
|
||||
"暴力": [
|
||||
r"杀人", r"砍人", r"捅人", r"枪击", r"爆炸", r"恐怖袭击",
|
||||
r"肢解", r"碎尸", r"虐杀", r"血洗",
|
||||
],
|
||||
"血腥": [
|
||||
r"断肢", r"残肢", r"内脏", r"脑浆", r"血淋淋", r"尸块",
|
||||
r"开膛", r"割喉", r"爆头",
|
||||
],
|
||||
"黄色": [
|
||||
r"裸体", r"裸照", r"裸聊", r"色情", r" porn", r"porn",
|
||||
r"做爱", r"性交", r"约炮", r"约炮", r"嫖娼", r"卖淫",
|
||||
r"av女", r"av男", r"av片", r"av资源",
|
||||
],
|
||||
"擦边": [
|
||||
r"擦边", r"大尺度", r"性感图", r"露点", r"半裸",
|
||||
],
|
||||
}
|
||||
|
||||
_COMPILED: dict = {}
|
||||
|
||||
|
||||
def _get_compiled():
|
||||
global _COMPILED
|
||||
if not _COMPILED:
|
||||
for cat, patterns in _SENSITIVE_PATTERNS.items():
|
||||
_COMPILED[cat] = [re.compile(p, re.I) for p in patterns]
|
||||
return _COMPILED
|
||||
|
||||
|
||||
def filter_sensitive(text: str) -> tuple[str, list[str]]:
|
||||
"""
|
||||
检测文本中的敏感词。不直接替换(避免误伤),返回 (原文本, 命中的类别列表)。
|
||||
若命中则调用方应使用兜底回复或转人工。
|
||||
|
||||
Returns:
|
||||
(text, hit_categories) - hit_categories 如 ["暴力", "黄色"],空则无敏感
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return text, []
|
||||
compiled = _get_compiled()
|
||||
hit = []
|
||||
for cat, pats in compiled.items():
|
||||
for p in pats:
|
||||
if p.search(text):
|
||||
hit.append(cat)
|
||||
break
|
||||
return text, hit
|
||||
|
||||
|
||||
def should_block_reply(text: str) -> tuple[bool, str]:
|
||||
"""
|
||||
判断 AI 回复是否应拦截。
|
||||
Returns:
|
||||
(should_block, fallback_reply) - 若应拦截,返回兜底话术
|
||||
"""
|
||||
_, hit = filter_sensitive(text)
|
||||
if not hit:
|
||||
return False, ""
|
||||
return True, "好的,您稍等,我帮您确认一下"
|
||||
|
||||
|
||||
def should_block_customer(text: str) -> bool:
|
||||
"""判断客户消息是否应拒单(不处理图片、不回复)"""
|
||||
_, hit = filter_sensitive(text)
|
||||
return len(hit) > 0
|
||||
|
||||
|
||||
_SENSITIVE_IMAGE_PROMPT = """请判断这张图片是否包含以下任何敏感内容:
|
||||
- 暴力(打斗、武器、伤害)
|
||||
- 血腥(伤口、血迹、残肢等)
|
||||
- 色情(裸露、性暗示)
|
||||
- 政治敏感(旗帜、标语、敏感人物等)
|
||||
|
||||
只回答「是」或「否」。若包含任一敏感内容则答「是」,否则答「否」。"""
|
||||
|
||||
|
||||
async def is_sensitive_image(local_path: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
使用视觉模型检测图片是否包含敏感内容。
|
||||
需配置 OPENAI_API_KEY、OPENAI_BASE_URL、VISION_MODEL(或 SENSITIVE_IMAGE_MODEL)。
|
||||
|
||||
Returns:
|
||||
(is_sensitive, reason) - 若敏感则 (True, "拒单原因"),否则 (False, "")
|
||||
未配置或异常时返回 (False, ""),不拦截(fail open)
|
||||
"""
|
||||
if not os.path.exists(local_path):
|
||||
return False, ""
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
base_url = os.getenv("OPENAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4")
|
||||
model = os.getenv("SENSITIVE_IMAGE_MODEL") or os.getenv("VISION_MODEL", "glm-4v-flash")
|
||||
if not api_key:
|
||||
return False, ""
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
b64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
|
||||
resp = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
|
||||
{"type": "text", "text": _SENSITIVE_IMAGE_PROMPT},
|
||||
],
|
||||
}],
|
||||
)
|
||||
try:
|
||||
from utils.api_cost_tracker import record
|
||||
record("gemini_vision", count=1)
|
||||
except Exception:
|
||||
pass
|
||||
text = (resp.choices[0].message.content or "").strip()
|
||||
is_sensitive = "是" in text
|
||||
return is_sensitive, "图片包含敏感内容,无法处理" if is_sensitive else ""
|
||||
except Exception as e:
|
||||
print(f"[ContentFilter] 敏感图片检测异常: {e},放行")
|
||||
return False, ""
|
||||
298
utils/daily_summary.py
Normal file
298
utils/daily_summary.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
每日聊天汇总定时任务
|
||||
- 每天 23:50 自动统计当日各店铺数据
|
||||
- 用 AI 生成自然语言摘要
|
||||
- 发送到企业微信 Webhook + QQ 邮件
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
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:
|
||||
print("[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:
|
||||
print(f"[DailySummary] 企业微信推送成功(第{i+1}段)")
|
||||
else:
|
||||
print(f"[DailySummary] 企业微信推送失败: {data}")
|
||||
except Exception as e:
|
||||
print(f"[DailySummary] 企业微信推送异常: {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())
|
||||
print(f"[DailySummary] 日报邮件已发送至 {SUMMARY_EMAIL}")
|
||||
except Exception as e:
|
||||
print(f"[DailySummary] 日报邮件发送失败: {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")
|
||||
|
||||
print(f"[DailySummary] 开始生成 {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)
|
||||
|
||||
print(f"[DailySummary] 日报推送完成")
|
||||
return ai_text
|
||||
|
||||
|
||||
# ──────────────────────────────────────────
|
||||
# 定时调度(由 websocket_client 启动)
|
||||
# ──────────────────────────────────────────
|
||||
|
||||
async def scheduler():
|
||||
"""每天 SEND_HOUR:SEND_MINUTE 触发日报"""
|
||||
print(f"[DailySummary] 定时日报已启动,发送时间 {SEND_HOUR:02d}:{SEND_MINUTE:02d}")
|
||||
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:
|
||||
print(f"[DailySummary] 日报生成出错: {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))
|
||||
print("\n=== AI 摘要 ===")
|
||||
print(result)
|
||||
41
utils/designer_roster.py
Normal file
41
utils/designer_roster.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
设计师在线状态 - 请求外部查询服务(转人工时按需调用)
|
||||
|
||||
接口: GET /online 返回 {online_users: ["lz", "ZuoWei"], ...}
|
||||
本端用 online_users 同步本地 designer_online 表后派单。
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 设计师在线查询服务,.env 配置 DESIGNER_ROSTER_API,如 http://huichang.online:8001/online
|
||||
_API_URL = os.getenv("DESIGNER_ROSTER_API", "")
|
||||
|
||||
|
||||
async def _fetch_online_users() -> Set[str]:
|
||||
"""请求 GET /online,返回当前在线用户的 user_id 集合"""
|
||||
if not _API_URL:
|
||||
return set()
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(_API_URL)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
users = data.get("online_users", [])
|
||||
return set(users) if isinstance(users, list) else set()
|
||||
except Exception as e:
|
||||
logger.debug(f"设计师在线查询失败: {e}")
|
||||
return set()
|
||||
|
||||
|
||||
async def poll_and_update_roster() -> None:
|
||||
"""请求查询服务,同步在线状态到本地数据库"""
|
||||
from db.designer_roster_db import update_online, get_all_wechat_user_ids
|
||||
online_users = await _fetch_online_users()
|
||||
all_ids = get_all_wechat_user_ids()
|
||||
for uid in all_ids:
|
||||
update_online(uid, uid in online_users)
|
||||
114
utils/health_check.py
Normal file
114
utils/health_check.py
Normal file
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
健康检查 - 定时检测轻简/企微连接,断线时告警
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 状态
|
||||
_qingjian_connected = False
|
||||
_wechat_ok = True
|
||||
_last_alert_at: dict[str, float] = {}
|
||||
_ALERT_COOLDOWN = 300
|
||||
_start_ts = time.time()
|
||||
|
||||
|
||||
def set_qingjian_connected(ok: bool):
|
||||
"""设置轻简连接状态(由 websocket_client 在连接/断开时调用)"""
|
||||
global _qingjian_connected
|
||||
_qingjian_connected = ok
|
||||
|
||||
|
||||
def set_wechat_ok(ok: bool):
|
||||
"""设置企微可达状态"""
|
||||
global _wechat_ok
|
||||
_wechat_ok = ok
|
||||
|
||||
|
||||
async def _check_wechat() -> bool:
|
||||
"""检测企微 Webhook 是否可达"""
|
||||
import httpx
|
||||
from config.config import WECHAT_WEBHOOK, HEALTH_CHECK_WECHAT_PING
|
||||
if not WECHAT_WEBHOOK:
|
||||
return True
|
||||
if not HEALTH_CHECK_WECHAT_PING:
|
||||
return True # 不主动 ping,避免刷屏
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
resp = await client.post(WECHAT_WEBHOOK, json={"msgtype": "text", "text": {"content": "ok"}})
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning(f"企微健康检查失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _send_alert(title: str, content: str):
|
||||
"""发送告警到企微"""
|
||||
from config.config import WECHAT_WEBHOOK
|
||||
import time
|
||||
global _last_alert_at
|
||||
now = time.time()
|
||||
if now - _last_alert_at.get(title, 0) < _ALERT_COOLDOWN:
|
||||
return
|
||||
_last_alert_at[title] = now
|
||||
if not WECHAT_WEBHOOK:
|
||||
logger.warning(f"[健康检查] {title}: {content}")
|
||||
return
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
await client.post(WECHAT_WEBHOOK, json={
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"content": f"⚠️ **{title}**\n{content}"}
|
||||
})
|
||||
logger.info(f"[健康检查] 已发送告警: {title}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[健康检查] 告警发送失败: {e}")
|
||||
|
||||
|
||||
async def run_health_check(get_qingjian_status: Optional[Callable[[], bool]] = None):
|
||||
"""
|
||||
执行一次健康检查。
|
||||
get_qingjian_status: 返回轻简是否已连接的函数
|
||||
"""
|
||||
from config.config import HEALTH_CHECK_INTERVAL, WECHAT_WEBHOOK, HEALTH_CHECK_STARTUP_GRACE, HEALTH_CHECK_QINGJIAN_ALERTS_ENABLED
|
||||
global _qingjian_connected, _wechat_ok
|
||||
|
||||
# 轻简
|
||||
if get_qingjian_status:
|
||||
qj_ok = get_qingjian_status()
|
||||
if not qj_ok:
|
||||
if time.time() - _start_ts >= HEALTH_CHECK_STARTUP_GRACE and HEALTH_CHECK_QINGJIAN_ALERTS_ENABLED:
|
||||
if _qingjian_connected:
|
||||
await _send_alert("轻简连接断开", "WebSocket 已断开,请检查轻简软件是否运行。")
|
||||
else:
|
||||
await _send_alert("轻简未连接", "无法连接轻简 API,请确认轻简软件已启动在 ws://127.0.0.1:9528")
|
||||
_qingjian_connected = qj_ok
|
||||
|
||||
# 企微
|
||||
wechat_ok = await _check_wechat()
|
||||
if not wechat_ok and _wechat_ok and WECHAT_WEBHOOK:
|
||||
await _send_alert("企微不可达", "企业微信 Webhook 无法访问,告警将无法送达。")
|
||||
_wechat_ok = wechat_ok
|
||||
|
||||
# API 成本预算告警
|
||||
try:
|
||||
from utils.api_cost_tracker import check_budget_alert
|
||||
await check_budget_alert()
|
||||
except Exception as e:
|
||||
logger.debug(f"[健康检查] 成本告警跳过: {e}")
|
||||
|
||||
|
||||
async def health_check_loop(get_qingjian_status: Optional[Callable[[], bool]] = None):
|
||||
"""健康检查循环"""
|
||||
from config.config import HEALTH_CHECK_INTERVAL
|
||||
while True:
|
||||
try:
|
||||
await run_health_check(get_qingjian_status)
|
||||
except Exception as e:
|
||||
logger.warning(f"[健康检查] 异常: {e}")
|
||||
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
|
||||
55
utils/image_queue.py
Normal file
55
utils/image_queue.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
图片处理队列 - 高并发时排队,避免打满 API
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 信号量限制并发数
|
||||
_semaphore: Optional[asyncio.Semaphore] = None
|
||||
_queue_size = 0
|
||||
_max_concurrent = 2
|
||||
_max_queue = 20
|
||||
|
||||
|
||||
def init(max_concurrent: int = None, max_queue: int = None):
|
||||
"""初始化队列(在 workflow 使用前调用)"""
|
||||
global _semaphore, _max_concurrent, _max_queue
|
||||
try:
|
||||
from config.config import IMAGE_QUEUE_MAX_CONCURRENT, IMAGE_QUEUE_MAX_SIZE
|
||||
_max_concurrent = max_concurrent or IMAGE_QUEUE_MAX_CONCURRENT
|
||||
_max_queue = max_queue or IMAGE_QUEUE_MAX_SIZE
|
||||
except Exception:
|
||||
_max_concurrent = max_concurrent or 2
|
||||
_max_queue = max_queue or 20
|
||||
_semaphore = asyncio.Semaphore(_max_concurrent)
|
||||
|
||||
|
||||
async def acquire():
|
||||
"""获取处理槽位,队列满时等待"""
|
||||
global _queue_size
|
||||
if _semaphore is None:
|
||||
init()
|
||||
if _queue_size >= _max_queue:
|
||||
logger.warning(f"[图片队列] 队列已满({_queue_size}),等待空位...")
|
||||
_queue_size += 1
|
||||
await _semaphore.acquire()
|
||||
_queue_size -= 1
|
||||
|
||||
|
||||
def release():
|
||||
"""释放槽位"""
|
||||
if _semaphore:
|
||||
_semaphore.release()
|
||||
|
||||
|
||||
async def run_with_queue(coro):
|
||||
"""在队列中执行协程"""
|
||||
await acquire()
|
||||
try:
|
||||
return await coro
|
||||
finally:
|
||||
release()
|
||||
120
utils/intent_analyzer.py
Normal file
120
utils/intent_analyzer.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
语义匹配 - 用 embedding 做意图/情绪识别
|
||||
配置 EMBEDDING_MODEL 后启用,否则回退到关键词
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 意图模板(用于 embedding 相似度匹配)
|
||||
INTENT_TEMPLATES = {
|
||||
"询价": "我想问一下价格多少钱",
|
||||
"发图": "我发图给你看看",
|
||||
"砍价": "能不能便宜点太贵了",
|
||||
"批量": "我要做很多张图批量",
|
||||
"加急": "能不能快点很急",
|
||||
"售后": "已经付款了什么时候好",
|
||||
"修改": "不满意要改一下",
|
||||
"转接": "我要退款投诉",
|
||||
"打招呼": "你好在吗有人吗",
|
||||
}
|
||||
EMOTION_TEMPLATES = {
|
||||
"平静": "好的谢谢",
|
||||
"着急": "快点啊很急",
|
||||
"不满": "怎么这么慢不满意",
|
||||
"砍价": "太贵了便宜点",
|
||||
}
|
||||
|
||||
|
||||
_template_embeddings: dict = {}
|
||||
|
||||
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"""
|
||||
msg_emb = _get_embedding(msg)
|
||||
if not msg_emb:
|
||||
return None
|
||||
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
|
||||
return best_intent if best_score > 0.6 else None
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
105
utils/service_base.py
Normal file
105
utils/service_base.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
服务基类 - 供 VectorizerService 等异步服务使用
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, TypeVar, Optional
|
||||
import aiohttp
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetryConfig:
|
||||
max_retries: int = 3
|
||||
base_delay: float = 2.0
|
||||
max_delay: float = 30.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimeoutConfig:
|
||||
connection_timeout: float = 60.0
|
||||
read_timeout: float = 240.0
|
||||
total_timeout: float = 1200.0
|
||||
|
||||
|
||||
class ServiceError(Exception):
|
||||
"""服务异常基类"""
|
||||
pass
|
||||
|
||||
|
||||
class ServiceTimeoutError(ServiceError):
|
||||
"""超时异常"""
|
||||
pass
|
||||
|
||||
|
||||
class ServiceNetworkError(ServiceError):
|
||||
"""网络异常"""
|
||||
pass
|
||||
|
||||
|
||||
class PollingMixin:
|
||||
"""轮询混入 - 子类需实现 _is_task_complete, _is_task_failed, _get_error_message"""
|
||||
def _is_task_complete(self, result: Any) -> bool:
|
||||
raise NotImplementedError
|
||||
def _is_task_failed(self, result: Any) -> bool:
|
||||
raise NotImplementedError
|
||||
def _get_error_message(self, result: Any) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class BaseService:
|
||||
"""异步服务基类"""
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "BaseService",
|
||||
base_url: str = "",
|
||||
retry_config: Optional[RetryConfig] = None,
|
||||
timeout_config: Optional[TimeoutConfig] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.retry_config = retry_config or RetryConfig()
|
||||
self.timeout_config = timeout_config or TimeoutConfig()
|
||||
self.logger = logging.getLogger(name)
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
async def create_http_session(self) -> aiohttp.ClientSession:
|
||||
"""创建 aiohttp 会话(不验证 SSL)"""
|
||||
connector = aiohttp.TCPConnector(ssl=False)
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
connect=self.timeout_config.connection_timeout,
|
||||
total=self.timeout_config.total_timeout,
|
||||
)
|
||||
return aiohttp.ClientSession(connector=connector, timeout=timeout)
|
||||
|
||||
async def execute_with_retry(
|
||||
self,
|
||||
func: Callable[..., Any],
|
||||
*args,
|
||||
error_context: str = "",
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""带重试的执行"""
|
||||
last_error = None
|
||||
for attempt in range(self.retry_config.max_retries + 1):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except (ServiceTimeoutError, ServiceNetworkError):
|
||||
raise
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
self.logger.warning(f"第{attempt + 1}次尝试失败{error_context}: {e}")
|
||||
if attempt < self.retry_config.max_retries:
|
||||
delay = min(
|
||||
self.retry_config.base_delay * (2 ** attempt),
|
||||
self.retry_config.max_delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
raise last_error or ServiceError("未知错误")
|
||||
|
||||
async def cleanup_failed_task(self, task_id: str) -> None:
|
||||
"""清理失败任务(子类可覆盖)"""
|
||||
self.logger.debug(f"清理任务: {task_id}")
|
||||
141
utils/wechat_chat_log.py
Normal file
141
utils/wechat_chat_log.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
客服对话推送到企业微信群 - 客户消息与AI回复成对发送,保持上下文
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_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:
|
||||
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:
|
||||
pass
|
||||
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)
|
||||
for m in recent:
|
||||
role = customer_id if m.get("direction") == "in" else "客服"
|
||||
msg = _truncate((m.get("message") or "").strip(), 120)
|
||||
if msg:
|
||||
lines.append(f"{role}:{msg}")
|
||||
# 当前回复(可能已在 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:
|
||||
pass # 成功静默
|
||||
else:
|
||||
print(f"[WechatChatLog] 推送失败: {data}")
|
||||
except Exception as e:
|
||||
print(f"[WechatChatLog] 推送异常: {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}},
|
||||
)
|
||||
print(f"[WechatChatLog] 早8点启动消息已发送")
|
||||
except Exception as e:
|
||||
print(f"[WechatChatLog] 启动消息发送失败: {e}")
|
||||
|
||||
|
||||
async def morning_startup_scheduler():
|
||||
"""每天 8:00 发送启动消息"""
|
||||
print("[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)
|
||||
Reference in New Issue
Block a user