refactor: migrate workflow to v2 core and archive legacy modules

This commit is contained in:
2026-03-04 21:52:24 +08:00
parent e1ce17f2aa
commit fa61b11b02
156 changed files with 1781 additions and 2066 deletions

View File

@@ -0,0 +1,265 @@
import asyncio
import logging
import re
import secrets
logger = logging.getLogger("cs_agent")
async def debounce_agent_reply(client, data: dict):
"""
消息防抖:同一客户在 _DEBOUNCE_SECONDS 内的连续消息合并后再处理。
订单通知、付款相关消息不走防抖,立即处理。
"""
msg_body = data.get("msg", "")
key = f"{data.get('acc_id','')}:{data.get('from_id','')}"
client._cancel_auto_quote_task(key, reason="new_inbound")
# 以下情况跳过防抖,立即处理(后台执行,不阻塞接收循环)
immediate_keywords = ["买家已付款", "已付款", "[系统订单信息]"]
if any(kw in msg_body for kw in immediate_keywords):
client._activity_log(
"debounce_bypass_immediate",
acc_id=data.get("acc_id", ""),
customer_id=data.get("from_id", ""),
reason="payment_or_order",
msg=msg_body,
)
client._fire_and_forget(client._agent_reply_serialized(data))
return
# 积攒消息
if key not in client._pending_msgs:
client._pending_msgs[key] = []
client._pending_msgs[key].append(msg_body)
client._activity_log(
"debounce_enqueue",
key=key,
queue_size=len(client._pending_msgs[key]),
msg=msg_body,
)
# 取消上一个等待任务(如果有)
old_task = client._debounce_tasks.get(key)
if old_task and not old_task.done():
old_task.cancel()
debounce_seconds = pick_debounce_seconds(client, data, msg_body)
# 创建新的延迟处理任务
async def _delayed(capture_key, capture_data, wait_s: float):
await asyncio.sleep(wait_s)
msgs = client._pending_msgs.pop(capture_key, [])
if not msgs:
return
if len(msgs) == 1:
merged_msg = msgs[0]
else:
merged_msg = "".join(m for m in msgs if m.strip())
logger.info(f"[{client.get_time()}] 防抖合并 {len(msgs)} 条消息: {merged_msg[:60]}")
client._activity_log(
"debounce_flush",
key=capture_key,
merged_count=len(msgs),
merged_msg=merged_msg,
)
merged_data = dict(capture_data)
merged_data["msg"] = merged_msg
await client._agent_reply_serialized(merged_data)
task = asyncio.create_task(_delayed(key, data, debounce_seconds))
client._debounce_tasks[key] = task
def rand_between(low: float, high: float) -> float:
if high <= low:
return float(low)
# 使用 secrets 增强随机性,避免固定周期导致机械感
span = high - low
return round(low + span * (secrets.randbelow(1000) / 1000.0), 2)
def guess_intent_for_debounce(client, msg: str) -> str:
text = (msg or "").strip()
if not text:
return "unknown"
if msg_has_image_url(text):
return "image"
try:
from utils.intent_analyzer import detect_intent
decision = detect_intent(text)
intent = decision.intent
if intent:
client._activity_log(
"debounce_intent_detected",
intent=intent,
source=decision.source,
score=round(float(decision.score or 0.0), 4),
msg=text[:120],
)
except Exception:
intent = ""
if intent:
return intent
lower = text.lower()
if any(k in lower for k in ["报价", "多少钱", "价格", "", "优惠", "收费", "怎么收费", "咋收费"]):
return "询价"
if any(k in lower for k in ["做一下", "改一下", "需求", "门头", "上面的字", "处理"]):
return "修改"
if any(k in lower for k in ["在吗", "你好", "有人"]):
return "打招呼"
return "unknown"
def looks_like_requirement_text(msg: str) -> bool:
text = (msg or "").strip().lower()
if not text:
return False
req_kw = (
"做一下",
"改一下",
"处理一下",
"这个字",
"上面的字",
"门头",
"去背景",
"抠图",
"换色",
"调色",
"清晰",
"高清",
"尺寸",
"比例",
"横版",
"竖版",
"排版",
"改字",
"按这个做",
"照这个做",
"就这张",
"看看做",
"弄一下",
)
return any(k in text for k in req_kw)
def pick_debounce_seconds(client, data: dict, msg: str) -> float:
"""意图驱动防抖:不同意图不同等待区间,并引入轻微随机。"""
base = max(1.0, float(client._DEBOUNCE_SECONDS))
if not client._adaptive_debounce_enabled:
return base
intent = guess_intent_for_debounce(client, msg)
is_req = looks_like_requirement_text(msg)
has_img = msg_has_image_url(msg)
# 区间策略:越明确、越短消息,等待越短;需求描述类稍长
if intent == "打招呼":
low, high = 1.0, min(3.0, base)
elif intent in ("询价", "砍价"):
# 询价先略等一会,给客户补发图片/需求的窗口,减少机械两连回
low, high = 4.0, min(7.0, max(base, 7.0))
elif intent == "image":
# 文本里直接贴图链接:短等合并上下文,避免和上一条询价并发
low, high = 2.2, 4.2
elif intent in ("修改", "批量"):
low, high = max(3.0, base * 0.65), min(18.0, base + 2.0)
elif intent == "转接":
low, high = 1.0, 2.5
else:
low, high = max(2.0, base * 0.5), base
# 发图后的需求描述,优先“多等一点”收集完整需求,减少半句回复
# 约束到 12-14s避免等待过长。
if is_req and not has_img:
low = max(low, 12.0)
high = min(14.0, max(high, 12.6))
# 短句更快,长句稍慢,避免把连续半句拆开
text_len = len((msg or "").strip())
if text_len <= 4:
high = min(high, max(low + 0.2, 2.5))
elif text_len >= 18:
low = min(high, low + 0.6)
wait_s = rand_between(low, high)
logger.info(f"防抖等待 {wait_s}s | intent={intent} | len={text_len}")
return wait_s
def msg_has_image_url(msg: str) -> bool:
"""判断文本消息里是否包含图片URL客户粘贴了图片链接可能带前缀文字如 有吗#*#https://..."""
if not msg:
return False
lower = msg.lower()
image_exts = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
image_hosts = ("alicdn.com", "imgextra", "taobao.com", "jd.com", "pinduoduo.com")
if "http://" in lower or "https://" in lower:
if any(ext in lower for ext in image_exts) or any(h in lower for h in image_hosts):
return True
return False
def msg_refers_images(msg: str) -> bool:
"""判断文本是否指代之前的图片(图一/图二/这张/那张/上面那张等)"""
if not msg:
return False
refs = (
"图一",
"图二",
"第一张",
"第二张",
"这张",
"那张",
"这图",
"那个图",
"这个",
"这个呢",
"上面那张",
"下面那张",
"刚才那张",
"上一张",
"下一张",
)
return any(r in msg for r in refs)
def extract_image_urls(msg: str) -> list:
if not msg:
return []
parts = [p.strip() for p in msg.split("#*#") if p.strip()]
urls = []
for p in parts:
if p.startswith("http://") or p.startswith("https://"):
urls.append(p)
if not urls and ("http://" in msg or "https://" in msg):
tokens = re.findall(r"(https?://\S+)", msg)
for t in tokens:
if any(ext in t.lower() for ext in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]):
urls.append(t)
return urls[:8]
def collect_recent_image_urls(client, customer_id: str, acc_id: str, max_count: int = 6) -> list:
"""从最近对话中回溯收集图片URL优先买家消息用于慢发或引用图片的场景"""
urls, seen = [], set()
try:
from db.chat_log_db import get_recent_conversation
recent = get_recent_conversation(customer_id=customer_id, acc_id=acc_id, limit=20)
# 从最近到更早遍历,收集买家(in)消息中的图片链接
for item in reversed(recent):
if item.get("direction") != "in":
continue
message = item.get("message") or ""
found = extract_image_urls(message)
for u in found:
if u not in seen:
seen.add(u)
urls.append(u)
if len(urls) >= max_count:
return urls
except Exception:
logger.debug("收集近期图片URL失败", exc_info=True)
return urls