From 7dadec279d2951bfe20220156a3d57fe107b09bb Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 14 Sep 2026 00:59:14 +0800 Subject: [PATCH] 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. --- app/service/customer_prompts.py | 5 ++-- app/service/customer_service.py | 44 +++++++++++++++++++++++---- app/service/tool_service.py | 3 ++ app/service/trade_action_service.py | 2 +- app/service/visitor_prompts.py | 5 ++-- app/service/visitor_service.py | 45 +++++++++++++++++++++------- app/tool/kb_tools.py | 8 ++++- docs/memory/MEMORY.md | 6 ++++ docs/memory/TODO.md | 14 +++++++++ tests/test_chat.py | 2 +- tests/test_kb_tools.py | 2 +- tests/test_wave2_prompts.py | 2 +- tests/test_wave3_customer_service.py | 2 +- 13 files changed, 115 insertions(+), 25 deletions(-) diff --git a/app/service/customer_prompts.py b/app/service/customer_prompts.py index ef53cc4..efc35f3 100644 --- a/app/service/customer_prompts.py +++ b/app/service/customer_prompts.py @@ -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", diff --git a/app/service/customer_service.py b/app/service/customer_service.py index ac28656..7c2c00c 100644 --- a/app/service/customer_service.py +++ b/app/service/customer_service.py @@ -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( diff --git a/app/service/tool_service.py b/app/service/tool_service.py index 89ed5d3..2fc539a 100644 --- a/app/service/tool_service.py +++ b/app/service/tool_service.py @@ -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: diff --git a/app/service/trade_action_service.py b/app/service/trade_action_service.py index c450eaa..becc27b 100644 --- a/app/service/trade_action_service.py +++ b/app/service/trade_action_service.py @@ -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"[《<]([^》>]+)[》>]") diff --git a/app/service/visitor_prompts.py b/app/service/visitor_prompts.py index 5436c1d..0b16d7b 100644 --- a/app/service/visitor_prompts.py +++ b/app/service/visitor_prompts.py @@ -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}" # --------------------------------------------------------------------------- diff --git a/app/service/visitor_service.py b/app/service/visitor_service.py index 950bac7..2ad6dd3 100644 --- a/app/service/visitor_service.py +++ b/app/service/visitor_service.py @@ -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"], diff --git a/app/tool/kb_tools.py b/app/tool/kb_tools.py index fcc877e..1926958 100644 --- a/app/tool/kb_tools.py +++ b/app/tool/kb_tools.py @@ -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"], diff --git a/docs/memory/MEMORY.md b/docs/memory/MEMORY.md index 5bf7a01..43a6685 100644 --- a/docs/memory/MEMORY.md +++ b/docs/memory/MEMORY.md @@ -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` 交付冻结。 **仓库地图:** diff --git a/docs/memory/TODO.md b/docs/memory/TODO.md index f8e60e8..60d86ce 100644 --- a/docs/memory/TODO.md +++ b/docs/memory/TODO.md @@ -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`。 diff --git a/tests/test_chat.py b/tests/test_chat.py index 3d1e27a..c1eebb7 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -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 diff --git a/tests/test_kb_tools.py b/tests/test_kb_tools.py index 4e6ac0b..2021e86 100644 --- a/tests/test_kb_tools.py +++ b/tests/test_kb_tools.py @@ -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 口径 diff --git a/tests/test_wave2_prompts.py b/tests/test_wave2_prompts.py index 89720ff..6b0546e 100644 --- a/tests/test_wave2_prompts.py +++ b/tests/test_wave2_prompts.py @@ -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 diff --git a/tests/test_wave3_customer_service.py b/tests/test_wave3_customer_service.py index 6803985..2ac0e97 100644 --- a/tests/test_wave3_customer_service.py +++ b/tests/test_wave3_customer_service.py @@ -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,