This commit is contained in:
Your Name
2026-07-16 12:11:05 +08:00
parent 22ec32eb29
commit 32895b1591
41 changed files with 5740 additions and 0 deletions
+457
View File
@@ -0,0 +1,457 @@
"""
挂号问诊登记
============
当 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