Files
kefu/wechat_rpa/registration_store.py
T
2026-07-16 12:11:05 +08:00

458 lines
16 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.
"""
挂号问诊登记
============
当 AI 判断客户需要挂号 / 回复中涉及医院时,把微信客户登记下来,
供人工稍后真正约上并回联。写入 registration_leads.json。
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
import uuid
from typing import Optional
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_DEFAULT_PATH = os.path.join(_SCRIPT_DIR, "registration_leads.json")
# 仅当客户【明确表达】要挂号/预约时才登记(疑问句、拒绝都不算)
_EXPLICIT_REG_RE = re.compile(
r"(帮我挂号|给我挂号|要挂号|想挂号|帮我预约|给我预约|要预约|想预约|"
r"预约一下|挂个号|安排面诊|约个医生|约个时间|帮我约一下|"
r"那就挂|那就约|好的挂|可以挂|帮我安排挂号)",
re.I,
)
# 客户明确拒绝 / 只要咨询不要挂号
_DECLINE_REG_RE = re.compile(
r"(不需要|不用了|不用挂|不挂号|不预约|不要挂|不要约|先不挂|先不约|"
r"暂时不|算了|先问问|只是问问|挂啥号|挂什么号|什么号|"
r"不用帮|先不用|不用预约|不用挂号)",
re.I,
)
# 客服口吻残留,避免把「需要我帮您预约吗」当成客户意图
_AGENT_LINE_RE = re.compile(
r"(需要我|帮您|建议您|咱们|我帮您|给您挂|已帮您|稍后预约|请问)"
)
_AGENT_MARKERS = ("高兴亮", "客服", "甄养堂助理")
_BOOKING_MARKERS = ("已帮您预约", "稍后预约上了再联系")
class RegistrationStore:
def __init__(self, path: str = None):
self.path = path or _DEFAULT_PATH
self._lock = threading.Lock()
self._data = {"leads": []}
self._load()
def _load(self):
try:
if os.path.exists(self.path):
with open(self.path, encoding="utf-8") as f:
raw = json.load(f)
if isinstance(raw, list):
self._data = {"leads": raw}
elif isinstance(raw, dict):
self._data = {"leads": list(raw.get("leads") or [])}
except Exception:
self._data = {"leads": []}
def save(self):
with self._lock:
tmp = self.path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False, indent=2)
os.replace(tmp, self.path)
except Exception:
pass
def list_leads(self, include_done: bool = True) -> list:
leads = list(self._data.get("leads") or [])
if not include_done:
leads = [x for x in leads if x.get("status") != "done"]
leads.sort(key=lambda x: x.get("updated") or x.get("created") or 0, reverse=True)
return leads
def add_or_update(
self,
*,
session_id: str,
contact: str,
symptom: str,
status: str,
note: str = "",
last_user: str = "",
last_reply: str = "",
) -> dict:
"""同一会话未完成的挂号单合并更新;已完成的另开新单。"""
with self._lock:
leads = self._data.setdefault("leads", [])
now = time.time()
target = None
for item in reversed(leads):
if item.get("session_id") == session_id and item.get("status") != "done":
target = item
break
if target is None:
target = {
"id": uuid.uuid4().hex[:12],
"session_id": session_id,
"contact": contact or "未知客户",
"symptom": symptom or "",
"status": status,
"note": note or "",
"last_user": last_user or "",
"last_reply": last_reply or "",
"created": now,
"updated": now,
}
leads.append(target)
else:
if contact:
target["contact"] = contact
if symptom:
target["symptom"] = symptom
target["status"] = status
if note:
target["note"] = note
if last_user:
target["last_user"] = last_user
if last_reply:
target["last_reply"] = last_reply
target["updated"] = now
self.save()
return target
def set_status(self, lead_id: str, status: str) -> bool:
with self._lock:
for item in self._data.get("leads") or []:
if item.get("id") == lead_id:
item["status"] = status
item["updated"] = time.time()
break
else:
return False
self.save()
return True
def delete(self, lead_id: str) -> bool:
with self._lock:
before = len(self._data.get("leads") or [])
self._data["leads"] = [
x for x in (self._data.get("leads") or []) if x.get("id") != lead_id
]
changed = len(self._data["leads"]) != before
if changed:
self.save()
return changed
def delete_many(self, lead_ids: list) -> int:
ids = set(lead_ids or [])
if not ids:
return 0
with self._lock:
before = len(self._data.get("leads") or [])
self._data["leads"] = [
x for x in (self._data.get("leads") or []) if x.get("id") not in ids
]
n = before - len(self._data["leads"])
if n:
self.save()
return n
def set_status_many(self, lead_ids: list, status: str) -> int:
ids = set(lead_ids or [])
if not ids:
return 0
n = 0
with self._lock:
for item in self._data.get("leads") or []:
if item.get("id") in ids:
item["status"] = status
item["updated"] = time.time()
n += 1
if n:
self.save()
return n
def pending_count(self) -> int:
return sum(
1 for x in (self._data.get("leads") or [])
if x.get("status") in ("pending_symptom", "booked")
)
def hospital_name() -> str:
try:
import ai_config
return getattr(ai_config, "AI_HOSPITAL_NAME", None) or "甄养堂互联网医院"
except Exception:
return "甄养堂互联网医院"
def _customer_lines(user_text: str = "") -> list:
"""尽量只保留客户侧原话行。"""
text = (user_text or "").strip()
if not text:
return []
skip_markers = (
"建议您", "祝您", "正规医院", "已帮您预约", "内分泌科",
"稍后预约", "少食多餐", "早日康复", "咱们是糖尿病",
"需要我帮您", "需要我在我们", "挂甄养堂",
)
lines = []
for line in text.splitlines():
s = line.strip()
if not s:
continue
if re.match(r".+\s+\d{1,2}/\d{1,2}\s+\d", s):
continue
if "@微信" in s and len(s) < 40:
continue
if any(s.startswith(k) for k in _AGENT_MARKERS):
continue
if any(k in s for k in skip_markers):
continue
if _AGENT_LINE_RE.search(s) and ("吗" in s or "" in s or "?" in s):
continue
lines.append(s)
return lines
def _customer_utterances(user_text: str = "") -> str:
lines = _customer_lines(user_text)
return "\n".join(lines) if lines else (user_text or "").strip()
def _last_customer_line(user_text: str = "") -> str:
lines = _customer_lines(user_text)
return lines[-1] if lines else (user_text or "").strip().splitlines()[-1].strip() if user_text else ""
def user_declines_registration(user_text: str = "") -> bool:
"""客户最新一句是否在拒绝挂号。"""
last = _last_customer_line(user_text)
if not last:
return False
return bool(_DECLINE_REG_RE.search(last))
def user_wants_registration(user_text: str = "") -> bool:
"""
客户是否明确要求挂号/预约。
规则:以【最后一句客户原话】为准;拒绝优先;疑问句不算同意。
"""
last = _last_customer_line(user_text)
if not last:
return False
if _DECLINE_REG_RE.search(last):
return False
# 「挂啥号啊」是疑问,不是下单
if re.search(r"挂(啥|什么|哪个)号", last):
return False
return bool(_EXPLICIT_REG_RE.search(last))
def needs_registration(user_text: str = "", reply_text: str = "") -> bool:
"""兼容旧调用:以客户明确意图为准。"""
return user_wants_registration(user_text)
def extract_contact_name(chat_text: str = "", agent_name: str = "") -> str:
"""从聊天提取文本里尽量扒出对方昵称。"""
text = chat_text or ""
agent = (agent_name or "").strip()
# 形如:一个小迷糊@微信@微信联系人
m = re.search(r"^([^\n@]{2,30})@微信", text, re.M)
if m:
name = m.group(1).strip()
if name and name != agent and not any(k in name for k in _AGENT_MARKERS):
return name
# 形如:昵称 7/13 15:27:11
for m in re.finditer(r"^([^\n\d][^\n]{1,28}?)\s+\d{1,2}/\d{1,2}\s+\d", text, re.M):
name = m.group(1).strip(" :")
if not name or name == agent:
continue
if any(k in name for k in _AGENT_MARKERS):
continue
if "微信" in name and "@" in name:
name = name.split("@", 1)[0].strip()
if 1 < len(name) <= 24:
return name
return "未知客户"
def extract_symptom(user_text: str = "") -> str:
"""从客户消息里抽取简要病症描述(优先最后一条客户短句)。"""
text = (user_text or "").strip()
if not text:
return ""
skip_markers = (
"建议您", "祝您", "正规医院", "已帮您预约", "内分泌科",
"稍后预约", "少食多餐", "早日康复",
)
customer_lines = []
for line in text.splitlines():
s = line.strip()
if not s:
continue
if re.match(r".+\s+\d{1,2}/\d{1,2}\s+\d", s):
continue
if "@微信" in s and len(s) < 40:
continue
if any(s.startswith(k) for k in _AGENT_MARKERS):
continue
if any(k in s for k in skip_markers):
continue
customer_lines.append(s)
body = (customer_lines[-1] if customer_lines else text)
body = re.sub(r"\s+", " ", body).strip()
for pat in (
r"(我?血糖[^。!?\n]{0,40})",
r"(糖尿病[^。!?\n]{0,40})",
r"(胰岛素[^。!?\n]{0,30})",
r"((?:我|最近)?[^。!?\n]{0,20}(?:不稳|偏高|偏低|疼|痛|麻|痒|肿|感染)[^。!?\n]{0,20})",
):
m = re.search(pat, body)
if m:
return m.group(1).strip()[:60]
if "怎么办" in body or "怎么治" in body or "怎么看" in body:
return body[:60]
if len(body) <= 40:
return body
return body[:60]
def normalize_hospital_in_reply(reply: str) -> str:
"""凡提到医院/就医,统一落到甄养堂互联网医院;去掉不存在的内分泌科。"""
if not reply:
return reply
hosp = hospital_name()
ph = "⟦HOSP⟧"
text = reply.replace(hosp, ph)
for a, b in (
("正规医院内分泌科", ph),
("当地医院内分泌科", ph),
("三甲医院内分泌科", ph),
("医院内分泌科", ph),
("内分泌科就诊", "就诊"),
("内分泌科", ""),
("正规医院", ph),
("当地医院", ph),
("附近医院", ph),
("三甲医院", ph),
("大医院", ph),
("医院就诊", f"{ph}就诊"),
("医院看看", f"去{ph}看看"),
("去医院", f"去{ph}"),
("到医院", f"到{ph}"),
):
text = text.replace(a, b)
text = re.sub(r"去{2,}", "去", text)
text = text.replace(ph, hosp)
while hosp + hosp in text:
text = text.replace(hosp + hosp, hosp)
text = re.sub(r"[。!?]{2,}", "。", text)
text = re.sub(r"。+", "。", text)
return text.strip()
def strip_unsolicited_booking(reply: str) -> str:
"""去掉未经客户要求就擅自预约的话术。"""
text = reply or ""
for pat in (
r"[^。!?\n]*已帮您(?:在[^。!?\n]{0,20})?预约了[^。!?\n]*[。!?]?",
r"[^。!?\n]*稍后预约上了再联系您[^。!?\n]*[。!?]?",
r"[^。!?\n]*您说的「[^」]{0,40}」我记下了[。!?]?",
r"[^。!?\n]*到时我跟您确认面诊时间[^。!?\n]*[。!?]?",
):
text = re.sub(pat, "", text)
text = re.sub(r"\s{2,}", " ", text)
text = re.sub(r"[。!?]{2,}", "。", text)
return text.strip(" 。")
def ensure_registration_script(reply: str, symptom: str) -> str:
"""仅在客户明确要挂号时使用。"""
text = (reply or "").strip()
text = re.sub(r"[?]。", "", text)
hosp = hospital_name()
booked_mark = "已帮您预约"
if symptom:
text = re.sub(r"需要我.*?挂个号吗[?]?", "", text).strip(" 。")
if booked_mark not in text:
advice = strip_unsolicited_booking(text)
if advice and len(advice) > 8 and booked_mark not in advice:
text = (
advice.rstrip("。.! ")
+ f"。挂号这事我记下了,已帮您在{hosp}预约了,"
+ "稍后预约上了再联系您。"
)
else:
text = (
f"行,这个我记下了。已帮您在{hosp}预约了,"
"稍后预约上了再联系您。"
)
elif "稍后" not in text and "再联系" not in text:
text = text.rstrip("。.! ") + ",稍后预约上了再联系您。"
if hosp not in text:
text = text.rstrip("。.! ") + f"。医院是{hosp}。"
text = text.replace("内分泌科", "")
else:
if not any(k in text for k in ("哪里不舒服", "怎么不舒服", "病症", "症状", "血糖怎么样")):
text = (
strip_unsolicited_booking(text).rstrip("。.! ")
+ "。行,那您先跟我说说现在哪里不舒服,血糖大概什么情况,"
+ "我好给您安排。"
)
return re.sub(r"\s{2,}", " ", text).strip()
def process_registration_reply(
*,
session_id: str,
user_text: str,
reply_text: str,
store: Optional[RegistrationStore] = None,
agent_name: str = "",
) -> tuple[str, Optional[dict]]:
"""
归一化医院名;仅当客户明确要挂号时才写预约话术并登记。
客户说不需要/挂啥号 → 绝不预约。
"""
reply = normalize_hospital_in_reply(reply_text or "")
if user_declines_registration(user_text) or not user_wants_registration(user_text):
reply = strip_unsolicited_booking(reply)
# 拒绝时若模型仍硬预约,改成简短确认
if any(k in (reply_text or "") for k in _BOOKING_MARKERS) and user_declines_registration(user_text):
reply = "好的,那先不挂号。您先按刚才说的观察着,有问题随时找我。"
reply = normalize_hospital_in_reply(reply)
return reply, None
symptom = extract_symptom(user_text)
# 拒绝语不能当病症
if symptom and _DECLINE_REG_RE.search(symptom):
symptom = ""
reply = ensure_registration_script(reply, symptom)
reply = normalize_hospital_in_reply(reply)
contact = extract_contact_name(user_text, agent_name=agent_name)
status = "booked" if symptom else "pending_symptom"
st = store or RegistrationStore()
lead = st.add_or_update(
session_id=session_id or "unknown",
contact=contact,
symptom=symptom,
status=status,
last_user=(user_text or "")[:500],
last_reply=reply[:500],
note="客户明确要求挂号/预约",
)
return reply, lead