This commit is contained in:
2026-02-27 16:03:04 +08:00
commit 5aedf1665d
137 changed files with 17604 additions and 0 deletions

0
utils/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

95
utils/api_cost_tracker.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)