From bfbccd4471f2b2cf3a6a35331861877610823804 Mon Sep 17 00:00:00 2001 From: ai_backend_user Date: Wed, 30 Sep 2026 18:20:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(ORS-939):=20=E8=BD=A6=E5=9E=8B=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=BB=95=E5=BC=80=E5=8D=95=E8=BD=AE=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=9B=9E=E4=B8=BB=E5=BE=AA=E7=8E=AF=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=86=E5=8F=89=E4=B8=8D=E5=87=BA=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 车辆选项引导规则只注入主循环 prompt(_vehicle_mode_block),出选项的唯一出口 也只挂在主循环(JSON 顶层 vehicle_choices → _validate_vehicle_choices → _finalize_diagnosis → result.vehicle_choices → 前端气泡)。而诊断意图默认落 单轮分支——纯文本流式,既没有那条规则也没有能携带选项的字段,模型只能把分叉树 整张表摊平在正文里(实锤:卸货/放货异常 A~G 全表直出,前端零气泡)。 按 _has_draft / _pending_proj_ask 同款守卫把车型会话挡回主循环: - 诊断分支(含 nokb 短路)与诊断工具循环分支 - 闲聊分支与意图兜底分支(短回复常被判成闲聊,落进去会丢 last_choices 承接规则) nokb 轮沿用省检索纪律不等检索(plan-execute 开时仍执行规划工具)。 另给 _validate_vehicle_choices 的整批丢弃加日志:选项被丢用户只看到一句没按钮的 空问题,事后翻日志是唯一线索。 --- ai/agents/AiDiagnosisPlatform/pipeline.py | 67 +++++++++--- ai/tests/test_vehicle_mode_pipeline.py | 120 ++++++++++++++++++++++ 2 files changed, 172 insertions(+), 15 deletions(-) diff --git a/ai/agents/AiDiagnosisPlatform/pipeline.py b/ai/agents/AiDiagnosisPlatform/pipeline.py index 33a762a7..ed8350d7 100644 --- a/ai/agents/AiDiagnosisPlatform/pipeline.py +++ b/ai/agents/AiDiagnosisPlatform/pipeline.py @@ -5434,6 +5434,11 @@ def _validate_vehicle_choices(parsed: dict) -> Optional[List[str]]: return None clean.append(s) if not (2 <= len(clean) <= 3) or len(set(clean)) != len(clean): + # 整批丢弃必须留痕:选项没了,用户只会看到一句没按钮的空问题, + # 事后翻日志是唯一线索(分叉树节点最多 7 个分支,条数最容易越界)。 + logger.info(f"[vehicle_mode] 车辆选项校验未过(条数={len(clean)}" + f"{'/去重后变少' if len(set(clean)) != len(clean) else ''})" + f",整批丢弃: {clean}") return None return clean @@ -5725,6 +5730,17 @@ def _cancel_prefetch() -> None: # 与草稿守卫同理:挂着就回落主循环。 _pending_proj_ask = bool(getattr(state, "project_candidates", None)) + # 车型会话(XQE 试点)必须走主循环。车辆选项引导规则 + # (_vehicle_mode_block)和出选项的唯一出口(主循环 JSON 顶层 + # vehicle_choices → _validate_vehicle_choices → _finalize_diagnosis + # → result.vehicle_choices)都只挂在主循环上;单轮分支和工具循环 + # 分支是纯文本流式,既拿不到那条规则也没有能携带选项的字段。一旦 + # 落进去,模型只能把分叉树整个摊平在正文里(0930 实锤:卸货/放货 + # 异常整张 A~G 表直出,前端一个气泡都拿不到)。与 _has_draft / + # _pending_proj_ask 守卫同理:缺协议的分支不进。 + _in_vehicle_mode = bool( + (memory.metadata.get("vehicle_mode") or {}).get("model")) + if _intent == "ticket": # 提单意图 → 不需要知识库检索。 reference_docs = "(提单轮跳过检索)" @@ -5774,7 +5790,9 @@ def _swallow_prefetch(t: asyncio.Task) -> None: _prefetch_decide.add_done_callback(_swallow_prefetch) logger.info(f"[stream] decide 预跑已启动(与主LLM并行): " f"session={request.session_id}") - elif _intent == "diagnosis" and os.getenv("AI_DIAGNOSIS_TOOL_LOOP", "") == "1": + elif (_intent == "diagnosis" + and os.getenv("AI_DIAGNOSIS_TOOL_LOOP", "") == "1" + and not _in_vehicle_mode): # 诊断意图 → 走诊断工具循环(search_kb + submit_ticket)。 # LLM 自主决定:查不查知识库、查什么、查几次,再生成回答; # 也可顺势提单(submit_ticket 也在工具列表里)。 @@ -5792,7 +5810,8 @@ def _swallow_prefetch(t: asyncio.Task) -> None: # 项目题挂着未答同理(见 _pending_proj_ask 注释):序号/名称回答 # 需要主协议还原,单轮分支没有候选也没有协议。 _has_draft = bool(memory.metadata.get("ticket_draft")) - if not _needs_kb and not _has_draft and not _pending_proj_ask: + if (not _needs_kb and not _has_draft and not _pending_proj_ask + and not _in_vehicle_mode): # 诊断但无需知识库(续接轮/通用对话):单轮分支自带最近 8 轮对话 # + 省略式追问承接规则,靠上文即可作答,省下 rerank 等检索尾延。 # plan-execute 开时以规划器为准:它判了工具就执行(判无工具 → 空, @@ -5809,10 +5828,20 @@ def _swallow_prefetch(t: asyncio.Task) -> None: return # 诊断单轮:等资料(plan-execute=执行规划工具;否则并发检索) # → 小 prompt 1 次 LLM 直接回答(无工具往返) - reference_docs = await _get_reference_docs() - if _has_draft or _pending_proj_ask: - logger.info(f"[stream] 草稿/项目题挂着,诊断意图回落主循环" - f"(防序号回答掉进无候选无协议的单轮分支): " + # 车型会话的 nokb 轮仍要回落主循环(出选项的协议只在那儿),但 + # 沿用 nokb 的省检索纪律:续接语的检索词本身就是噪声,召回的无关 + # 内容反而会污染选项;plan-execute 开时照旧执行规划工具 + # (lookup_ticket 等要挂 state)。 + if _in_vehicle_mode and not _needs_kb and _plan_task is None: + reference_docs = "" + _cancel_prefetch() + logger.info(f"[stream] 车型会话 nokb 轮回落主循环(跳过检索): " + f"session={request.session_id}") + else: + reference_docs = await _get_reference_docs() + if _has_draft or _pending_proj_ask or _in_vehicle_mode: + logger.info(f"[stream] 草稿/项目题/车型会话挂着,诊断意图回落主循环" + f"(防序号回答或车辆选项掉进无候选无协议的单轮分支): " f"session={request.session_id}") else: logger.info(f"[stream] 诊断走单轮分支(服务端检索+1次LLM): session={request.session_id}") @@ -5826,9 +5855,13 @@ def _swallow_prefetch(t: asyncio.Task) -> None: # 「已取消草稿」而草稿根本没删。守卫:有待确认草稿时不走闲聊 # 分支,回落主循环(草稿轮铁律在那,取消/补充由 LLM 结构化判定)。 # 项目题挂着未答同理:答序号/「好的」要主协议还原或重新引导。 - if memory.metadata.get("ticket_draft") or _pending_proj_ask: - logger.info(f"[stream] 草稿/项目题挂着,闲聊意图回落主循环" - f"(防取消话术/序号回答掉进无处理能力的单轮分支): " + # 车型会话同理:短回复(选完选项后的「好的」「知道了」)常被判成 + # 闲聊,落单轮分支就没有车辆块了,上一轮 last_choices 的承接规则 + # 一并丢失,用户再追问一嘴分叉又得整张表直出。 + if (memory.metadata.get("ticket_draft") or _pending_proj_ask + or _in_vehicle_mode): + logger.info(f"[stream] 草稿/项目题/车型会话挂着,闲聊意图回落主循环" + f"(防取消话术/序号回答/车辆选项掉进无处理能力的单轮分支): " f"session={request.session_id}") reference_docs = await _get_reference_docs() else: @@ -5848,13 +5881,17 @@ def _swallow_prefetch(t: asyncio.Task) -> None: yield ev return else: - # 兜底(意图识别失败按 diagnosis 处理):等资料 → 单轮分支 + # 兜底(意图识别失败按 diagnosis 处理):等资料 → 单轮分支。 + # 车型会话回落主循环(车辆选项协议只在那儿)。 reference_docs = await _get_reference_docs() - logger.info(f"[stream] 意图兜底走单轮分支: session={request.session_id}") - async for ev in self._diagnosis_oneshot_branch( - request, state, memory, reference_docs, fill_problem_summary=True): - yield ev - return + if _in_vehicle_mode: + logger.info(f"[stream] 车型会话兜底回落主循环: session={request.session_id}") + else: + logger.info(f"[stream] 意图兜底走单轮分支: session={request.session_id}") + async for ev in self._diagnosis_oneshot_branch( + request, state, memory, reference_docs, fill_problem_summary=True): + yield ev + return t_stream["retrieve"] = round((time.perf_counter() - t_ret) * 1000) logger.info(f"[stream] 检索完成: {t_stream['retrieve']}ms, docs_len={len(reference_docs)}" diff --git a/ai/tests/test_vehicle_mode_pipeline.py b/ai/tests/test_vehicle_mode_pipeline.py index 53d7e52c..20f243a4 100644 --- a/ai/tests/test_vehicle_mode_pipeline.py +++ b/ai/tests/test_vehicle_mode_pipeline.py @@ -10,6 +10,7 @@ - 检索域换成车型域优先配额 """ import pytest +from unittest.mock import AsyncMock from ai.agents.AiDiagnosisPlatform.pipeline import ( AiDiagnosisPlatform, @@ -351,3 +352,122 @@ async def _boom_async(*a, **k): async def _boom_stream(*a, **k): raise AssertionError("故障码短接不应调用 LLM") yield # pragma: no cover + + +# ================================================================ +# 车型会话必须走主循环(0930 修复 A) +# +# 病根:车辆选项引导规则只注入主循环 prompt(_vehicle_mode_block),出选项的唯一 +# 出口也只挂在主循环(JSON 顶层 vehicle_choices → _validate_vehicle_choices → +# _finalize_diagnosis → result.vehicle_choices → 前端气泡)。诊断意图默认落单轮 +# 分支——纯文本流式,既没有那条规则也没有能携带选项的字段,模型只能把分叉树整张 +# 表摊平在正文里(实锤:卸货/放货异常 A~G 全表直出,前端零气泡)。 +# 本组用例走真实入口 _agent_think_stream,断言路由分流与 result 取数口。 +# ================================================================ + +async def _route_probe(platform, monkeypatch, query, *, vehicle): + """真实入口跑一轮,返回三分支各自的进入次数。""" + from ai.agents.AiDiagnosisPlatform.pipeline import AgentState + + calls = {"oneshot": 0, "mainloop": 0, "tool_loop": 0} + + async def _oneshot_spy(*a, **k): + calls["oneshot"] += 1 + return + yield # pragma: no cover + + async def _tool_loop_spy(*a, **k): + calls["tool_loop"] += 1 + return + yield # pragma: no cover + + _orig_build = platform._build_diagnosis_prompt + + def _build_spy(*a, **k): + calls["mainloop"] += 1 + return _orig_build(*a, **k) + + async def _dual(q, domain, top_k=8, query_filter=None): + return [], [] + + monkeypatch.setattr(platform, "_diagnosis_oneshot_branch", _oneshot_spy) + monkeypatch.setattr(platform, "_diagnosis_tool_loop_branch", _tool_loop_spy) + monkeypatch.setattr(platform, "_build_diagnosis_prompt", _build_spy) + platform._retriever.retrieve_domain_dual = _dual + + sid = f"route-{'vm' if vehicle else 'normal'}-{hash(query) & 0xffff}" + mem = await platform._memory_manager.get_memory(sid) + if vehicle: + mem.metadata["vehicle_mode"] = dict(_VM, opening_choices=list(_OPEN_CATS)) + state = AgentState(session_id=sid, phase="idle", + original_query=query, problem_summary=query) + request = type("R", (), {})() + request.session_id = sid + request.query = query + request.skip_retrieval = False + request.created_by = "tester" + async for _ev in platform._agent_think_stream(request, state, mem): + pass + return calls + + +async def test_vehicle_session_goes_main_loop(platform, monkeypatch): + """车型会话:点开场类目后追问分叉,必须回落主循环(车辆块+选项协议只在那儿)。 + 查询串就是开场气泡的原文(= 用户点按钮发出的那条),与实机复现同形。""" + calls = await _route_probe(platform, monkeypatch, "堆垛失败", vehicle=True) + assert calls["mainloop"] == 1, "车型会话必须构建主循环 prompt" + assert calls["oneshot"] == 0, "车型会话不得落单轮分支(无车辆块、无选项字段)" + + +async def test_normal_session_keeps_oneshot(platform, monkeypatch): + """常规会话同一问题仍走单轮分支——守卫不得外溢影响既有路径。""" + calls = await _route_probe(platform, monkeypatch, "堆垛失败", vehicle=False) + assert calls["oneshot"] == 1, "常规会话默认路径零变化" + assert calls["mainloop"] == 0 + + +async def test_vehicle_session_ignores_diagnosis_tool_loop(platform, monkeypatch): + """诊断工具循环开关开着也不接管车型会话(那里同样没有选项协议)。""" + monkeypatch.setenv("AI_DIAGNOSIS_TOOL_LOOP", "1") + calls = await _route_probe(platform, monkeypatch, "车辆停着不动", vehicle=True) + assert calls["tool_loop"] == 0, "车型会话不得进工具循环" + assert calls["mainloop"] == 1 + + +async def test_vehicle_session_choices_reach_result_event(platform, monkeypatch): + """出题轮的 vehicle_choices 必须随 result 下发并记 last_choices—— + 这是前端唯一的气泡来源,也是本修复的最终产物断言。""" + import json as _json + from ai.agents.AiDiagnosisPlatform.pipeline import AgentState + + async def _dual(q, domain, top_k=8, query_filter=None): + return [], [] + + platform._retriever.retrieve_domain_dual = _dual + _choices = ["货放歪/没对准", "卸货报识别超时"] + platform._llm_client.complete = AsyncMock(side_effect=lambda *a, **k: ( + '```json\n' + _json.dumps({ + "thinking": "知识库有互斥分支", + "action": "answer", + "message": "具体是什么表现?", + "vehicle_choices": _choices, + }, ensure_ascii=False) + '\n```')) + + sid = "vm-choices-result" + mem = await platform._memory_manager.get_memory(sid) + mem.metadata["vehicle_mode"] = dict(_VM, opening_choices=list(_OPEN_CATS)) + state = AgentState(session_id=sid, phase="idle", + original_query="堆垛失败", problem_summary="堆垛失败") + request = type("R", (), {})() + request.session_id = sid + request.query = "堆垛失败" + request.skip_retrieval = False + request.created_by = "tester" + result = None + async for ev in platform._agent_think_stream(request, state, mem): + if ev["event"] == "result": + result = ev["data"] + assert result is not None, "必须发出 result 事件" + assert result.get("vehicle_choices") == _choices, "选项必须随 result 下发到前端" + assert mem.metadata["vehicle_mode"]["last_choices"] == _choices, \ + "出题轮必须记 last_choices(下一轮不重复出题)" From a9a3eafbcb54ebc895373677ba28b7a28aaa4aea Mon Sep 17 00:00:00 2001 From: ai_backend_user Date: Wed, 30 Sep 2026 18:26:53 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(ORS-939):=20=E8=BD=A6=E8=BE=86=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E8=B6=85=E4=B8=8A=E9=99=90=E6=88=AA=E5=89=8D=203?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=95=B4=E6=89=B9=E4=B8=A2=E5=BC=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原实现是整体丢弃制:条数超 3(分叉树节点最多 7 个分支)或有重复项就整批丢掉, result 里没有 vehicle_choices。而车辆块明令"不要在正文里罗列选项或编号", 所以正文里也没有清单——用户只看到一句没按钮的空问题,比原来的"一大坨"更难用。 丢弃是零可选,截断只是少分支(还有输入框「其他」兜底),两者代价不对等。 改为:清洗(丢非字符串/空串/超 30 字/重复)→ 不足 2 条才整批丢弃 → 超 3 条 截前 3。下限保留是机械判断(一条选项不成题)。配套改 prompt 为"分支多于 3 个 时只给常见程度最高的 3 个,并按该顺序排列"——截断取的是模型排过序的前 3, 否则等于随便取 3 个。截断/丢弃都打日志,可观测 prompt 遵从率。 --- ai/agents/AiDiagnosisPlatform/pipeline.py | 33 ++++++----- ai/tests/test_vehicle_mode_pipeline.py | 68 +++++++++++++++++++++-- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/ai/agents/AiDiagnosisPlatform/pipeline.py b/ai/agents/AiDiagnosisPlatform/pipeline.py index ed8350d7..e8f50db1 100644 --- a/ai/agents/AiDiagnosisPlatform/pipeline.py +++ b/ai/agents/AiDiagnosisPlatform/pipeline.py @@ -483,8 +483,9 @@ def _vehicle_mode_block(memory) -> str: "【车辆】选项引导:当且仅当知识库检索内容对当前问题存在明确互斥的分支、" "需要确认用户属于哪种分支才能继续处理时,在输出 JSON 顶层增加 " "vehicle_choices 字段(字符串数组),填 2~3 个分支的简短短语——选项必须" - "来自知识库检索内容,不得编造;此时 message 只写一句引导问题,不要在正文" - "里罗列选项或编号。以下情形一律不输出该字段:知识库内容没有明确分支、" + "来自知识库检索内容,不得编造;分支多于 3 个时只给常见程度最高的 3 个," + "并按该顺序排列,其余分支由用户自行描述;此时 message 只写一句引导" + "问题,不要在正文里罗列选项或编号。以下情形一律不输出该字段:知识库内容没有明确分支、" "问题可以直接回答、需要用户开放描述、应当转工单、上一轮已给出选项尚未" "回答、当前在收集工单信息。用户问的是故障码时不出选项,引导其直接输入" "完整故障码即可。") @@ -5416,10 +5417,13 @@ def _parse_agent_output(self, raw: str) -> dict: @staticmethod def _validate_vehicle_choices(parsed: dict) -> Optional[List[str]]: - """车型追问选项机械校验(0930):整体丢弃制,不部分挽救。 + """车型追问选项机械校验(0930):超标截断,救不到下限才整批丢弃。 - 通过条件:2~3 个非空字符串、单条 ≤30 字、去重后不变少。 - 任何不满足 → None(当普通回复处理——正文从不含选项,前端零变化)。 + 清洗链:丢非字符串/空串/超 30 字/重复 → 不足 2 条整批丢弃 → 超 3 条截前 3。 + 截断而不整批丢弃:车辆块明令"不要在正文里罗列选项或编号",丢弃时用户 + 既没按钮也没清单,等于彻底卡死;截断至少给出 3 条 + 输入框「其他」兜底, + 正好落回分叉树「1/2/3/其他」的设计。截断取模型给的顺序(prompt 已要求 + 取最常见的 3 个),服务端不做语义判断,全是机械操作。 submit(话术已被服务端接管)由调用侧先行丢弃。 """ vc = parsed.get("vehicle_choices") @@ -5428,18 +5432,19 @@ def _validate_vehicle_choices(parsed: dict) -> Optional[List[str]]: clean = [] for x in vc: if not isinstance(x, str): - return None + continue s = x.strip() - if not s or len(s) > 30: - return None + if not s or len(s) > 30 or s in clean: + continue clean.append(s) - if not (2 <= len(clean) <= 3) or len(set(clean)) != len(clean): - # 整批丢弃必须留痕:选项没了,用户只会看到一句没按钮的空问题, - # 事后翻日志是唯一线索(分叉树节点最多 7 个分支,条数最容易越界)。 - logger.info(f"[vehicle_mode] 车辆选项校验未过(条数={len(clean)}" - f"{'/去重后变少' if len(set(clean)) != len(clean) else ''})" - f",整批丢弃: {clean}") + if len(clean) < 2: + # 一条选项不成题,无可展示——此时只能整批丢弃(正文里本就没有列表) + logger.info(f"[vehicle_mode] 车辆选项清洗后不足 2 条,整批丢弃: {vc}") return None + if len(clean) > 3: + # 截断只少分支,丢弃是零可选——取模型排过序的前 3,其余走输入框 + logger.info(f"[vehicle_mode] 车辆选项超上限({len(clean)}条),截前 3: {clean}") + clean = clean[:3] return clean # ================================================================ diff --git a/ai/tests/test_vehicle_mode_pipeline.py b/ai/tests/test_vehicle_mode_pipeline.py index 20f243a4..94f16329 100644 --- a/ai/tests/test_vehicle_mode_pipeline.py +++ b/ai/tests/test_vehicle_mode_pipeline.py @@ -55,6 +55,9 @@ def test_vehicle_block_renders_fields(): # 0930 选项引导规则 assert "vehicle_choices" in out assert "不得编造" in out + # 超上限时只给最常见的 3 个(与 _validate_vehicle_choices 的截断配套: + # 截断取前 3,所以 prompt 必须要求模型自己排好序) + assert "常见程度最高的 3 个" in out def test_vehicle_block_last_choices(): @@ -223,17 +226,30 @@ def test_validate_vehicle_choices_ok(): assert p._validate_vehicle_choices({"vehicle_choices": [" a ", "b"]}) == ["a", "b"] +def test_validate_vehicle_choices_truncates(): + """超上限截前 3 而不是整批丢弃:分叉树节点最多 7 个分支,全丢 = 用户零可选 + (正文里明令不许列选项,连清单都没有),截断只少分支且输入框「其他」兜底。""" + p = AiDiagnosisPlatform() + got = p._validate_vehicle_choices( + {"vehicle_choices": ["识别超时", "反复调整后失败", "货物滑动倒塌", "空库位报有物料"]}) + assert got == ["识别超时", "反复调整后失败", "货物滑动倒塌"] + assert len(p._validate_vehicle_choices( + {"vehicle_choices": [f"分支{i}" for i in range(7)]})) == 3 + # 清洗 + 去重后仍够 2 条 → 照样可展示(截断取模型给的顺序) + assert p._validate_vehicle_choices( + {"vehicle_choices": ["a", "a", "b", "c", "d"]}) == ["a", "b", "c"] + + def test_validate_vehicle_choices_rejects(): - # 整体丢弃制:任一不满足 → None(当普通回复处理) + """只有机械挽救后不足 2 条才整批丢弃——一条选项不成题,无从展示。""" p = AiDiagnosisPlatform() bad = [ None, [], # 非法/空 ["只有一项"], # <2 - ["a", "b", "c", "d"], # >3 - ["a", 123], # 非字符串元素 - ["a", " "], # 空串 - ["x" * 31, "y"], # 单条超 30 字 - ["同", "同"], # 去重变少 + ["同", "同"], # 去重后只剩 1 条 + ["a", 123], # 非字符串被清洗掉 → 1 条 + ["a", " "], # 空串被清洗掉 → 1 条 + ["x" * 31, "y"], # 超 30 字被清洗掉 → 1 条 ] for vc in bad: assert p._validate_vehicle_choices({"vehicle_choices": vc}) is None, vc @@ -471,3 +487,43 @@ async def _dual(q, domain, top_k=8, query_filter=None): assert result.get("vehicle_choices") == _choices, "选项必须随 result 下发到前端" assert mem.metadata["vehicle_mode"]["last_choices"] == _choices, \ "出题轮必须记 last_choices(下一轮不重复出题)" + + +async def test_vehicle_session_over_limit_still_gets_buttons(platform, monkeypatch): + """端到端兜住最坏情形:模型照抄 4 个分支(分叉树常见)→ 截前 3 下发。 + 回归「用户看到一句没按钮的空问题」——那条路径上正文里没有列表, + 选项一旦被整批丢掉,用户就彻底无从选择。""" + import json as _json + from ai.agents.AiDiagnosisPlatform.pipeline import AgentState + + async def _dual(q, domain, top_k=8, query_filter=None): + return [], [] + + platform._retriever.retrieve_domain_dual = _dual + # ## 4. 堆叠/堆垛失败 的 A~D 四个分支,模型原样照抄 + _branches = ["报识别超时", "反复调整后任务失败", "堆垛后货物滑动/倒塌", "空库位报识别到物料"] + platform._llm_client.complete = AsyncMock(side_effect=lambda *a, **k: ( + '```json\n' + _json.dumps({ + "thinking": "知识库有四个互斥分支", + "action": "answer", + "message": "具体是什么表现?", + "vehicle_choices": _branches, + }, ensure_ascii=False) + '\n```')) + + sid = "vm-choices-over-limit" + mem = await platform._memory_manager.get_memory(sid) + mem.metadata["vehicle_mode"] = dict(_VM, opening_choices=list(_OPEN_CATS)) + state = AgentState(session_id=sid, phase="idle", + original_query="堆垛失败", problem_summary="堆垛失败") + request = type("R", (), {})() + request.session_id = sid + request.query = "堆垛失败" + request.skip_retrieval = False + request.created_by = "tester" + result = None + async for ev in platform._agent_think_stream(request, state, mem): + if ev["event"] == "result": + result = ev["data"] + assert result is not None + assert result.get("vehicle_choices") == _branches[:3], \ + "超上限必须截前 3 下发,不得整批丢失(丢了用户就没按钮了)" From 5f164979e017f5d39b85609f6361bc23b3a42e73 Mon Sep 17 00:00:00 2001 From: ai_backend_user Date: Wed, 30 Sep 2026 19:20:54 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(ORS-939):=20=E8=BD=A6=E5=9E=8B=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E5=B7=B2=E4=BD=9C=E7=AD=94=E6=97=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E8=BF=87=E6=9C=9F=E7=9A=84=E3=80=8C=E5=B0=9A?= =?UTF-8?q?=E6=9C=AA=E5=9B=9E=E7=AD=94=E3=80=8D=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit last_choices 由上一轮写入,本轮构建 prompt 时它还在,但用户可能已经点选作答。 旧文案无条件断言「用户尚未回答」并禁止重复给出选项——该前提一旦失效,这条指令 会连下一层的新分叉一起压住(点选后沿树下钻的方向被摊成正文段落)。 改为按本轮 query 是否命中选项原文分流:命中则注入「用户已选择『X』,按该方向 继续;若其下仍有明确互斥的分支需再确认,照常输出下一层选项」,未命中保持旧文案; 不传 query 时与旧行为完全一致。两个调用点(_session_state_block、 _build_diagnosis_prompt)透传 original_query。 注:实机对照未复现出「点选后不出下一层」——旧代码在该场景下也能出题,本改动是指令 与事实对齐,不是被证实的 bug 修复。已验证叶子节点不会被带出多余选项。 --- ai/agents/AiDiagnosisPlatform/pipeline.py | 27 ++++++++--- ai/tests/test_vehicle_mode_pipeline.py | 59 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/ai/agents/AiDiagnosisPlatform/pipeline.py b/ai/agents/AiDiagnosisPlatform/pipeline.py index e8f50db1..460cb226 100644 --- a/ai/agents/AiDiagnosisPlatform/pipeline.py +++ b/ai/agents/AiDiagnosisPlatform/pipeline.py @@ -446,9 +446,12 @@ def _user_profile_block(state: "AgentState") -> str: return "\n".join(lines) + "\n" -def _vehicle_mode_block(memory) -> str: +def _vehicle_mode_block(memory, query: str = "") -> str: """【车辆】扫码定制模式块(XQE 试点,0929;0930 选项引导规则)。 + query:本轮用户输入原文,用于判定「上一轮选项是否已被作答」——见 last_choices + 段落。不传(默认空串)时按「尚未回答」处理,与旧行为一致。 + 会话经模式确认接口(/mode/confirm)注册 metadata["vehicle_mode"] 后, 三套 prompt(全量诊断/收集/快路径)均注入本块:车型信息已确认, 涉及车型的字段视为已回答不追问;追问选项引导规则(vehicle_choices @@ -476,9 +479,21 @@ def _vehicle_mode_block(memory) -> str: ] last = vm.get("last_choices") or [] if last: - lines.append( - "【车辆】上一轮你已给出选项(" + "/".join(last) + ")且用户尚未回答:" - "不要重复给出选项;用户以选项原文回应时,按该选项的含义理解并继续处理。") + _q = (query or "").strip() + if _q and _q in [str(c).strip() for c in last]: + # 用户点了选项原文作答——前提「尚未回答」不再成立。此时若照旧注入 + # 「不要重复给出选项」,模型会把**下一层**的新分叉一起压住(0930 实机: + # 点「报识别超时」后沿树继续下钻的方向被摊成正文段落,不出气泡)。 + # 选项原文另有一层用途:气泡不进 message 正文,模型只能靠这里还原 + # 「用户答的是哪一项」。 + lines.append( + "【车辆】上一轮你给出的选项是(" + "/".join(last) + "),用户已选择「" + + _q + "」:按该选项的含义继续处理。若该方向下知识库仍有明确互斥的" + "分支、需再确认属于哪种才能继续,照常输出 vehicle_choices 出下一层选项。") + else: + lines.append( + "【车辆】上一轮你已给出选项(" + "/".join(last) + ")且用户尚未回答:" + "不要重复给出选项;用户以选项原文回应时,按该选项的含义理解并继续处理。") lines.append( "【车辆】选项引导:当且仅当知识库检索内容对当前问题存在明确互斥的分支、" "需要确认用户属于哪种分支才能继续处理时,在输出 JSON 顶层增加 " @@ -513,7 +528,7 @@ def _session_state_block(state: "AgentState", memory) -> str: lines.extend(_up.splitlines()) # 车辆定制模式块(XQE 试点):扫码绑定会话的车型事实,先于工单事实; # 常规会话空串零开销 - _vm = _vehicle_mode_block(memory) + _vm = _vehicle_mode_block(memory, getattr(state, "original_query", "") or "") if _vm: lines.extend(_vm.splitlines()) _lt = state.last_submitted_ticket or {} @@ -1863,7 +1878,7 @@ def _build_diagnosis_prompt(self, state: AgentState, memory, reference_docs: str _user_block = _user_profile_block(state) # 车辆定制模式块(XQE 试点):收集/快路径 prompt 不走 _session_state_block, # 在此与【用户】块同位注入——三套 prompt 都知道车型已绑定 - _vehicle_block = _vehicle_mode_block(memory) + _vehicle_block = _vehicle_mode_block(memory, getattr(state, "original_query", "") or "") # 工单填写模式(对话路径 ticket_collecting / 按钮路径 prepare not_ready) if state.ticket_collecting: fields = "、".join(state.ticket_collecting) diff --git a/ai/tests/test_vehicle_mode_pipeline.py b/ai/tests/test_vehicle_mode_pipeline.py index 94f16329..ae6a4666 100644 --- a/ai/tests/test_vehicle_mode_pipeline.py +++ b/ai/tests/test_vehicle_mode_pipeline.py @@ -71,6 +71,65 @@ def test_vehicle_block_last_choices(): assert "上一轮你已给出选项" not in _vehicle_mode_block(_memory(_VM)) +# ---- 选项已作答(0930 修 B:下一层分叉被「不要重复给出选项」压住)---- +# last_choices 是**上一轮结束时**写的,本轮构建 prompt 时它还在;文案却断言 +# 「用户尚未回答」。用户点了选项原文后该前提已不成立,而「不要重复给出选项」 +# 会连**下一层**的新分叉一起压住(实机:点「报识别超时」沿树下钻的方向被摊 +# 成正文段落、不出气泡)。判定依据=本轮 query 是否为选项原文。 + +def test_vehicle_block_answered_choice_drops_stale_rule(): + vm = dict(_VM, last_choices=["报识别超时", "反复调整后任务失败"]) + out = _vehicle_mode_block(_memory(vm), "报识别超时") + assert "用户已选择「报识别超时」" in out + assert "且用户尚未回答" not in out + assert "不要重复给出选项" not in out + # 必须放行下一层选项:多层的分叉(1.1 行驶途中停下 → 界面提示 → A~F)才走得下去 + assert "出下一层选项" in out + + +def test_vehicle_block_answered_choice_tolerates_whitespace(): + vm = dict(_VM, last_choices=["报识别超时"]) + assert "用户已选择「报识别超时」" in _vehicle_mode_block(_memory(vm), " 报识别超时 ") + + +def test_vehicle_block_unanswered_keeps_stale_rule(): + """用户这轮没答选项(问的是别的)→ 保持旧规则,防重复出题。""" + vm = dict(_VM, last_choices=["报识别超时", "反复调整后任务失败"]) + out = _vehicle_mode_block(_memory(vm), "这台车现在在几号库位") + assert "尚未回答" in out + assert "不要重复给出选项" in out + assert "用户已选择" not in out + + +def test_vehicle_block_no_query_backward_compatible(): + """不传 query(旧调用形态)按尚未回答处理,行为零变化。""" + vm = dict(_VM, last_choices=["报识别超时"]) + assert "尚未回答" in _vehicle_mode_block(_memory(vm)) + + +def test_session_state_passes_query_to_vehicle_block(make_state): + """集成:_session_state_block 是本轮 query 的透传口(主循环 prompt 的注入口)。""" + state = make_state(original_query="报识别超时") + mem = _memory(dict(_VM, last_choices=["报识别超时", "反复调整后任务失败"])) + out = _session_state_block(state, mem) + assert "用户已选择「报识别超时」" in out + assert "且用户尚未回答" not in out + + +def test_collect_prompt_passes_query_to_vehicle_block(): + """收集/快路径 prompt(另一注入点)同样透传——三套 prompt 必须一致, + 否则收集轮里用户点了选项又会被当成新分叉压住。""" + from ai.agents.AiDiagnosisPlatform.pipeline import AgentState as _AS + + p = AiDiagnosisPlatform() + mem = _memory(dict(_VM, last_choices=["报识别超时", "反复调整后任务失败"])) + state = _AS(session_id="s-vm", phase="idle", + original_query="报识别超时", problem_summary="报识别超时") + out = p._build_diagnosis_prompt(state, mem, "") + assert "用户已选择「报识别超时」" in out + assert "且用户尚未回答" not in out + + def test_vehicle_block_minimal_fields(): out = _vehicle_mode_block(_memory({"model": "XQE", "domain": "xqe"})) assert "车型 XQE" in out