""" 挂号问诊登记 ============ 当 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