458 lines
16 KiB
Python
458 lines
16 KiB
Python
"""
|
||
挂号问诊登记
|
||
============
|
||
当 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
|