新增功能

This commit is contained in:
Your Name
2026-07-28 09:46:53 +08:00
parent 45b3bc0852
commit 980795b4da
57 changed files with 18421 additions and 1051 deletions
+130 -79
View File
@@ -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",