This commit is contained in:
Your Name
2026-07-29 09:34:02 +08:00
parent 0ff8943ee2
commit f913a57529
54 changed files with 2453 additions and 378 deletions
+203 -14
View File
@@ -48,6 +48,59 @@ def _system_prompt() -> str:
return prompt
_CHAT_HEADER_RE = re.compile(
r"^.+?\s+(?:(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}\s+)?"
r"\d{1,2}:\d{2}(?::\d{2})?$"
)
_CASUAL_RE = re.compile(
r"(?:好困|困死|想睡|好累|累死|无聊|好烦|烦死|好饿|饿死|"
r"在干嘛|干什么呢|多大了|几岁|哪里人|叫什么|吃饭了吗|"
r"早上好|中午好|晚上好|晚安|想你了|哈哈|嘿嘿)"
)
_EXPLICIT_HEALTH_RE = re.compile(
r"(?:血糖|糖尿病|胰岛素|降糖药|头晕|胸痛|心慌|恶心|呕吐|"
r"呼吸困难|昏迷|伤口|感染|疼|痛|麻|肿|低血糖|高血糖)"
)
_MEDICAL_DRIFT_RE = re.compile(
r"(?:血糖|糖尿病|胰岛素|降糖药|医院|医生|就医|挂号|面诊|"
r"调药|调整方案|治疗方案)"
)
_CLARIFY_RE = re.compile(r"^(?:什么|啥|什么意思|没懂|没看懂|没明白)[??。!!]*$")
def latest_customer_message(chat_text: str) -> str:
"""从企微复制文本中提取最后一位说话人的消息正文。"""
lines = [line.strip() for line in str(chat_text or "").splitlines() if line.strip()]
if not lines:
return ""
last_header = -1
for index, line in enumerate(lines):
if _CHAT_HEADER_RE.match(line):
last_header = index
if 0 <= last_header < len(lines) - 1:
return "\n".join(lines[last_header + 1:]).strip()
# 没有说话人/时间头时,调用方通常传入的就是本次新消息。
return "\n".join(lines).strip()
def _conversation_mode_instruction(latest: str) -> str:
text = str(latest or "").strip()
if _CLARIFY_RE.fullmatch(text):
return (
"【本轮是追问澄清】客户是在说没听懂你上一句。"
"请把客服上一句换成更简单的一句话说明;不要说自己没听清,也不要让客户再说一遍。"
)
if _CASUAL_RE.search(text) and not _EXPLICIT_HEALTH_RE.search(text):
return (
"【本轮是日常闲聊】直接顺着对方的话自然接一句。"
"绝对不要主动转到血糖、疾病、医院、挂号或健康管理,也不要教育对方。"
)
return (
"【本轮按原问题回答】先准确回答客户最后一句,不要因为历史里谈过健康,"
"就把当前无关问题强行拉回健康或业务。"
)
def _history_messages(history: list) -> list:
"""
将会话历史裁剪为最近 N 轮,作为多轮上下文消息插入到请求中。
@@ -57,10 +110,17 @@ def _history_messages(history: list) -> list:
if not getattr(ai_config, 'AI_CONTEXT_ENABLED', False) or not history:
return []
max_rounds = getattr(ai_config, 'AI_CONTEXT_MAX_ROUNDS', 5)
return [
{"role": m["role"], "content": m["content"]}
for m in history[-max_rounds * 2:]
]
messages = []
for item in history[-max_rounds * 2:]:
role = item.get("role")
content = str(item.get("content") or "").strip()
if role == "user":
# 旧档案中的 user 内容可能是一整屏聊天,里面混有我方历史回复。
# 只留下最后一位说话人的正文,避免把客服自己的话再次当成客户诉求。
content = latest_customer_message(content)
if role in {"user", "assistant"} and content:
messages.append({"role": role, "content": content})
return messages
def _headers():
@@ -127,8 +187,12 @@ def _dify_query_from_chat(chat_text: str, history: list = None) -> str:
"""
try:
hosp = getattr(ai_config, "AI_HOSPITAL_NAME", None) or "甄养堂互联网医院"
agent = getattr(ai_config, "AI_AGENT_NAME", None) or "客服"
except Exception:
hosp = "甄养堂互联网医院"
agent = "客服"
latest = latest_customer_message(chat_text)
mode_instruction = _conversation_mode_instruction(latest)
rules = (
"【事实铁律|必须遵守】\n"
"1. 严禁编造快递单号、物流状态、签收时间、订单号、库存等业务数据。\n"
@@ -144,11 +208,19 @@ def _dify_query_from_chat(chat_text: str, history: list = None) -> str:
f"医院是{hosp}。客户说不需要/挂啥号时绝不能预约。\n"
"7. 只回复客户最后一条需要处理的问题。语气像干了十几年的老客服:口语、沉稳、"
"不急不躁,一次只说一件事,最多顺带问一个问题,不要复述对方原话,不要一次抛一大段方案。\n"
"8. 默认只写1~2句、20~60个汉字;先用一句自然的话接住对方的担心或不舒服,再回答重点。"
"不要标题、列表、客套收尾,不说「希望能帮到您」「请您放心」等套话。\n"
"9. 只有急救风险可以写到3句;一条消息最多一个问号,只输出能直接发给客户的正文。\n"
"10. 先判断客户是在闲聊、问业务还是问健康。闲聊就闲聊,普通问题就直接回答;"
"严禁每句话都扯到血糖、身体、医院、医生、挂号或面诊。\n"
f"11. 你的称呼是「{agent}」,年龄设定是四十来岁。客户问年龄时自然回答「四十来岁」,"
"不要回避、说教,也不要借机转移到健康话题。\n"
"12. 示例:客户说「好困啊」,可回「困了就先眯一会儿,别硬撑着」;"
"客户问「你多大了」,可回「四十来岁啦,怎么突然问这个?」。"
"示例只说明说话方式,不要机械重复。\n"
)
hist = _history_messages(history)
if not hist:
body = f"以下是最近的聊天记录:\n\n{chat_text}\n\n请生成回复:"
else:
if hist:
lines = []
for m in hist:
role = "客户" if m.get("role") == "user" else "客服"
@@ -156,7 +228,15 @@ def _dify_query_from_chat(chat_text: str, history: list = None) -> str:
body = (
"【近期对话|仅供参考,其中客服所述业务数据可能不实】\n"
+ "\n".join(lines)
+ f"\n\n【最新消息】\n{chat_text}\n\n请生成回复:"
+ f"\n\n{mode_instruction}\n"
+ f"【客户最后一句|唯一回答对象】\n{latest}\n\n"
+ f"【本次原始聊天片段|仅用于理解上下文】\n{chat_text}\n\n请生成回复:"
)
else:
body = (
f"{mode_instruction}\n"
f"【客户最后一句|唯一回答对象】\n{latest}\n\n"
f"【本次原始聊天片段|仅用于理解上下文】\n{chat_text}\n\n请生成回复:"
)
return rules + "\n" + body
@@ -181,24 +261,116 @@ _SELF_ID_RE = re.compile(
r"[,,。!]?"
)
_LAUGH_RE = re.compile(r"哈{2,}[,,]?|哈哈[,,]?")
_MARKDOWN_PREFIX_RE = re.compile(r"^\s*(?:#{1,6}\s*|[-*•]\s+|\d+[.、)]\s*)")
_CANNED_RE = re.compile(
r"(?:希望(?:以上|这些)?(?:建议|内容)?能帮到您|请您放心|感谢您的理解|"
r"感谢您的耐心等待|祝您(?:生活愉快|身体健康|早日康复)|"
r"如有(?:其他|任何)?问题[,,]?欢迎随时(?:咨询|联系)(?:我|我们)?|"
r"如果还有(?:其他|任何)?问题[,,]?(?:可以|请)随时(?:咨询|联系)(?:我|我们)?)"
r"[。!!]?"
)
_URGENT_RE = re.compile(
r"(?:120|急诊|急救|昏迷|意识不清|胸痛|呼吸困难|抽搐|酮症|严重低血糖|"
r"立即就医|尽快就医|马上就医)"
)
def _humanize(text: str) -> str:
"""去除自称机器人/助手的句子,以及禁止的"哈哈"语气词,收拾多余标点。"""
"""把模型输出收成适合企微发送的短句,去掉机器味、套话和 Markdown。"""
if not text:
return text
lines = []
for raw_line in str(text).replace("\r", "\n").split("\n"):
line = _MARKDOWN_PREFIX_RE.sub("", raw_line).strip()
line = re.sub(r"^(?:回复|答复|客服回复)\s*[::]\s*", "", line)
line = line.replace("**", "").replace("__", "").replace("`", "")
if line in {"回复", "答复", "客服回复", "建议", "参考回复"}:
continue
if line:
lines.append(line)
text = ";".join(lines)
# 用逗号占位替换,避免前后半句直接粘连;随后统一收拢多余标点
cleaned = _SELF_ID_RE.sub(",", text)
cleaned = _LAUGH_RE.sub("", cleaned)
cleaned = _CANNED_RE.sub("", cleaned)
cleaned = re.sub(r"^(?:您好|尊敬的客户|亲爱的)[,,!!。\s]*", "", cleaned)
cleaned = re.sub(r"(?:首先|其次|另外|总之|综上)[,,::\s]*", "", cleaned)
cleaned = re.sub(r"[,,]{2,}", ",", cleaned)
cleaned = re.sub(r"[,,]\s*([。!?])", r"\1", cleaned)
cleaned = re.sub(r"([。!?])\s*[,,]+", r"\1", cleaned)
cleaned = re.sub(r"[。!?]{2,}", "。", cleaned)
cleaned = re.sub(r"^[,,、\s]+", "", cleaned)
cleaned = re.sub(r"\s{2,}", " ", cleaned)
cleaned = cleaned.strip(" ;")
# 默认只保留两句;出现急救提示时允许三句,避免安全信息被压掉。
sentence_limit = 3 if _URGENT_RE.search(cleaned) else 2
char_limit = 180 if sentence_limit == 3 else 100
sentences = re.findall(r"[^。!?;]+[。!?;]?", cleaned)
cleaned = "".join(sentences[:sentence_limit]).strip(" ;")
if len(cleaned) > char_limit:
shortened = cleaned[:char_limit]
cut = max(shortened.rfind(mark) for mark in ",、;")
if cut >= int(char_limit * 0.55):
shortened = shortened[:cut]
cleaned = shortened.rstrip(",、;: ") + "。"
# 同一条最多问一个问题,避免像问卷审讯。
question_positions = [m.start() for m in re.finditer(r"[??]", cleaned)]
if len(question_positions) > 1:
last = question_positions[-1]
cleaned = "".join(
("," if char in "??" and i != last else char)
for i, char in enumerate(cleaned)
)
return cleaned.strip()
def _repair_obvious_mismatch(reply: str, chat_text: str, history: list = None) -> str:
"""对少量可明确判断的闲聊答非所问做最终兜底,不改写业务和医疗回答。"""
cleaned = str(reply or "").strip()
latest = latest_customer_message(chat_text)
if not latest or not cleaned:
return cleaned
if _CLARIFY_RE.fullmatch(latest):
previous = ""
for item in reversed(_history_messages(history or [])):
if item.get("role") == "assistant":
previous = _humanize(item.get("content") or "")
break
if previous:
first_sentence = re.match(r"[^。!?;]+[。!?;]?", previous)
concise = (first_sentence.group(0) if first_sentence else previous).strip()
if concise.startswith(("我是说", "就是说")):
return concise
return _humanize(f"我是说,{concise}")
if re.search(r"(?:你(?:今年)?多大|你几岁|你多大年纪)", latest):
answered_age = bool(re.search(r"(?:[二三四五六七八九]\s*十|\d{2})\s*(?:来)?岁", cleaned))
if not answered_age or _MEDICAL_DRIFT_RE.search(cleaned):
return "四十来岁啦,怎么突然问这个?"
if re.search(r"(?:好困|困死|想睡)", latest):
if len(cleaned) > 60 or _MEDICAL_DRIFT_RE.search(cleaned):
return "困了就先眯一会儿,别硬撑着。"
if re.search(r"(?:你)?在干嘛|干什么呢", latest):
if _MEDICAL_DRIFT_RE.search(cleaned):
return "刚忙完,正好看到你消息。你呢?"
if re.search(r"你(?:叫)?什么(?:名字)?|怎么称呼你", latest):
agent = str(getattr(ai_config, "AI_AGENT_NAME", "客服") or "客服").strip()
if agent not in cleaned or _MEDICAL_DRIFT_RE.search(cleaned):
return f"我叫{agent},叫我{agent}就行。"
return cleaned
def _finalize_reply(text: str, chat_text: str, history: list = None) -> str:
cleaned = _humanize(_strip_thinking(text))
return _repair_obvious_mismatch(cleaned, chat_text, history)
def _chat_completion(messages: list, tools: list = None) -> dict:
"""
调用 chat/completions,返回 message 对象(含 content / tool_calls)。
@@ -234,9 +406,17 @@ def _chat_completion(messages: list, tools: list = None) -> dict:
def _user_turn(chat_text: str) -> dict:
latest = latest_customer_message(chat_text)
mode_instruction = _conversation_mode_instruction(latest)
return {
"role": "user",
"content": f"以下是最近的聊天记录:\n\n{chat_text}\n\n请生成回复:",
"content": (
f"{mode_instruction}\n"
f"【客户最后一句|唯一回答对象】\n{latest}\n\n"
f"【原始聊天片段|仅用于理解上下文】\n{chat_text}\n\n"
"像真人微信聊天;普通闲聊直接接话,只有对方明确担心或难受时才先关心。默认1~2句、20~60字,"
"最多问一个问题,不要列表、标题和客套收尾。"
),
}
@@ -248,12 +428,20 @@ def call_ai_text(chat_text: str, history: list = None) -> str:
"""
if _is_dify_endpoint():
print(" [AI] 检测到 Dify 接口,使用 chat-messages 协议")
return _humanize(_strip_thinking(_call_dify(_dify_query_from_chat(chat_text, history))))
return _finalize_reply(
_call_dify(_dify_query_from_chat(chat_text, history)),
chat_text,
history,
)
if getattr(ai_config, "AI_MCP_ENABLED", False):
try:
from mcp_bridge import run_coro
return _humanize(run_coro(_call_ai_text_with_mcp(chat_text, history)))
return _finalize_reply(
run_coro(_call_ai_text_with_mcp(chat_text, history)),
chat_text,
history,
)
except Exception as e:
print(f" [MCP] ⚠ 工具增强失败,回退普通回复: {e}")
@@ -261,7 +449,7 @@ def call_ai_text(chat_text: str, history: list = None) -> str:
messages += _history_messages(history)
messages.append(_user_turn(chat_text))
msg = _chat_completion(messages)
return _humanize(_strip_thinking(msg.get("content") or ""))
return _finalize_reply(msg.get("content") or "", chat_text, history)
async def _call_ai_text_with_mcp(chat_text: str, history: list = None) -> str:
@@ -348,7 +536,8 @@ def call_ai_vision(image_bytes: bytes, history: list = None) -> str:
"这是一个聊天对话窗口的截图。"
"左边的灰色气泡是对方(客户)发的消息,右边的蓝色气泡是我方之前的回复。"
"请只关注对方(客户)发的最后一条消息,针对那条消息直接回复。"
"只输出回复内容,不要描述图片,不要解释,不要加引号。"
"像真人微信聊天,先关心一句,再说重点;默认1~2句、20~60字,最多问一个问题。"
"只输出回复内容,不要描述图片,不要解释,不要加引号、标题或列表。"
),
},
{