From 62d12c5c0600e1083d698d55b505fe02b80a7289 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 13 Aug 2026 13:28:45 +0800 Subject: [PATCH] fix(rectification): render candidates and keep agent replies natural --- docs/BUG_HISTORY.md | 15 ++++ .../components/rectification-agentic-chat.tsx | 55 ++++--------- frontend/src/lib/agent-reply.ts | 18 ++++- .../src/lib/rectification-candidate-result.ts | 77 +++++++++++++++++++ frontend/src/mastra/agentic-rectification.ts | 9 ++- frontend/tests/agent-reply.test.ts | 12 ++- .../tests/rectification-agentic-entry.test.ts | 33 +++++++- .../rectification-candidate-result.test.ts | 56 ++++++++++++++ .../jyotish-birth-time-rectification/SKILL.md | 11 ++- .../references/candidate-comparison.md | 5 +- .../references/conversation-strategy.md | 17 ++-- 11 files changed, 246 insertions(+), 62 deletions(-) create mode 100644 frontend/src/lib/rectification-candidate-result.ts create mode 100644 frontend/tests/rectification-candidate-result.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e699a955..03a935a5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3050,3 +3050,18 @@ - 验证:工作流契约测试锁定 staging-ref dispatch、源 gate run 证明、无 main 引用、当前 HEAD 防陈旧发布、同一 gate artifact 的 controller/digest 校验和回滚祖先限制;远端 gate/deploy 与运行时 SHA 待本次 staging 发布记录。 - 防复发:测试环境的部署控制器必须来自被同一 quality gate 证明的 staging SHA;staging gate 名称不得恢复为默认 main 遗留监听器匹配的 `Staging Backend Quality Gate`,也不得重新引入 `workflow_run` 默认分支控制器或 staging/main 相等门禁。production 继续保持独立的 main/staging 收敛要求。 - 修复版本:本次 staging workflow 提交 + +## BUG-179 | 生时校正候选未显示卡片且回复被推荐问题与内部执行叙述干扰 + +- 状态:resolved(代码已验证,staging 部署与真实环境验收待发布流程) +- 首次发现:2026-08-13 +- 最近更新:2026-08-13 +- 影响面:V9 生时校正候选结果展示、对话输入区、Agent 回复自然度与候选采用后的会话延续。 +- 用户现象:服务器已有候选快照时,候选时间仍被写进 Agent 正文而没有出现既有候选卡;输入框下方固定出现三条推荐问题;Agent 还会叙述读取 Skill、加载 Case、调用工具和读取诊断等内部步骤。用户采用候选或表示没有更多事件后,回复容易被迫继续追问或引导结束、暂停、保存进度。 +- 触发条件:Case API 返回已解析为公开 camelCase 字段的 `latest_result`,同时生时校正复用通用 Agent 回复解析器及其三条建议兜底,并且 Prompt/Skill 未明确零问题回复、静默工具执行和无更多事件的停止边界。 +- 根因:候选组件仍按数据库 snake_case 字段读取 Case API 的公开 camelCase 快照,因缺少 `result_id` 判定而丢弃结果;通用 `parseAgentReply()` 在模型未提供恰好三条建议时自动生成三条兜底建议,生时校正又持有并渲染 `composer-suggestions`;Prompt/Skill 只限制“最多一个问题”,未明确完整回复可以零问题、内部执行不得进入正文、用户没有更多事件时不得继续轮换领域,也未划清候选卡与 Agent 正文的内容所有权。 +- 修复:新增客户端候选快照解析模块,按 Case API camelCase 外层字段读取结果并规范化候选内部字段;候选卡独占时间、排名、相对支持度、采用动作和选中状态,近乎并列且未开放确认门时不标记“当前推荐”;生时校正改用只提取正文与标题的解析入口,移除建议状态、持久化和 `composer-suggestions`;Prompt、Skill 与会话策略明确工具静默、零或一个问题、没有更多事件时停止领域轮换、采用后不强制追问或关闭,Session 依现有机制自然保留。 +- 验证:候选解析、回复解析、V9 入口静态合同与 V9 合同聚焦测试共 48 项通过;V9 Agent 聚焦测试 14 项通过;目标 ESLint 与变更源文件定向 TypeScript 检查通过,`git diff --check` 通过。仓库全量 TypeScript 仍命中既有的无关测试类型错误,未在本修复中扩展处理。 +- 防复发:Case API 的公开 DTO 必须有单一解析边界并由合同测试锁定;特殊业务流不得继承通用推荐问题兜底;Agent 正文、Activity 和结构化业务卡片必须各自拥有唯一内容职责,不得重复呈现或把内部执行过程写进自然回复。 +- 相关记录:BUG-173、BUG-174、BUG-175、BUG-176 +- 修复版本:本次提交(staging 精确 SHA 以推送结果为准;尚未部署) diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 8e5dba54..f607e501 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -2,8 +2,13 @@ import { ArrowUp } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { parseAgentReply } from "@/lib/agent-reply"; +import { parseAgentReplyBody } from "@/lib/agent-reply"; import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view"; +import { + isRecommendedRectificationCandidate, + parseRectificationCandidateResult, + type RectificationCandidateResult, +} from "@/lib/rectification-candidate-result"; import { membershipHref } from "@/lib/membership"; import { isPublicRectificationMethod, @@ -32,16 +37,7 @@ type PersistedTurn = Readonly<{ }> | null; }>; -type CandidateResult = Readonly<{ - resultId: string; - candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[]; - overallConfidence: "low" | "medium" | "high"; - selectionAllowed: boolean; - confirmationAllowed: boolean; - representativeTime: string | null; - selectedTime: string | null; - selectionKind: string | null; -}> | null; +type CandidateResult = RectificationCandidateResult | null; type RectificationAgenticChatProps = Readonly<{ caseId: string; @@ -161,7 +157,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null); const [candidateResult, setCandidateResult] = useState(null); const [acceptingTime, setAcceptingTime] = useState(null); - const [suggestions, setSuggestions] = useState([]); const conversation = useRef(null); const composer = useRef(null); const keyCounter = useRef(0); @@ -192,18 +187,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ); if (!response.ok) return null; const payload = await response.json().catch(() => null); - const latest = payload?.latest_result; - if (!latest || typeof latest.result_id !== "string") return null; - return { - resultId: latest.result_id, - candidates: Array.isArray(latest.candidates) ? latest.candidates : [], - overallConfidence: latest.overall_confidence === "high" || latest.overall_confidence === "medium" ? latest.overall_confidence : "low", - selectionAllowed: latest.selection_allowed === true, - confirmationAllowed: latest.confirmation_allowed === true, - representativeTime: typeof latest.representative_time === "string" ? latest.representative_time : null, - selectedTime: typeof latest.selected_time === "string" ? latest.selected_time : null, - selectionKind: typeof latest.selection_kind === "string" ? latest.selection_kind : null, - }; + return parseRectificationCandidateResult(payload?.latest_result); } catch { return null; } @@ -221,7 +205,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const trimmed = action === "message" ? messageText.trim() : ""; if ((action === "message" && !trimmed) || busy || readonly) return; setError(""); - setSuggestions([]); setPending(true); keyCounter.current += 1; @@ -300,11 +283,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { if (typeof event.type !== "string") continue; if (event.type === "answer.delta" && typeof event.text === "string") { raw += event.text; - const parsed = parseAgentReply(raw, "general"); + const parsed = parseAgentReplyBody(raw); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: parsed.text, state: "streaming" } : message)); - setSuggestions(parsed.suggestions); } else if (event.type === "run.failed") { streamFailed = true; } else if (event.type === "error") { @@ -330,18 +312,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } } - const parsed = parseAgentReply(raw, "general"); + const parsed = parseAgentReplyBody(raw); const succeeded = completed && !streamFailed && Boolean(parsed.text); setMessages((current) => succeeded ? current.map((message) => message.renderKey === assistantRenderKey - ? { ...message, text: parsed.text, suggestions: parsed.suggestions, state: "settled", receiptActivity: liveActivity } + ? { ...message, text: parsed.text, state: "settled", receiptActivity: liveActivity } : message) : current.filter((message) => message.renderKey !== assistantRenderKey)); - setSuggestions(succeeded ? parsed.suggestions : []); if (succeeded) { onMessagesChange?.([ ...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []), - { role: "assistant", text: parsed.text, suggestions: parsed.suggestions }, + { role: "assistant", text: parsed.text }, ]); onCompleted?.(); await loadCandidate().then((result) => { @@ -444,7 +425,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{candidateResult.candidates.map((candidate) => { const selected = candidateResult.selectedTime === candidate.time; - const recommended = !candidateResult.selectedTime && candidate.rank === 1; + const recommended = isRecommendedRectificationCandidate(candidateResult, candidate); return ( - ))} -
- )} -