新增功能
This commit is contained in:
@@ -7,12 +7,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -44,34 +44,90 @@ _BOOKING_MARKERS = ("已帮您预约", "稍后预约上了再联系")
|
||||
class RegistrationStore:
|
||||
def __init__(self, path: str = None):
|
||||
self.path = path or _DEFAULT_PATH
|
||||
self._lock = threading.Lock()
|
||||
self._lock = threading.RLock()
|
||||
self._data = {"leads": []}
|
||||
self._load()
|
||||
|
||||
@property
|
||||
def _path(self) -> Path:
|
||||
return Path(self.path)
|
||||
|
||||
@property
|
||||
def _lock_path(self) -> Path:
|
||||
path = self._path
|
||||
return path.with_name(path.name + ".lock")
|
||||
|
||||
@staticmethod
|
||||
def _storage_helpers():
|
||||
"""
|
||||
延迟导入,复用 Grok 客服 MCP 的同一套跨进程锁与原子写实现。
|
||||
|
||||
customer_service_policy 会导入本模块中的业务文本函数,因此不能在
|
||||
模块加载阶段反向导入;方法运行时两个模块均已完成初始化。
|
||||
"""
|
||||
from customer_service_policy import (
|
||||
LocalStoreError,
|
||||
_atomic_write_json,
|
||||
_exclusive_lock,
|
||||
_read_json,
|
||||
)
|
||||
|
||||
return LocalStoreError, _atomic_write_json, _exclusive_lock, _read_json
|
||||
|
||||
def _read_latest_unlocked(self) -> dict:
|
||||
LocalStoreError, _, _, read_json = self._storage_helpers()
|
||||
raw = read_json(self._path, {"leads": []})
|
||||
if isinstance(raw, list):
|
||||
leads = raw
|
||||
elif isinstance(raw, dict) and isinstance(raw.get("leads", []), list):
|
||||
leads = raw.get("leads") or []
|
||||
else:
|
||||
raise LocalStoreError("本地登记数据格式无效")
|
||||
return {"leads": list(leads)}
|
||||
|
||||
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": []}
|
||||
_, _, exclusive_lock, _ = self._storage_helpers()
|
||||
with self._lock:
|
||||
with exclusive_lock(self._lock_path):
|
||||
self._data = self._read_latest_unlocked()
|
||||
|
||||
def save(self):
|
||||
"""原子保存当前快照;保存失败会向调用方抛出异常。"""
|
||||
_, atomic_write_json, exclusive_lock, _ = self._storage_helpers()
|
||||
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
|
||||
payload = {"leads": list(self._data.get("leads") or [])}
|
||||
with exclusive_lock(self._lock_path):
|
||||
atomic_write_json(self._path, payload)
|
||||
self._data = payload
|
||||
|
||||
def _read_latest(self) -> dict:
|
||||
_, _, exclusive_lock, _ = self._storage_helpers()
|
||||
with self._lock:
|
||||
with exclusive_lock(self._lock_path):
|
||||
payload = self._read_latest_unlocked()
|
||||
self._data = payload
|
||||
return payload
|
||||
|
||||
def _mutate(self, mutation):
|
||||
"""
|
||||
在同一跨进程临界区内执行 reload -> 修改 -> 原子保存。
|
||||
|
||||
这样 UI 持有较早创建的 RegistrationStore 实例时,也不会覆盖 MCP
|
||||
刚刚写入的挂号登记。
|
||||
"""
|
||||
_, atomic_write_json, exclusive_lock, _ = self._storage_helpers()
|
||||
with self._lock:
|
||||
with exclusive_lock(self._lock_path):
|
||||
payload = self._read_latest_unlocked()
|
||||
result, changed = mutation(payload)
|
||||
if changed:
|
||||
atomic_write_json(self._path, payload)
|
||||
self._data = payload
|
||||
return result
|
||||
|
||||
def list_leads(self, include_done: bool = True) -> list:
|
||||
leads = list(self._data.get("leads") or [])
|
||||
payload = self._read_latest()
|
||||
leads = list(payload.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)
|
||||
@@ -89,8 +145,8 @@ class RegistrationStore:
|
||||
last_reply: str = "",
|
||||
) -> dict:
|
||||
"""同一会话未完成的挂号单合并更新;已完成的另开新单。"""
|
||||
with self._lock:
|
||||
leads = self._data.setdefault("leads", [])
|
||||
def mutation(payload):
|
||||
leads = payload.setdefault("leads", [])
|
||||
now = time.time()
|
||||
target = None
|
||||
for item in reversed(leads):
|
||||
@@ -124,65 +180,69 @@ class RegistrationStore:
|
||||
if last_reply:
|
||||
target["last_reply"] = last_reply
|
||||
target["updated"] = now
|
||||
self.save()
|
||||
return target
|
||||
return target, True
|
||||
|
||||
return self._mutate(mutation)
|
||||
|
||||
def set_status(self, lead_id: str, status: str) -> bool:
|
||||
with self._lock:
|
||||
for item in self._data.get("leads") or []:
|
||||
def mutation(payload):
|
||||
for item in payload.get("leads") or []:
|
||||
if item.get("id") == lead_id:
|
||||
item["status"] = status
|
||||
item["updated"] = time.time()
|
||||
break
|
||||
else:
|
||||
return False
|
||||
self.save()
|
||||
return True
|
||||
return True, True
|
||||
return False, False
|
||||
|
||||
return self._mutate(mutation)
|
||||
|
||||
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
|
||||
def mutation(payload):
|
||||
before = len(payload.get("leads") or [])
|
||||
payload["leads"] = [
|
||||
x for x in (payload.get("leads") or []) if x.get("id") != lead_id
|
||||
]
|
||||
changed = len(self._data["leads"]) != before
|
||||
if changed:
|
||||
self.save()
|
||||
return changed
|
||||
changed = len(payload["leads"]) != before
|
||||
return changed, changed
|
||||
|
||||
return self._mutate(mutation)
|
||||
|
||||
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
|
||||
|
||||
def mutation(payload):
|
||||
before = len(payload.get("leads") or [])
|
||||
payload["leads"] = [
|
||||
x for x in (payload.get("leads") or []) if x.get("id") not in ids
|
||||
]
|
||||
n = before - len(self._data["leads"])
|
||||
if n:
|
||||
self.save()
|
||||
return n
|
||||
count = before - len(payload["leads"])
|
||||
return count, bool(count)
|
||||
|
||||
return self._mutate(mutation)
|
||||
|
||||
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 []:
|
||||
|
||||
def mutation(payload):
|
||||
count = 0
|
||||
for item in payload.get("leads") or []:
|
||||
if item.get("id") in ids:
|
||||
item["status"] = status
|
||||
item["updated"] = time.time()
|
||||
n += 1
|
||||
if n:
|
||||
self.save()
|
||||
return n
|
||||
count += 1
|
||||
return count, bool(count)
|
||||
|
||||
return self._mutate(mutation)
|
||||
|
||||
def pending_count(self) -> int:
|
||||
payload = self._read_latest()
|
||||
return sum(
|
||||
1 for x in (self._data.get("leads") or [])
|
||||
if x.get("status") in ("pending_symptom", "booked")
|
||||
1 for x in (payload.get("leads") or [])
|
||||
if x.get("status")
|
||||
in ("pending_symptom", "pending_human_confirmation", "booked")
|
||||
)
|
||||
|
||||
|
||||
@@ -377,31 +437,22 @@ def strip_unsolicited_booking(reply: str) -> str:
|
||||
|
||||
|
||||
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}。"
|
||||
advice = strip_unsolicited_booking(text)
|
||||
pending = (
|
||||
"已记录您的挂号需求,需由工作人员人工联系确认,"
|
||||
"当前尚未预约成功。"
|
||||
)
|
||||
text = (
|
||||
advice.rstrip("。.!! ") + "。" + pending
|
||||
if advice and len(advice) > 8
|
||||
else pending
|
||||
)
|
||||
text = text.replace("内分泌科", "")
|
||||
else:
|
||||
if not any(k in text for k in ("哪里不舒服", "怎么不舒服", "病症", "症状", "血糖怎么样")):
|
||||
@@ -422,8 +473,8 @@ def process_registration_reply(
|
||||
agent_name: str = "",
|
||||
) -> tuple[str, Optional[dict]]:
|
||||
"""
|
||||
归一化医院名;仅当客户明确要挂号时才写预约话术并登记。
|
||||
客户说不需要/挂啥号 → 绝不预约。
|
||||
归一化医院名;仅当客户明确要挂号时登记为待人工确认。
|
||||
客户说不需要/挂啥号 → 绝不登记,也绝不声称预约成功。
|
||||
"""
|
||||
reply = normalize_hospital_in_reply(reply_text or "")
|
||||
|
||||
@@ -443,7 +494,7 @@ def process_registration_reply(
|
||||
reply = normalize_hospital_in_reply(reply)
|
||||
|
||||
contact = extract_contact_name(user_text, agent_name=agent_name)
|
||||
status = "booked" if symptom else "pending_symptom"
|
||||
status = "pending_human_confirmation" if symptom else "pending_symptom"
|
||||
st = store or RegistrationStore()
|
||||
lead = st.add_or_update(
|
||||
session_id=session_id or "unknown",
|
||||
|
||||
Reference in New Issue
Block a user