import os import re import time def normalize_reply_semantic_key(text: str) -> str: s = (text or "").strip().lower() if not s: return "" for w in ("哈", "呀", "哦", "呢", "啦", "咯", "亲"): s = s.replace(w, "") s = re.sub(r"[,。!?、,.!?::;\s~\-—_]+", "", s) return s[:200] def classify_outbound_reply(text: str) -> str: s = (text or "").strip() if not s: return "empty" if any(k in s for k in ("报价", "总价", "多少钱", "多少", "马上给你报价", "先给你报")): return "quote" if any(k in s for k in ("继续发图", "发完", "发图", "把图发", "先看图")): return "collect" if any(k in s for k in ("在吗", "你好", "在的", "在呢")): return "greeting" if any(k in s for k in ("转人工", "转接", "转给")): return "transfer" if any(k in s for k in ("稍等", "我先看", "看一下", "看下")): return "ack" return "general" def template_family(reply: str) -> str: s = (reply or "").strip() if not s: return "" if "需求我记上了" in s and "继续发图" in s: return "collect_remind" if ("这批图过一遍" in s or "收齐了" in s or "收好了" in s) and ("总价" in s or "报价" in s): return "quote_defer" if "图片收到了" in s and "继续发" in s: return "collect_ack" if "好嘞,你稍等下,我这边看一下" in s: return "fallback_ack" return "" def outbound_arbiter(client, original_msg: dict, reply_content: str, trace_id: str) -> tuple[bool, str]: """ 统一出站裁决层: 1) 语义去重(相同语义短窗口不重复); 2) 同类回复节流(同类话术短窗口不重复)。 """ key = f"{original_msg.get('acc_id', '')}:{original_msg.get('from_id', '')}" now_mono = time.monotonic() sem_key = normalize_reply_semantic_key(reply_content) reply_class = classify_outbound_reply(reply_content) try: sem_window = max(30, int(os.getenv("AI_OUTBOUND_SEMANTIC_DEDUPE_SECONDS", "180"))) except Exception: sem_window = 180 try: class_window = max(20, int(os.getenv("AI_OUTBOUND_CLASS_DEDUPE_SECONDS", "90"))) except Exception: class_window = 90 try: template_window = max(120, int(os.getenv("AI_OUTBOUND_TEMPLATE_FATIGUE_SECONDS", "600"))) except Exception: template_window = 600 sem_bucket = client._outbound_semantic_seen.setdefault(key, {}) cls_bucket = client._outbound_class_seen.setdefault(key, {}) tpl_bucket = client._outbound_template_seen.setdefault(key, {}) client._prune_seen(sem_bucket, now_mono, ttl_sec=max(sem_window * 2, 240)) client._prune_seen(cls_bucket, now_mono, ttl_sec=max(class_window * 2, 180)) client._prune_seen(tpl_bucket, now_mono, ttl_sec=max(template_window * 2, 1200)) if sem_key and (now_mono - sem_bucket.get(sem_key, 0.0)) < sem_window: client._activity_log( "outbound_arbiter_block", trace_id=trace_id, acc_id=original_msg.get("acc_id", ""), customer_id=original_msg.get("from_id", ""), reason="semantic_duplicate", semantic_key=sem_key[:80], reply_class=reply_class, msg=reply_content, ) return False, "semantic_duplicate" family = template_family(reply_content) if family and (now_mono - tpl_bucket.get(family, 0.0)) < template_window: client._activity_log( "outbound_arbiter_block", trace_id=trace_id, acc_id=original_msg.get("acc_id", ""), customer_id=original_msg.get("from_id", ""), reason="template_fatigue", template_family=family, msg=reply_content, ) return False, "template_fatigue" if reply_class in {"quote", "collect", "ack"} and (now_mono - cls_bucket.get(reply_class, 0.0)) < class_window: client._activity_log( "outbound_arbiter_block", trace_id=trace_id, acc_id=original_msg.get("acc_id", ""), customer_id=original_msg.get("from_id", ""), reason="class_duplicate", reply_class=reply_class, msg=reply_content, ) return False, "class_duplicate" if sem_key: sem_bucket[sem_key] = now_mono cls_bucket[reply_class] = now_mono if family: tpl_bucket[family] = now_mono client._activity_log( "outbound_arbiter_pass", trace_id=trace_id, acc_id=original_msg.get("acc_id", ""), customer_id=original_msg.get("from_id", ""), reply_class=reply_class, template_family=family, semantic_key=sem_key[:80] if sem_key else "", ) return True, "pass"