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:
2026-09-14 00:59:14 +08:00
parent 9e1a9b2a7e
commit 7dadec279d
13 changed files with 115 additions and 25 deletions
+3 -2
View File
@@ -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",
+39 -5
View File
@@ -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(
+3
View File
@@ -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:
+1 -1
View File
@@ -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"[《<]([^》>]+)[》>]")
+3 -2
View File
@@ -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}"
# ---------------------------------------------------------------------------
+35 -10
View File
@@ -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"],
+7 -1
View File
@@ -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"],
+6
View File
@@ -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` 交付冻结。
**仓库地图:**
+14
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 口径
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,