feat: 客服对话支持寒暄回复与跑题拒答
问候不再写入槽位;明显无关问题直接拒绝,知识库未命中时明确说明无法回答,并继续合理追问。 Co-authored-by: haijd <haijd@163.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 寒暄:不写槽、先回问候再追问
|
||||||
|
_GREETING_RE = re.compile(
|
||||||
|
r"^\s*(hi|hello|hey|hola|yo|你好|您好|哈喽|嗨|早上好|上午好|中午好|下午好|晚上好|早|在吗|在不在)[\s!!.。~~??]*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 跳过/暂无类有效回答
|
||||||
|
_SKIP_RE = re.compile(r"暂无|没有|无|不清楚|不确定|随便|都可以|未知|n/?a|none|no\b", re.I)
|
||||||
|
|
||||||
|
# 鲜境业务相关词:冷链温控 / 官网产品线
|
||||||
|
_DOMAIN_RE = re.compile(
|
||||||
|
r"冷链|冷库|仓储|仓库|运输|配送|保温|冷冻|冷藏|温控|温度|湿度|"
|
||||||
|
r"药品|疫苗|生鲜|实验室|产线|户外|监测|告警|报警|"
|
||||||
|
r"TagPlus|BlueTag|GAEA|Fresh\s*Tag|USB|PDF|蓝牙|网关|记录仪|"
|
||||||
|
r"不开箱|行程|温区|WiFi|4G|LAN|API|方案|产品|报价|选型|"
|
||||||
|
r"实时|读数|报告|传感器|物联网|物联|预算|网络|对接|系统|次数|可重复",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 明显跑题话题
|
||||||
|
_OFFTOPIC_RE = re.compile(
|
||||||
|
r"天气|气温|下雨|新闻|股票|基金|彩票|足球|篮球|电影|电视剧|游戏|"
|
||||||
|
r"笑话|段子|星座|运势|做饭|菜谱|旅游|机票|酒店|恋爱|八卦|"
|
||||||
|
r"政治|选举|战争|加密货币|比特币|chatgpt|写诗|作文|数学题|"
|
||||||
|
r"编程作业|leetcode|今天吃什么",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
_QUESTION_RE = re.compile(r"[??]|什么是|怎么|如何|能否|可以吗|多少|哪[个些]|是否|介绍一下|有没有")
|
||||||
|
|
||||||
|
|
||||||
|
def is_greeting(text: str) -> bool:
|
||||||
|
return bool(_GREETING_RE.match((text or "").strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def is_skip_answer(text: str) -> bool:
|
||||||
|
t = (text or "").strip()
|
||||||
|
if not t:
|
||||||
|
return False
|
||||||
|
if _SKIP_RE.fullmatch(t):
|
||||||
|
return True
|
||||||
|
return t in {"没有要求", "无要求", "没什么", "先不确定"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_domain_related(text: str) -> bool:
|
||||||
|
return bool(_DOMAIN_RE.search(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_question(text: str) -> bool:
|
||||||
|
return bool(_QUESTION_RE.search(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def has_offtopic_topic(text: str) -> bool:
|
||||||
|
return bool(_OFFTOPIC_RE.search(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def looks_off_topic(text: str) -> bool:
|
||||||
|
"""明显与冷链方案无关的输入;寒暄单独处理,不算跑题。"""
|
||||||
|
t = (text or "").strip()
|
||||||
|
if not t or is_greeting(t) or is_skip_answer(t):
|
||||||
|
return False
|
||||||
|
if is_domain_related(t):
|
||||||
|
return False
|
||||||
|
if _OFFTOPIC_RE.search(t):
|
||||||
|
return True
|
||||||
|
# 纯英文闲聊 / 极短无意义且不像在答槽
|
||||||
|
if re.fullmatch(r"[a-zA-Z\s!.?~]{1,40}", t) and not is_domain_related(t):
|
||||||
|
lower = t.lower()
|
||||||
|
if any(w in lower for w in ("hello", "hi", "hey", "thanks", "bye", "ok", "test")):
|
||||||
|
return False # greeting/ack 另判
|
||||||
|
if looks_like_question(t):
|
||||||
|
return True
|
||||||
|
# 较长文本却无任何业务词 → 视为跑题
|
||||||
|
if len(t) >= 8 and not is_domain_related(t) and looks_like_question(t):
|
||||||
|
return True
|
||||||
|
if len(t) >= 16 and not is_domain_related(t):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def should_write_slot(text: str) -> bool:
|
||||||
|
"""是否允许本轮用户话写入槽位。"""
|
||||||
|
t = (text or "").strip()
|
||||||
|
if not t or is_greeting(t):
|
||||||
|
return False
|
||||||
|
if looks_off_topic(t):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def greeting_reply(next_prompt: str | None) -> str:
|
||||||
|
base = "你好!我是鲜境方案客服,可以帮您梳理冷链温控场景并匹配官网产品组合。"
|
||||||
|
if next_prompt:
|
||||||
|
return f"{base}\n\n{next_prompt}"
|
||||||
|
return f"{base}\n\n您可以直接描述场景,例如:「冷库仓储,要实时告警」。"
|
||||||
|
|
||||||
|
|
||||||
|
def refuse_reply(reason: str = "off_topic", next_prompt: str | None = None) -> str:
|
||||||
|
if reason == "kb_miss":
|
||||||
|
msg = (
|
||||||
|
"抱歉,我无法回答这个问题。"
|
||||||
|
"当前知识库里没有足够相关依据,请换一个与鲜境冷链温控产品/场景相关的问题,"
|
||||||
|
"或描述您的应用现场,我再帮您追问选型。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
"抱歉,我无法回答这类问题。"
|
||||||
|
"我主要协助鲜境物联的冷链温控方案咨询(运输记录、蓝牙读数、仓储网关等)。"
|
||||||
|
)
|
||||||
|
if next_prompt:
|
||||||
|
return f"{msg}\n\n我们继续:{next_prompt}"
|
||||||
|
return msg
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .config_loader import business_config
|
from .config_loader import business_config
|
||||||
@@ -24,6 +25,67 @@ def retrieve_for_scheme(slots: dict[str, str], bundle: list[str]) -> RetrievalRe
|
|||||||
return RetrievalResult([], "ragflow", warning=str(exc))
|
return RetrievalResult([], "ragflow", warning=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_question(question: str, *, page_size: int = 3, min_score: float = 0.22) -> RetrievalResult:
|
||||||
|
"""自由问答检索;分数过低视为知识库未命中。"""
|
||||||
|
backend = (settings.retrieval_backend or "ragflow").lower()
|
||||||
|
q = (question or "").strip()
|
||||||
|
if not q:
|
||||||
|
return RetrievalResult([], backend or "none", warning="empty_query")
|
||||||
|
if backend == "mock":
|
||||||
|
return _retrieve_mock_question(q, min_score=min_score)
|
||||||
|
try:
|
||||||
|
client = RagflowClient()
|
||||||
|
dataset_ids = [item.strip() for item in settings.ragflow_dataset_ids.split(",") if item.strip()]
|
||||||
|
raw = client.retrieve(q, dataset_ids, page_size=page_size)
|
||||||
|
chunks = []
|
||||||
|
for index, item in enumerate(raw[:page_size]):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
score = item.get("similarity")
|
||||||
|
try:
|
||||||
|
score_f = float(score) if score is not None else 0.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
score_f = 0.0
|
||||||
|
if score_f < min_score:
|
||||||
|
continue
|
||||||
|
chunks.append(
|
||||||
|
{
|
||||||
|
"id": item.get("id") or f"rf-q-{index}",
|
||||||
|
"title": item.get("document_keyword")
|
||||||
|
or item.get("document_name")
|
||||||
|
or item.get("docnm_kwd")
|
||||||
|
or "知识片段",
|
||||||
|
"content": item.get("content") or item.get("content_with_weight") or "",
|
||||||
|
"score": score_f,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
warning = "" if chunks else "知识库未召回到足够相关的内容"
|
||||||
|
return RetrievalResult(chunks, "ragflow", warning=warning)
|
||||||
|
except RagflowError as exc:
|
||||||
|
return RetrievalResult([], "ragflow", warning=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _retrieve_mock_question(question: str, *, min_score: float = 0.22) -> RetrievalResult:
|
||||||
|
picked: list[dict[str, Any]] = []
|
||||||
|
for chunk in business_config.chunks:
|
||||||
|
blob = f"{chunk.get('title', '')} {chunk.get('content', '')} {' '.join(chunk.get('tags') or [])}"
|
||||||
|
hits = sum(1 for token in re_split_tokens(question) if token and token in blob)
|
||||||
|
if hits:
|
||||||
|
score = min(0.9, 0.2 + 0.15 * hits)
|
||||||
|
if score >= min_score:
|
||||||
|
item = dict(chunk)
|
||||||
|
item["score"] = score
|
||||||
|
picked.append(item)
|
||||||
|
if len(picked) >= 3:
|
||||||
|
break
|
||||||
|
warning = "" if picked else "知识库未召回到足够相关的内容"
|
||||||
|
return RetrievalResult(picked, "mock", warning=warning)
|
||||||
|
|
||||||
|
|
||||||
|
def re_split_tokens(text: str) -> list[str]:
|
||||||
|
return [t for t in re.split(r"[\s,,。;;??!!/|]+", text) if len(t) >= 2]
|
||||||
|
|
||||||
|
|
||||||
def _retrieve_mock(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
|
def _retrieve_mock(slots: dict[str, str], bundle: list[str]) -> RetrievalResult:
|
||||||
chunks = business_config.chunks
|
chunks = business_config.chunks
|
||||||
picked: list[dict[str, Any]] = []
|
picked: list[dict[str, Any]] = []
|
||||||
|
|||||||
+68
-13
@@ -4,7 +4,17 @@ from typing import Any
|
|||||||
|
|
||||||
from .config_loader import business_config
|
from .config_loader import business_config
|
||||||
from .decision import evaluate_tree
|
from .decision import evaluate_tree
|
||||||
from .retrieval import retrieve_for_scheme
|
from .intent import (
|
||||||
|
greeting_reply,
|
||||||
|
has_offtopic_topic,
|
||||||
|
is_domain_related,
|
||||||
|
is_greeting,
|
||||||
|
looks_like_question,
|
||||||
|
looks_off_topic,
|
||||||
|
refuse_reply,
|
||||||
|
should_write_slot,
|
||||||
|
)
|
||||||
|
from .retrieval import retrieve_for_scheme, retrieve_question
|
||||||
from .session_store import (
|
from .session_store import (
|
||||||
Session,
|
Session,
|
||||||
looks_like_handoff,
|
looks_like_handoff,
|
||||||
@@ -25,34 +35,79 @@ def handle_message(session_id: str, message: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
text = (message or "").strip()
|
text = (message or "").strip()
|
||||||
session.messages.append({"role": "user", "content": text})
|
session.messages.append({"role": "user", "content": text})
|
||||||
|
nxt = next_slot_prompt(business_config.slots, session.slots)
|
||||||
|
next_prompt = nxt["prompt"] if nxt else None
|
||||||
|
|
||||||
if looks_like_handoff(text, business_config.handoff_phrases):
|
if looks_like_handoff(text, business_config.handoff_phrases):
|
||||||
session.phase = "handoff"
|
session.phase = "handoff"
|
||||||
reply = "已为您标记转人工。请留下称呼与联系方式,销售同事会跟进。(原型:仅记录意图,未接 Chatwoot)"
|
reply = "已为您标记转人工。请留下称呼与联系方式,销售同事会跟进。(原型:仅记录意图,未接 Chatwoot)"
|
||||||
return _finish(session, reply, need_handoff=True)
|
return _finish(session, reply, need_handoff=True)
|
||||||
|
|
||||||
# fill slots while collecting
|
# 寒暄:回问候 + 合理追问,绝不把 hello 写进槽位
|
||||||
if session.phase in ("collecting", "scheme_ready"):
|
if is_greeting(text):
|
||||||
|
return _finish(session, greeting_reply(next_prompt), asking_slot=nxt["id"] if nxt else None)
|
||||||
|
|
||||||
|
# 明显跑题:拒答并拉回当前追问
|
||||||
|
if looks_off_topic(text):
|
||||||
|
return _finish(
|
||||||
|
session,
|
||||||
|
refuse_reply("off_topic", next_prompt),
|
||||||
|
asking_slot=nxt["id"] if nxt else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 业务相关自由提问:先查知识库;没命中则拒答,不强行灌槽
|
||||||
|
# 同时夹带明显跑题话题(如「记录仪能炒股吗」)→ 直接拒答
|
||||||
|
if looks_like_question(text) and is_domain_related(text):
|
||||||
|
if has_offtopic_topic(text):
|
||||||
|
return _finish(
|
||||||
|
session,
|
||||||
|
refuse_reply("off_topic", next_prompt),
|
||||||
|
asking_slot=nxt["id"] if nxt else None,
|
||||||
|
)
|
||||||
|
qa = retrieve_question(text)
|
||||||
|
if not qa.chunks:
|
||||||
|
return _finish(
|
||||||
|
session,
|
||||||
|
refuse_reply("kb_miss", next_prompt),
|
||||||
|
asking_slot=nxt["id"] if nxt else None,
|
||||||
|
)
|
||||||
|
lines = ["根据知识库,简要说明如下:", ""]
|
||||||
|
for citation in qa.chunks[:3]:
|
||||||
|
lines.append(f"- {citation.get('title')}:{citation.get('content')}")
|
||||||
|
if next_prompt and session.phase == "collecting":
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"若要继续匹配方案:{next_prompt}")
|
||||||
|
return _finish(session, "\n".join(lines), asking_slot=nxt["id"] if nxt else None)
|
||||||
|
|
||||||
|
# 正常收集槽位
|
||||||
|
if session.phase in ("collecting", "scheme_ready") and should_write_slot(text):
|
||||||
session.slots = naive_fill_slots(text, business_config.slots, session.slots)
|
session.slots = naive_fill_slots(text, business_config.slots, session.slots)
|
||||||
|
|
||||||
nxt = next_slot_prompt(business_config.slots, session.slots)
|
nxt = next_slot_prompt(business_config.slots, session.slots)
|
||||||
if nxt and session.phase == "collecting":
|
if nxt and session.phase == "collecting":
|
||||||
# first turn with empty slots: greet + first question
|
reply = nxt["prompt"]
|
||||||
if len(session.messages) == 1 and not any(session.slots.values()):
|
return _finish(session, reply, asking_slot=nxt["id"])
|
||||||
# put first user utterance into first slot if they already answered something
|
|
||||||
session.slots = naive_fill_slots(text, business_config.slots, session.slots)
|
|
||||||
nxt = next_slot_prompt(business_config.slots, session.slots)
|
|
||||||
if nxt:
|
|
||||||
reply = nxt["prompt"]
|
|
||||||
return _finish(session, reply, asking_slot=nxt["id"])
|
|
||||||
|
|
||||||
# all required (+ optional asked or filled) → scheme
|
# 槽位齐了 → 出方案;知识库完全无依据时明确说明无法背书
|
||||||
decision = evaluate_tree(business_config.rules, session.slots)
|
decision = evaluate_tree(business_config.rules, session.slots)
|
||||||
confidence = float(decision.get("confidence") or 0)
|
confidence = float(decision.get("confidence") or 0)
|
||||||
need_handoff = bool(decision.get("handoff")) or confidence < business_config.handoff_confidence_below
|
need_handoff = bool(decision.get("handoff")) or confidence < business_config.handoff_confidence_below
|
||||||
bundle = list(decision.get("product_bundle") or [])
|
bundle = list(decision.get("product_bundle") or [])
|
||||||
retrieval = retrieve_for_scheme(session.slots, bundle)
|
retrieval = retrieve_for_scheme(session.slots, bundle)
|
||||||
|
|
||||||
|
# 槽位内容本身不像业务需求,且检索也空 → 拒答
|
||||||
|
slot_blob = " ".join(str(v) for v in session.slots.values() if v)
|
||||||
|
if not is_domain_related(slot_blob) and not retrieval.chunks:
|
||||||
|
reply = refuse_reply("kb_miss", None)
|
||||||
|
session.phase = "collecting"
|
||||||
|
# 清空无效槽,避免一直卡在坏状态
|
||||||
|
session.slots = {}
|
||||||
|
first = next_slot_prompt(business_config.slots, session.slots)
|
||||||
|
if first:
|
||||||
|
reply = refuse_reply("off_topic", first["prompt"])
|
||||||
|
return _finish(session, reply, asking_slot=first["id"])
|
||||||
|
return _finish(session, reply)
|
||||||
|
|
||||||
scheme = {
|
scheme = {
|
||||||
"tech_line": decision.get("tech_line"),
|
"tech_line": decision.get("tech_line"),
|
||||||
"product_bundle": bundle,
|
"product_bundle": bundle,
|
||||||
@@ -93,7 +148,7 @@ def handle_message(session_id: str, message: str) -> dict[str, Any]:
|
|||||||
lines.append(f"- {citation['title']}:{citation['content']}")
|
lines.append(f"- {citation['title']}:{citation['content']}")
|
||||||
else:
|
else:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("知识库未给出可引用片段,请勿把上述组合当成已确认报价。")
|
lines.append("知识库未给出可引用片段,请勿把上述组合当成已确认报价。若问题超出资料范围,我无法保证回答准确。")
|
||||||
if need_handoff or retrieval.warning:
|
if need_handoff or retrieval.warning:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("建议转人工确认后再发给客户。可在右侧留下称呼和电话。")
|
lines.append("建议转人工确认后再发给客户。可在右侧留下称呼和电话。")
|
||||||
|
|||||||
@@ -48,9 +48,11 @@ def looks_like_handoff(text: str, phrases: list[str]) -> bool:
|
|||||||
|
|
||||||
def naive_fill_slots(message: str, slots_meta: list[dict[str, Any]], current: dict[str, str]) -> dict[str, str]:
|
def naive_fill_slots(message: str, slots_meta: list[dict[str, Any]], current: dict[str, str]) -> dict[str, str]:
|
||||||
"""每轮通常只推进一个槽:先做明确关键词,再把整句写入当前优先空槽。"""
|
"""每轮通常只推进一个槽:先做明确关键词,再把整句写入当前优先空槽。"""
|
||||||
|
from .intent import should_write_slot
|
||||||
|
|
||||||
updated = dict(current)
|
updated = dict(current)
|
||||||
text = message.strip()
|
text = message.strip()
|
||||||
if not text:
|
if not text or not should_write_slot(text):
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
touched = False
|
touched = False
|
||||||
|
|||||||
Reference in New Issue
Block a user