Files
kefu/wechat_rpa/knowledge_processing.py
T

169 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Bounded, deterministic conversation processing. Raw chat is never an instruction."""
from __future__ import annotations
import hashlib
import re
import unicodedata
PIPELINE_VERSION = "chat-knowledge-v2"
# 引用回复也是文字:客服常用"引用+回答"的方式答复,以前整条丢掉。
TEXT_TYPES = {"text", "文本", "文字", "1", "引用", "quote"}
# 撤回提示、入群、安全提醒之类不是对话内容:跳过,但不打断正在进行的问答。
SYSTEM_TYPES = {"系统", "系统消息", "系统通知", "安全通知", "撤回", "撤回消息", "订阅通知", "群公告", "修改群名"}
# 跳过原因,进度里按原因分别计数,方便看清楚"为什么这么多消息没用上"。
SKIP_REASONS = {
"system": "系统提示",
"revoked": "已撤回或删除",
"empty": "空消息",
"unknown_direction": "收发方向不明",
}
NOISE = re.compile(r"^(你好|您好|在吗|谢谢|好的|好|嗯|收到|不客气|再见|谢谢您)[!!。,.,\s]*$")
PII_PATTERNS = [
(r"(?<!\d)1[3-9]\d{9}(?!\d)", "[手机号]"),
(r"(?<!\d)\d{17}[\dXx](?!\w)", "[身份证号]"),
(r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}", "[邮箱]"),
(r"https?://[^\s<>]+", "[链接]"),
(r"(?<!\d)\d{8,}(?!\d)", "[编号]"),
(r"(?:姓名|联系人|收件人)\s*[::]\s*[^\s,,。;;]{2,12}", "姓名:[姓名]"),
(r"(?:地址|住址|收货地址)\s*[::][^\n;;]+", "地址:[地址]"),
]
def redact(text: str, names=()) -> str:
text = unicodedata.normalize("NFKC", str(text or ""))
for name in sorted(set(names), key=len, reverse=True):
if len(name.strip()) >= 2 and name.strip() not in {"客服", "客户", "用户"}:
text = text.replace(name, "[姓名]")
for pattern, replacement in PII_PATTERNS:
text = re.sub(pattern, replacement, text)
return text.strip()
def fingerprint(text: str) -> str:
return hashlib.sha256(re.sub(r"\s+", "", text).encode()).hexdigest()
def source_key(sources) -> str:
"""同一段原始对话(消息 + 版本)的指纹。规则提取和模型清洗出自同一段对话时,
靠它认出是同一条知识:模型结果覆盖还没人动过的规则草稿,而不是再多一条重复草稿。"""
parts = sorted(f"{s['message_id']}@{s['version_no']}" for s in sources or [])
return hashlib.sha256("|".join(parts).encode()).hexdigest() if parts else ""
def tokens(text: str) -> list[str]:
"""Chinese bigrams plus Latin words; valid FTS terms without query operators."""
text = unicodedata.normalize("NFKC", text).lower()
result = []
for part in re.findall(r"[\u3400-\u9fff]+|[a-z0-9]+", text):
if re.fullmatch(r"[\u3400-\u9fff]+", part):
result.extend(part[i:i + 2] for i in range(len(part) - 1))
else:
result.append(part)
return list(dict.fromkeys(result))[:1000]
def classify(row: dict) -> tuple[str, str]:
"""一条消息怎么用:("text"|"media"|"skip", 跳过原因)。
媒体(图片、语音、文件……)用占位符留在问答里,不再让一张截图把整段问答作废;
系统提示、撤回、空消息直接跳过,也不打断问答。
"""
kind = str(row.get("message_type") or "").strip()
if row.get("status") != "normal":
return "skip", "revoked"
if kind in SYSTEM_TYPES or "系统" in kind:
return "skip", "system"
if row.get("direction") not in {"inbound", "outbound"}:
return "skip", "unknown_direction"
if kind.lower() in TEXT_TYPES:
return ("text", "") if str(row.get("content") or "").strip() else ("skip", "empty")
return "media", ""
def placeholder(row: dict) -> str:
kind = str(row.get("message_type") or "").strip() or "非文字消息"
return f"[{kind}]"
def feed(state: dict, row: dict) -> tuple[dict, list[dict]]:
"""Carry unanswered turns across pages; never emit half an answer at a limit."""
emitted = []
use, _reason = classify(row)
if use == "skip":
return state, emitted
changed = state and state.get("conversation_id") != row["conversation_id"]
gap = state and int(row.get("sent_at_epoch") or 0) - int(state.get("epoch") or 0) > 1800
if changed or gap:
candidate = finish(state)
if candidate:
emitted.append(candidate)
state = {}
if use == "media":
row = {**row, "content": placeholder(row), "media": True}
if row["direction"] == "inbound" and state.get("answers"):
candidate = finish(state)
if candidate:
emitted.append(candidate)
state = {}
state.setdefault("conversation_id", row["conversation_id"])
state["epoch"] = row.get("sent_at_epoch") or 0
state.setdefault("questions", [])
state.setdefault("answers", [])
if row["direction"] == "outbound" and not state["questions"]:
return {}, emitted
side = "questions" if row["direction"] == "inbound" else "answers"
state[side].append(row)
rows = state["questions"] + state["answers"]
if len(rows) > 40 or sum(len(r["content"]) for r in rows) > 12000:
# Mark this whole oversized turn as unusable until a new question begins.
state["overflow"] = True
state[side] = state[side][-1:]
return state, emitted
def finish(state: dict) -> dict | None:
if not state or state.get("overflow") or not state.get("questions") or not state.get("answers"):
return None
rows = state["questions"] + state["answers"]
if len({r["sender_person_id"] for r in state["questions"]}) != 1:
return None
names = [r.get("sender_name", "") for r in rows]
question = redact("\n".join(r["content"] for r in state["questions"]), names)
answer = redact("\n".join(r["content"] for r in state["answers"]), names)
# 长度只算文字:问题只有一张截图、答案只有"[图片]"的,不算一条知识。
words = lambda side: redact("".join(r["content"] for r in state[side] if not r.get("media")), names)
asked, answered = words("questions"), words("answers")
if len(asked) < 4 or len(answered) < 6 or NOISE.fullmatch(asked) or NOISE.fullmatch(answered):
return None
flags = ["待核实客服身份、适用条件和事实;自动脱敏后仍须人工检查"]
if any(r.get("media") for r in rows):
flags.append("对话含图片、语音等非文字内容(已用占位符标出),请核对答案是否完整")
if re.search(r"血糖|用药|药量|诊断|病情|患者|治疗|剂量|胰岛素", question + answer):
flags.append("包含健康或个体医疗信息,仅经专业审核通用化后可发布")
return {"title": question[:80], "question": question, "answer": answer,
"conditions": "", "category": "待分类", "kind": "qa", "flags": flags,
"sources": [{"message_id": r["id"], "version_no": r["version_no"],
"role": "customer" if r["direction"] == "inbound" else "staff",
"content": redact(r["content"], names), "sent_at": r["sent_at"]} for r in rows]}
def latest_query(text: str) -> str:
"""Extract the last speaker's contiguous bubbles without desktop dependencies."""
header = re.compile(r"^(?P<speaker>.+?)\s+(?:(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}\s+)?\d{1,2}:\d{2}(?::\d{2})?$")
blocks = []
for line in str(text or '').splitlines():
line = line.strip()
match = None if re.search(r"[\[【](?:语音|图片|视频)", line) else header.match(line)
if match:
blocks.append([match.group('speaker'), []])
elif blocks and line:
blocks[-1][1].append(line)
if not blocks:
return str(text or '').strip()
tail = []
for speaker, lines in reversed(blocks):
if speaker != blocks[-1][0]:
break
tail.append('\n'.join(lines))
return '\n'.join(reversed(tail)).strip()