fix(memory): Address multi-turn dialogue defects and enhance context handling
- Implemented `_merged_items` and `_merged_memory_text` functions to consolidate consult and chitchat memories, improving context awareness in intent classification and response generation. - Updated intent prompts to include recent dialogue history, aiding in the resolution of ambiguous user queries. - Enhanced `search_knowledge` tool to utilize context window for better query understanding, addressing issues with omitted references in user inputs. - Fixed existing test cases to reflect changes in intent constants and ensure accurate context handling during tests. This update significantly improves the handling of multi-turn dialogues, ensuring a more coherent and contextually aware interaction for users.
This commit is contained in:
@@ -44,9 +44,10 @@ INTENT_SYSTEM = """你是金融客服(已登录客户模式)意图分类器
|
||||
11. "重新测评/重做风评/更新风评"→ risk_assessment_query(引导至 App/网点正式流程,Agent 不代填问卷)
|
||||
12. 用户同时问「有哪些 R4/R5 产品」并表达购买/申购意愿 → suitability_check(先列产品与适当性判定),不是 trade_action
|
||||
13. 用户点名具体产品问能否买、或「申购《某产品》」但未给金额 → suitability_check 或继续追问金额;禁止把「看到申购二字」一律当成已发起交易
|
||||
14. 只有用户明确要**提交**申购/赎回/转换且信息足够(或已在多轮中补齐槽位)时,才输出 trade_action"""
|
||||
14. 只有用户明确要**提交**申购/赎回/转换且信息足够(或已在多轮中补齐槽位)时,才输出 trade_action
|
||||
15. 用户输入可能含省略指代(如「那申购呢」「那这个能买吗」),须结合「近期对话」判断指代对象后再分类"""
|
||||
|
||||
INTENT_USER_TEMPLATE = "用户输入:{message}"
|
||||
INTENT_USER_TEMPLATE = "近期对话:\n{memory}\n\n用户输入:{message}"
|
||||
|
||||
VALID_INTENTS = frozenset({
|
||||
"holding_query",
|
||||
|
||||
@@ -162,6 +162,40 @@ def _session_memory_context(state: CustomerState) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _merged_items(state: CustomerState) -> list[dict]:
|
||||
"""consult + chitchat 记忆按 ts 合并(recall_memory 已载入 state)。"""
|
||||
items: list[dict] = []
|
||||
items.extend(state.get("consult_memory") or [])
|
||||
items.extend(state.get("chitchat_memory") or [])
|
||||
items.sort(key=lambda m: m.get("ts", 0))
|
||||
return items
|
||||
|
||||
|
||||
def _merged_memory_prompt(state: CustomerState, max_items: int = 16) -> str:
|
||||
"""合并两类近期对话为「用户/客服」文本(供意图/生成/解读/闲聊 prompt)。
|
||||
|
||||
与 _session_memory_context(英文 role 标签,供交易续轮)并存;数据源为
|
||||
recall_memory 已载入的 state 记忆,不重复回源 Redis。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for msg in _merged_items(state)[-max_items:]:
|
||||
role = "用户" if msg.get("role") == "user" else "客服"
|
||||
content = (msg.get("content") or "").strip()
|
||||
if content:
|
||||
lines.append(f"{role}: {content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _rag_query(state: CustomerState, max_items: int = 6) -> str:
|
||||
"""历史感知检索 query:拼最近几轮原始内容,解决「那申购呢」类省略指代。"""
|
||||
msg = state["message"]
|
||||
recent = [(m.get("content") or "").strip() for m in _merged_items(state)[-max_items:]]
|
||||
recent = [c for c in recent if c]
|
||||
if not recent:
|
||||
return msg
|
||||
return "\n".join(recent) + "\n" + msg
|
||||
|
||||
|
||||
def _trade_context_window(state: CustomerState) -> str:
|
||||
"""交易续轮上下文:Redis 双线记忆 + MySQL 会话落库(与 chat.insert_turn 对齐)。
|
||||
|
||||
@@ -240,7 +274,7 @@ def intent_classify(state: CustomerState) -> CustomerState:
|
||||
return {"intent": intent}
|
||||
|
||||
try:
|
||||
content = _invoke(INTENT_SYSTEM, INTENT_USER_TEMPLATE.format(message=msg))
|
||||
content = _invoke(INTENT_SYSTEM, INTENT_USER_TEMPLATE.format(memory=_merged_memory_prompt(state), message=msg))
|
||||
intent = content.strip().lower()
|
||||
if intent not in VALID_INTENTS:
|
||||
intent = "fallback"
|
||||
@@ -255,7 +289,7 @@ def intent_classify(state: CustomerState) -> CustomerState:
|
||||
|
||||
def rag_search(state: CustomerState) -> CustomerState:
|
||||
try:
|
||||
context, sources = VisitorRagService().retrieve(state["intent"], state["message"])
|
||||
context, sources = VisitorRagService().retrieve(state["intent"], _rag_query(state))
|
||||
except Exception:
|
||||
context, sources = "", []
|
||||
|
||||
@@ -356,7 +390,7 @@ def interpret(state: CustomerState) -> CustomerState:
|
||||
return {"reply": reply, "has_disclaimer": False}
|
||||
|
||||
try:
|
||||
mem = CustomerMemoryService().as_prompt_text(state["session_id"], "consult")
|
||||
mem = _merged_memory_prompt(state)
|
||||
content = _invoke(
|
||||
INTERPRET_SYSTEM,
|
||||
INTERPRET_USER_TEMPLATE.format(
|
||||
@@ -382,7 +416,7 @@ def generate(state: CustomerState) -> CustomerState:
|
||||
return {"reply": state.get("reply") or FALLBACK_TEXT, "has_disclaimer": False}
|
||||
|
||||
try:
|
||||
mem = CustomerMemoryService().as_prompt_text(state["session_id"], "consult")
|
||||
mem = _merged_memory_prompt(state)
|
||||
content = _invoke(
|
||||
GENERATE_SYSTEM,
|
||||
GENERATE_USER_TEMPLATE.format(
|
||||
@@ -414,7 +448,7 @@ def generate(state: CustomerState) -> CustomerState:
|
||||
|
||||
def chitchat(state: CustomerState) -> CustomerState:
|
||||
try:
|
||||
mem = CustomerMemoryService().as_prompt_text(state["session_id"], "chitchat")
|
||||
mem = _merged_memory_prompt(state)
|
||||
content = _invoke(
|
||||
CHITCHAT_SYSTEM,
|
||||
CHITCHAT_USER_TEMPLATE.format(
|
||||
|
||||
@@ -285,6 +285,9 @@ def run_tool(
|
||||
if tool_name in ("prepare_simulate_trade", "query_suitability_catalog"):
|
||||
call_kw["session_id"] = session_id
|
||||
call_kw["context_window"] = context_window
|
||||
elif tool_name == "search_knowledge":
|
||||
# T21 知识库:tool_node 注入的近期对话,供检索感知历史(省略指代)
|
||||
call_kw["context_window"] = context_window
|
||||
data = spec["func"](customer_id=customer_id, core_ro=core_ro, risk_repo=risk_repo, **call_kw)
|
||||
status, error_code = STATUS_SUCCESS, None
|
||||
except PermissionDenied as exc:
|
||||
|
||||
@@ -37,7 +37,7 @@ _TRADE_EXECUTE_KW = (
|
||||
"换购",
|
||||
"转成",
|
||||
)
|
||||
_TRADE_QUERY_MARKERS = ("记录", "明细", "流水", "历史", "查询", "最近买", "最近卖", "买过")
|
||||
_TRADE_QUERY_MARKERS = ("记录", "明细", "流水", "历史", "查询", "最近买", "最近卖", "买过", "了啥", "过啥", "了什么", "过什么", "了哪些", "过哪些")
|
||||
_R_LEVEL_IN_MSG = re.compile(r"[Rr]\s*[1-5]")
|
||||
_CATALOG_INQUIRY_KW = ("哪些", "有哪些", "什么产品", "列出", "罗列", "在售", "有卖", "有么")
|
||||
_BRACKET_PRODUCT_RE = re.compile(r"[《<]([^》>]+)[》>]")
|
||||
|
||||
@@ -22,9 +22,10 @@ INTENT_SYSTEM = """你是金融客服意图分类器。将用户输入分为以
|
||||
5. 涉及"明天会涨/收益预测/能赚多少"等走势预测一律 reject
|
||||
6. 涉及"和其他平台比/哪个好"等竞品对比一律 reject
|
||||
7. 涉及实时行情/实时价格查询一律 reject
|
||||
8. 仅当用户明确要求"转人工/人工客服",或涉及投诉/纠纷/账户异常/被盗等安全问题时,才输出 transfer_human"""
|
||||
8. 仅当用户明确要求"转人工/人工客服",或涉及投诉/纠纷/账户异常/被盗等安全问题时,才输出 transfer_human
|
||||
9. 用户输入可能含省略指代(如「那申购呢」「那这个呢」),须结合「近期对话」判断指代对象后再分类"""
|
||||
|
||||
INTENT_USER_TEMPLATE = "用户输入:{message}"
|
||||
INTENT_USER_TEMPLATE = "近期对话:\n{memory}\n\n用户输入:{message}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -109,12 +109,37 @@ _FAQ_KEYWORDS = (
|
||||
)
|
||||
|
||||
|
||||
def _visitor_memory_prompt(session_id: str, kind: str) -> str:
|
||||
"""读取游客短期记忆;Redis 不可用时降级为空(与 recall_memory 口径一致)。"""
|
||||
try:
|
||||
return VisitorMemoryService().as_prompt_text(session_id, kind)
|
||||
except Exception:
|
||||
return ""
|
||||
def _merged_items(state: VisitorState) -> list[dict]:
|
||||
"""consult + chitchat 记忆按 ts 合并(recall_memory 已载入 state)。"""
|
||||
items: list[dict] = []
|
||||
items.extend(state.get("consult_memory") or [])
|
||||
items.extend(state.get("chitchat_memory") or [])
|
||||
items.sort(key=lambda m: m.get("ts", 0))
|
||||
return items
|
||||
|
||||
|
||||
def _merged_memory_text(state: VisitorState, max_items: int = 16) -> str:
|
||||
"""合并两类近期对话为「用户/客服」文本(供意图分类与生成 prompt 使用)。
|
||||
|
||||
数据源为 recall_memory 已载入的 state 记忆,不重复回源 Redis。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for msg in _merged_items(state)[-max_items:]:
|
||||
role = "用户" if msg.get("role") == "user" else "客服"
|
||||
content = (msg.get("content") or "").strip()
|
||||
if content:
|
||||
lines.append(f"{role}: {content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _rag_query(state: VisitorState, max_items: int = 6) -> str:
|
||||
"""历史感知检索 query:拼最近几轮原始内容,解决「那申购呢」类省略指代。"""
|
||||
msg = state["message"]
|
||||
recent = [(m.get("content") or "").strip() for m in _merged_items(state)[-max_items:]]
|
||||
recent = [c for c in recent if c]
|
||||
if not recent:
|
||||
return msg
|
||||
return "\n".join(recent) + "\n" + msg
|
||||
|
||||
|
||||
def _degraded_reply_from_rag(rag_context: str) -> str | None:
|
||||
@@ -173,7 +198,7 @@ def intent_classify(state: VisitorState) -> VisitorState:
|
||||
# DeepSeek 分类
|
||||
try:
|
||||
llm = _build_llm()
|
||||
user_prompt = INTENT_USER_TEMPLATE.format(message=msg)
|
||||
user_prompt = INTENT_USER_TEMPLATE.format(memory=_merged_memory_text(state), message=msg)
|
||||
resp = llm.invoke([
|
||||
{"role": "system", "content": INTENT_SYSTEM},
|
||||
{"role": "user", "content": user_prompt},
|
||||
@@ -193,7 +218,7 @@ def rag_search(state: VisitorState) -> VisitorState:
|
||||
"""节点 3:RAG 检索(product_consult/policy_interpret/faq 分支)。"""
|
||||
intent = state["intent"]
|
||||
rag = VisitorRagService()
|
||||
context, sources = rag.retrieve(intent, state["message"])
|
||||
context, sources = rag.retrieve(intent, _rag_query(state))
|
||||
|
||||
if not context:
|
||||
# 空结果 → 走兜底
|
||||
@@ -223,7 +248,7 @@ def generate(state: VisitorState) -> VisitorState:
|
||||
|
||||
try:
|
||||
llm = _build_llm()
|
||||
mem_text = _visitor_memory_prompt(state["session_id"], "consult")
|
||||
mem_text = _merged_memory_text(state)
|
||||
user_prompt = GENERATE_USER_TEMPLATE.format(
|
||||
rag_context=state["rag_context"],
|
||||
memory=mem_text,
|
||||
@@ -257,7 +282,7 @@ def chitchat(state: VisitorState) -> VisitorState:
|
||||
"""节点 5:闲聊生成。"""
|
||||
try:
|
||||
llm = _build_llm()
|
||||
mem_text = _visitor_memory_prompt(state["session_id"], "chitchat")
|
||||
mem_text = _merged_memory_text(state)
|
||||
user_prompt = CHITCHAT_USER_TEMPLATE.format(
|
||||
memory=mem_text,
|
||||
message=state["message"],
|
||||
|
||||
@@ -30,13 +30,19 @@ def search_knowledge(
|
||||
customer_id: str = "",
|
||||
core_ro=None,
|
||||
risk_repo=None,
|
||||
context_window: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""产品知识检索(TopK chunks + 溯源清单)。
|
||||
|
||||
customer_id/core_ro/risk_repo 为 runner 恒传参数,本 Tool 不使用
|
||||
(公开知识,无归属语义);保留形参以满足统一签名。
|
||||
context_window 为 tool_node 注入的近期对话,拼接进 query 使检索
|
||||
感知历史(解决「那申购呢」类省略指代)。
|
||||
"""
|
||||
out = rag_service.search_knowledge(query, top_k=KB_TOP_K)
|
||||
effective_query = query
|
||||
if context_window and context_window.strip():
|
||||
effective_query = f"{context_window.strip()}\n{query}"
|
||||
out = rag_service.search_knowledge(effective_query, top_k=KB_TOP_K)
|
||||
return {
|
||||
"hit_count": len(out["results"]),
|
||||
"results": out["results"],
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
- ⚠️ **本轮未做完(如实声明)**:`/register` 注册链路仍**零覆盖** · `/app/home`·`/app` 未访问 · 面包屑/登出/会话侧栏切换未做浏览器验证 · `AnalystAssetsPage` 表单仍未提交
|
||||
- ⚠️ **已登记残留**:`risk_alert.pending_review` **41 → 35**(追加型审计表,按语义不还原)· `script_template` 的 `test:*` 行置 `is_active=0` 留库
|
||||
|
||||
**多轮对话三缺陷修复(visitor/customer/advisor · 2026-09-14 · 未 commit)** —— 起因「看看其他 agent 有没有类似缺陷」。
|
||||
- 三缺陷(RAG 只用当前消息 · 意图分类只用当前消息 · 闲聊+咨询记忆割裂从不合并)是**节点函数设计选择**,非 LangGraph 架构问题;全仓唯一历史感知路径是交易续轮(`_trade_context_window` / `_resolve_advisor_tool`)。
|
||||
- 修法(6 文件 +90/−20,问数线 `analyst_agent` 排除——单发 NL→SQL,无 RAG/意图/记忆):① visitor/customer 各加 `_merged_items`/`_merged_memory_text`/`_rag_query`,从已加载 state 合并 consult+chitchat(按 ts 排序),不再回 Redis 重取;② RAG query 拼接近期原文(解「那申购呢」省略指代);③ INTENT prompt 注入 `{memory}`;④ `tool_service.run_tool` 给 `search_knowledge` 转发 `context_window`(原死管道:tool_node 注入但 run_tool 丢弃);⑤ `kb_tools.search_knowledge` 加 `context_window` 参数拼接进 query。
|
||||
- 顺带收口 5 处既存测试 bug(均非本轮三缺陷引入,已 git stash 取证为 pristine HEAD 即失败):`fake_suit` 缺 `user_message` 形参 · `test_query_injected` 断言缺 `_context_window` · `VALID_INTENTS` 14 非 13(含 `trade_action`)· `looks_like_trade_execute` 把「申购了啥」误判 trade_action · `test_chat` mock 返 4 元组现返 5(+`pending_trade`)。
|
||||
- **F-β 选A 收口(2026-09-14)**:`test_convert_confirm.py` 的 28 条其实已在 `f856ab4`(客户 C3→C4)修掉;仅剩 `test_convert_accept.py::test_accept_uk_idem_race_falls_back_to_idempotent`(L1 同键异体→409 后,竞态用例「抢占者」份额 100≠50 四元组不匹配)→ 抢占者份额改 50。**五文件 99 passed / 0 failed**。
|
||||
|
||||
**工作分支:** 主线 **`merger`**(已纳入顾问线 + 基金转换线 · HEAD **`c09b987`**);保留 **`integrate/advisor-agent`** 作历史指针;历史 `risk-control-agent` 交付冻结。
|
||||
|
||||
**仓库地图:**
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
|
||||
## 进行中
|
||||
|
||||
**2026-09-14 · 多轮对话三缺陷修复 + 既存测试 bug 收口**(分支 `merger` · 未 commit)
|
||||
|
||||
> 起因「看看其他 agent 有没有类似缺陷」(visitor 三缺陷)。结论:三缺陷是节点函数设计选择,非 LangGraph 架构问题;问数线(analyst_agent)排除。
|
||||
|
||||
- [x] **多轮三缺陷修复(visitor/customer/advisor · 6 文件 +90/−20)**:合并 consult+chitchat 记忆(`_merged_items`/`_merged_memory_text`/`_rag_query`,按 ts 排序,从已加载 state 取,不再回 Redis 重取)· RAG query 拼接近期原文 · INTENT prompt 注入 `{memory}` · `run_tool` 转发 `context_window` 给 `search_knowledge`(原死管道)· `kb_tools.search_knowledge` 加 `context_window` 参数
|
||||
- [x] **既存测试 bug 收口(5 处,非本轮三缺陷引入)**:
|
||||
- `test_suitability_risk_level_normalized`:`fake_suit` 补 `user_message` 形参(`query_suitability` 已加该参)
|
||||
- `test_query_injected_from_user_message`:断言补 `_context_window`(`tool_node` 恒注入)
|
||||
- `test_intent_constants`:`VALID_INTENTS` 13→14(含 `trade_action`)
|
||||
- `test_keyword_route_data_queries[我上个月申购了啥]`:`looks_like_trade_execute` 误判 → `_TRADE_QUERY_MARKERS` 补「了/过+疑问词」查询标记
|
||||
- `test_chat_sync_persist_disclaimer`:mock 返 4→5 元组(+`pending_trade`)
|
||||
- [x] **验证**:`test_wave2_prompts` + `test_kb_tools` + `test_trade_action_service` + `test_chat` + `test_wave3_customer_service` 五文件 **98 passed / 0 failed**(含 `_TRADE_QUERY_MARKERS` 改动对 `test_trade_action_service` 零回归)
|
||||
- [x] **convert 五文件单测(F-β 选A · 收口)**:`test_convert_confirm.py` 的 28 条其实已在 `f856ab4`(客户 C3→C4)修掉,剩 `test_convert_accept.py::test_accept_uk_idem_race_falls_back_to_idempotent` 1 条——L1 同键异体→409 后,竞态用例「抢占者」份额 100≠50 四元组不匹配误走冲突 → 抢占者份额改 50 对齐。**验证:`test_trade_gateway` + `test_trade_flow_service` + `test_trade_action_service` + `test_convert_accept` + `test_convert_confirm` 五文件 99 passed / 0 failed**
|
||||
|
||||
**2026-09-13 · 端到端覆盖缺口补测(三条线全补 · 一线一包)**(分支 **`merger`** · 未 commit)
|
||||
|
||||
> 起因:「看看还有哪块没有端到端跑过」。方法:superpowers(先取证再下结论 · 先定根因再提修法)。计划归档 `docs/superpowers/plans/2026-09-13-e2e-coverage-gap.md`。
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ def test_chat_sync_persist_disclaimer_when_guard_skips_append(env, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
chat_mod,
|
||||
"run_customer_chat",
|
||||
lambda *a, **k: ("仅正文", True, "chit_chat", False),
|
||||
lambda *a, **k: ("仅正文", True, "chit_chat", False, None),
|
||||
)
|
||||
r = env["client"].post("/api/chat", json={"message": "hi"}, headers=CUSTOMER)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -184,7 +184,7 @@ class TestToolNodeInjection:
|
||||
|
||||
monkeypatch.setattr(tool_service, "run_tool", fake_run_tool)
|
||||
tool_node(self._state())
|
||||
assert captured["tool_input"] == {"query": "基金申购费率"}
|
||||
assert captured["tool_input"] == {"query": "基金申购费率", "_context_window": ""}
|
||||
|
||||
def test_no_input_for_core_tools(self, fake_rag, monkeypatch):
|
||||
# Core Tool(白名单无 query)不注入 tool_input,维持 T-04 口径
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.service.customer_prompts import (
|
||||
|
||||
|
||||
def test_intent_constants():
|
||||
assert len(VALID_INTENTS) == 13
|
||||
assert len(VALID_INTENTS) == 14
|
||||
assert len(DATA_QUERY_INTENTS) == 5
|
||||
# 数据查询意图均在合法集内
|
||||
assert DATA_QUERY_INTENTS <= VALID_INTENTS
|
||||
|
||||
@@ -159,7 +159,7 @@ def test_transaction_query_month_extraction(env, monkeypatch):
|
||||
def test_suitability_risk_level_normalized(env, monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_suit(cid, product_keyword=None, risk_level=None, repo=None):
|
||||
def fake_suit(cid, product_keyword=None, risk_level=None, user_message=None, repo=None):
|
||||
captured["risk_level"] = risk_level
|
||||
captured["product_keyword"] = product_keyword
|
||||
return {"tool": "suitability_check", "ok": True, "facts": [], "error": None,
|
||||
|
||||
Reference in New Issue
Block a user